diff --git a/CHANGELOG.md b/CHANGELOG.md index ec2ed185..e98292da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [0.16.7] - 2026-05-XX + +If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. + +## Added +- RateLimit header fields for HTTP ([draft-ietf-httpapi-ratelimit-headers-10](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers-10)) + +## Changed + +## Fixed + ## [0.16.6] - 2026-05-20 If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. diff --git a/Cargo.lock b/Cargo.lock index 5dc10c81..450a3618 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,7 +1084,7 @@ dependencies = [ [[package]] name = "common" -version = "0.16.6" +version = "0.16.7" dependencies = [ "aes-gcm-siv", "ahash", @@ -1633,7 +1633,7 @@ checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "dav" -version = "0.16.6" +version = "0.16.7" dependencies = [ "calcard", "chrono", @@ -1656,7 +1656,7 @@ dependencies = [ [[package]] name = "dav-proto" -version = "0.16.6" +version = "0.16.7" dependencies = [ "calcard", "chrono", @@ -1855,7 +1855,7 @@ dependencies = [ [[package]] name = "directory" -version = "0.16.6" +version = "0.16.7" dependencies = [ "ahash", "argon2", @@ -2096,7 +2096,7 @@ dependencies = [ [[package]] name = "email" -version = "0.16.6" +version = "0.16.7" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -2193,7 +2193,7 @@ dependencies = [ [[package]] name = "event_macro" -version = "0.16.6" +version = "0.16.7" dependencies = [ "proc-macro2", "quote", @@ -2690,7 +2690,7 @@ dependencies = [ [[package]] name = "groupware" -version = "0.16.6" +version = "0.16.7" dependencies = [ "ahash", "calcard", @@ -2969,7 +2969,7 @@ dependencies = [ [[package]] name = "http" -version = "0.16.6" +version = "0.16.7" dependencies = [ "async-stream", "base64 0.22.1", @@ -3068,7 +3068,7 @@ dependencies = [ [[package]] name = "http_proto" -version = "0.16.6" +version = "0.16.7" dependencies = [ "common", "compact_str", @@ -3340,7 +3340,7 @@ checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "imap" -version = "0.16.6" +version = "0.16.7" dependencies = [ "ahash", "common", @@ -3367,7 +3367,7 @@ dependencies = [ [[package]] name = "imap_proto" -version = "0.16.6" +version = "0.16.7" dependencies = [ "ahash", "chrono", @@ -3579,7 +3579,7 @@ dependencies = [ [[package]] name = "jmap" -version = "0.16.6" +version = "0.16.7" dependencies = [ "aes-gcm", "aes-gcm-siv", @@ -3667,7 +3667,7 @@ dependencies = [ [[package]] name = "jmap_proto" -version = "0.16.6" +version = "0.16.7" dependencies = [ "ahash", "calcard", @@ -4176,7 +4176,7 @@ dependencies = [ [[package]] name = "managesieve" -version = "0.16.6" +version = "0.16.7" dependencies = [ "ahash", "common", @@ -4289,7 +4289,7 @@ checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492" [[package]] name = "migration" -version = "0.16.6" +version = "0.16.7" dependencies = [ "base64 0.22.1", "common", @@ -4506,7 +4506,7 @@ dependencies = [ [[package]] name = "nlp" -version = "0.16.6" +version = "0.16.7" dependencies = [ "ahash", "bincode 1.3.3", @@ -5240,7 +5240,7 @@ dependencies = [ [[package]] name = "pop3" -version = "0.16.6" +version = "0.16.7" dependencies = [ "common", "directory", @@ -5412,7 +5412,7 @@ dependencies = [ [[package]] name = "proc_macros" -version = "0.16.6" +version = "0.16.7" dependencies = [ "proc-macro2", "quote", @@ -6965,7 +6965,7 @@ dependencies = [ [[package]] name = "services" -version = "0.16.6" +version = "0.16.7" dependencies = [ "aes-gcm", "aes-gcm-siv", @@ -7217,7 +7217,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smtp" -version = "0.16.6" +version = "0.16.7" dependencies = [ "ahash", "base64 0.22.1", @@ -7318,7 +7318,7 @@ dependencies = [ [[package]] name = "spam-filter" -version = "0.16.6" +version = "0.16.7" dependencies = [ "common", "compact_str", @@ -7428,7 +7428,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stalwart" -version = "0.16.6" +version = "0.16.7" dependencies = [ "common", "coordinator", @@ -7436,7 +7436,7 @@ dependencies = [ "directory", "email", "groupware", - "http 0.16.6", + "http 0.16.7", "http_proto", "imap", "jemallocator", @@ -7465,7 +7465,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "store" -version = "0.16.6" +version = "0.16.7" dependencies = [ "ahash", "arc-swap", @@ -7662,7 +7662,7 @@ dependencies = [ [[package]] name = "tests" -version = "0.16.6" +version = "0.16.7" dependencies = [ "ahash", "async-trait", @@ -7686,7 +7686,7 @@ dependencies = [ "form_urlencoded", "futures", "groupware", - "http 0.16.6", + "http 0.16.7", "http-body-util", "http_proto", "hyper", @@ -8222,7 +8222,7 @@ dependencies = [ [[package]] name = "trc" -version = "0.16.6" +version = "0.16.7" dependencies = [ "ahash", "base64 0.22.1", @@ -8315,7 +8315,7 @@ checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "types" -version = "0.16.6" +version = "0.16.7" dependencies = [ "blake3", "compact_str", @@ -8480,7 +8480,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "utils" -version = "0.16.6" +version = "0.16.7" dependencies = [ "ahash", "arcstr", diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 855605fd..7e34869c 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "common" -version = "0.16.6" +version = "0.16.7" edition = "2024" build = "build.rs" diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 8adbfe1a..0895241e 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -8,7 +8,7 @@ use super::AccessToken; use crate::{ Server, auth::{ - AccessScope, AccessTo, AccessTokenInner, AccountTenantIds, RECOVERY_ADMIN_ID, Permissions, + AccessScope, AccessTo, AccessTokenInner, AccountTenantIds, Permissions, RECOVERY_ADMIN_ID, permissions::{BuildPermissions, PermissionsListBuilder}, }, network::limiter::{ConcurrencyLimiter, LimiterResult}, @@ -695,6 +695,14 @@ impl AccessToken { .map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed()) } + pub fn concurrent_http_requests(&self) -> u64 { + self.inner + .concurrent_http_requests + .as_ref() + .map(|limiter| limiter.max_concurrent()) + .unwrap_or(0) + } + pub fn is_imap_request_allowed(&self) -> LimiterResult { self.inner .concurrent_imap_requests @@ -709,6 +717,14 @@ impl AccessToken { .map_or(LimiterResult::Disabled, |limiter| limiter.is_allowed()) } + pub fn concurrent_uploads(&self) -> u64 { + self.inner + .concurrent_uploads + .as_ref() + .map(|limiter| limiter.max_concurrent()) + .unwrap_or(0) + } + pub fn account_tenant_ids(&self) -> AccountTenantIds { AccountTenantIds { account_id: self.account_id(), diff --git a/crates/common/src/auth/rate_limit.rs b/crates/common/src/auth/rate_limit.rs index 56258d3b..92020446 100644 --- a/crates/common/src/auth/rate_limit.rs +++ b/crates/common/src/auth/rate_limit.rs @@ -18,10 +18,11 @@ impl Server { access_token: &AccessToken, addr: IpAddr, ) -> trc::Result> { - let is_rate_allowed = if let Some(rate) = &self.core.network.http.rate_authenticated { - self.is_ip_allowed(addr) - || self - .core + let rate_reset = if let Some(rate) = &self.core.network.http.rate_authenticated { + if self.is_ip_allowed(addr) { + None + } else { + self.core .storage .memory .is_rate_allowed( @@ -32,34 +33,42 @@ impl Server { ) .await .caused_by(trc::location!())? - .is_none() + .map(|reset| (reset, rate.count)) + } } else { - true + None }; - if is_rate_allowed { + if let Some((reset, count)) = rate_reset { + if access_token.has_permission(Permission::UnlimitedRequests) { + Ok(None) + } else { + Err(trc::LimitEvent::TooManyRequests + .into_err() + .ctx(trc::Key::Expires, reset) + .ctx(trc::Key::Limit, count)) + } + } else { match access_token.is_http_request_allowed() { LimiterResult::Allowed(in_flight) => Ok(Some(in_flight)), LimiterResult::Forbidden => { if access_token.has_permission(Permission::UnlimitedRequests) { Ok(None) } else { - Err(trc::LimitEvent::ConcurrentRequest.into_err()) + Err(trc::LimitEvent::ConcurrentRequest + .into_err() + .ctx(trc::Key::Limit, access_token.concurrent_http_requests())) } } LimiterResult::Disabled => Ok(None), } - } else if access_token.has_permission(Permission::UnlimitedRequests) { - Ok(None) - } else { - Err(trc::LimitEvent::TooManyRequests.into_err()) } } pub async fn is_http_anonymous_request_allowed(&self, addr: IpAddr) -> trc::Result<()> { if let Some(rate) = &self.core.network.http.rate_anonymous && !self.is_ip_allowed(addr) - && self + && let Some(reset) = self .core .storage .memory @@ -71,9 +80,11 @@ impl Server { ) .await .caused_by(trc::location!())? - .is_some() { - return Err(trc::LimitEvent::TooManyRequests.into_err()); + return Err(trc::LimitEvent::TooManyRequests + .into_err() + .ctx(trc::Key::Expires, reset) + .ctx(trc::Key::Limit, rate.count)); } Ok(()) } @@ -85,7 +96,9 @@ impl Server { if access_token.has_permission(Permission::UnlimitedRequests) { Ok(None) } else { - Err(trc::LimitEvent::ConcurrentUpload.into_err()) + Err(trc::LimitEvent::ConcurrentUpload + .into_err() + .ctx(trc::Key::Limit, access_token.concurrent_uploads())) } } LimiterResult::Disabled => Ok(None), diff --git a/crates/common/src/config/server/listener.rs b/crates/common/src/config/server/listener.rs index 94504dd6..87edf38f 100644 --- a/crates/common/src/config/server/listener.rs +++ b/crates/common/src/config/server/listener.rs @@ -121,9 +121,7 @@ impl Listeners { } { Ok(socket) => socket, Err(err) - if is_eafnosupport(&err) - && addr.is_ipv6() - && addr.ip().is_unspecified() => + if is_eafnosupport(&err) && addr.is_ipv6() && addr.ip().is_unspecified() => { let v4_addr = StdSocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), addr.port()); diff --git a/crates/common/src/config/smtp/resolver.rs b/crates/common/src/config/smtp/resolver.rs index 33db918c..ca388c20 100644 --- a/crates/common/src/config/smtp/resolver.rs +++ b/crates/common/src/config/smtp/resolver.rs @@ -144,7 +144,11 @@ impl Resolvers { }; let mut connection = ConnectionConfig::new(protocol); connection.port = port; - resolver_config.add_name_server(NameServerConfig::new(ip, true, vec![connection])); + resolver_config.add_name_server(NameServerConfig::new( + ip, + true, + vec![connection], + )); } opts.num_concurrent_reqs = resolver.concurrency as usize; diff --git a/crates/common/src/enterprise/license.rs b/crates/common/src/enterprise/license.rs index c10944a7..2f3980c3 100644 --- a/crates/common/src/enterprise/license.rs +++ b/crates/common/src/enterprise/license.rs @@ -20,9 +20,9 @@ */ use crate::manager::fetch_resource; +use aws_lc_rs::signature::{ED25519, UnparsedPublicKey}; use base64::{Engine, engine::general_purpose::STANDARD}; use hyper::{HeaderMap, header::AUTHORIZATION}; -use aws_lc_rs::signature::{ED25519, UnparsedPublicKey}; use std::{ fmt::{Display, Formatter}, time::Duration, diff --git a/crates/common/src/network/acme/account.rs b/crates/common/src/network/acme/account.rs index ac8115d3..318ae9ba 100644 --- a/crates/common/src/network/acme/account.rs +++ b/crates/common/src/network/acme/account.rs @@ -7,12 +7,12 @@ use super::jose::{Body, eab_sign, sign}; use crate::network::acme::http::{get_header, https}; use crate::network::acme::{AcmeError, AcmeResult, Directory}; +use aws_lc_rs::rand::SystemRandom; +use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, EcdsaSigningAlgorithm}; use base64::Engine; use base64::engine::general_purpose::{self, URL_SAFE_NO_PAD}; use registry::schema::structs::AcmeProvider; use reqwest::Method; -use aws_lc_rs::rand::SystemRandom; -use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, EcdsaSigningAlgorithm}; use utils::sanitize_email; static ALG: &EcdsaSigningAlgorithm = &ECDSA_P256_SHA256_FIXED_SIGNING; diff --git a/crates/common/src/network/acme/directory.rs b/crates/common/src/network/acme/directory.rs index a3354289..9ca2b9b1 100644 --- a/crates/common/src/network/acme/directory.rs +++ b/crates/common/src/network/acme/directory.rs @@ -6,8 +6,6 @@ // Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0. -use std::time::Duration; - use super::jose::{ key_authorization, key_authorization_sha256, key_authorization_sha256_base64, sign, }; @@ -16,14 +14,15 @@ use crate::network::acme::{ AcmeError, AcmeResult, Auth, AuthStatus, Challenge, ChallengeType, Directory, Identifier, Order, SerializedCert, }; +use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, EcdsaSigningAlgorithm}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use rcgen::{CustomExtension, KeyPair, PKCS_ECDSA_P256_SHA256}; use registry::schema::structs::AcmeProvider; use reqwest::Method; -use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, EcdsaSigningAlgorithm}; use serde::de::DeserializeOwned; use serde_json::json; +use std::time::Duration; use store::Serialize; use store::write::Archiver; @@ -161,9 +160,8 @@ impl AcmeRequestBuilder { })?; let key_auth = key_authorization_sha256(&self.key_pair, challenge_token)?; params.custom_extensions = vec![CustomExtension::new_acme_identifier(key_auth.as_ref())]; - let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).map_err(|err| { - AcmeError::Crypto(format!("Failed to generate key pair: {}", err)) - })?; + let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) + .map_err(|err| AcmeError::Crypto(format!("Failed to generate key pair: {}", err)))?; let cert = params.self_signed(&key_pair).map_err(|err| { AcmeError::Crypto(format!( "Failed to generate TLS-ALPN-01 certificate: {}", diff --git a/crates/common/src/network/acme/jose.rs b/crates/common/src/network/acme/jose.rs index 94c3987e..9b1ec159 100644 --- a/crates/common/src/network/acme/jose.rs +++ b/crates/common/src/network/acme/jose.rs @@ -7,12 +7,12 @@ // Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0. use crate::network::acme::{AcmeError, AcmeResult}; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; use aws_lc_rs::digest::{Digest, SHA256, digest}; use aws_lc_rs::hmac; use aws_lc_rs::rand::SystemRandom; use aws_lc_rs::signature::{EcdsaKeyPair, KeyPair}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use serde::Serialize; pub(crate) fn sign( diff --git a/crates/common/src/network/acme/order.rs b/crates/common/src/network/acme/order.rs index b52678e2..4edf668f 100644 --- a/crates/common/src/network/acme/order.rs +++ b/crates/common/src/network/acme/order.rs @@ -43,8 +43,7 @@ impl AcmeRequestBuilder { } else { let server_name = server.core.network.server_name.as_str(); let domain_suffix = format!(".{domain}"); - let matches_zone = - |name: &str| name == domain || name.ends_with(&domain_suffix); + let matches_zone = |name: &str| name == domain || name.ends_with(&domain_suffix); // Add technical domains let mut domains = HOSTNAMES @@ -106,8 +105,7 @@ impl AcmeRequestBuilder { OrderStatus::Pending => { if matches!(self.challenge, ChallengeType::Dns01) { for url in &order.authorizations { - self.authorize(server, url, dns_parameters.as_ref()) - .await?; + self.authorize(server, url, dns_parameters.as_ref()).await?; } } else { let auth_futures = order diff --git a/crates/common/src/network/autoconfig/legacy_autoconfig.rs b/crates/common/src/network/autoconfig/legacy_autoconfig.rs index 28c781e4..2f2e2cbb 100644 --- a/crates/common/src/network/autoconfig/legacy_autoconfig.rs +++ b/crates/common/src/network/autoconfig/legacy_autoconfig.rs @@ -21,12 +21,12 @@ impl Server { .unwrap_or_default() .to_lowercase(); let default_host = &self.core.network.server_name; - let (emailaddress, domain) = - if let Some((_, domain)) = emailaddress_param.rsplit_once('@') { - (emailaddress_param.as_str(), domain) - } else { - ("%EMAILADDRESS%", default_host.as_str()) - }; + let (emailaddress, domain) = if let Some((_, domain)) = emailaddress_param.rsplit_once('@') + { + (emailaddress_param.as_str(), domain) + } else { + ("%EMAILADDRESS%", default_host.as_str()) + }; // Build XML response let mut config = String::with_capacity(1024); diff --git a/crates/common/src/storage/blob.rs b/crates/common/src/storage/blob.rs index 86a6f34d..ca957ffe 100644 --- a/crates/common/src/storage/blob.rs +++ b/crates/common/src/storage/blob.rs @@ -24,8 +24,17 @@ const COUNT_BYTES: u32 = 20; const COUNT_SHIFT: u32 = 64 - COUNT_BYTES; const SIZE_MASK: u64 = (1u64 << COUNT_SHIFT) - 1; +pub struct BlobQuotaStatus { + pub allowed: bool, + pub expires_in: u64, +} + impl Server { - pub async fn blob_has_quota(&self, account_id: u32, bytes: usize) -> trc::Result { + pub async fn blob_has_quota( + &self, + account_id: u32, + bytes: usize, + ) -> trc::Result { if self.core.jmap.upload_tmp_quota_size > 0 || self.core.jmap.upload_tmp_quota_amount > 0 { let now = now(); let range_start = now / self.core.jmap.upload_tmp_ttl; @@ -50,13 +59,21 @@ impl Server { let count = v >> COUNT_SHIFT; let size = v & SIZE_MASK; - (self.core.jmap.upload_tmp_quota_amount == 0 + let allowed = (self.core.jmap.upload_tmp_quota_amount == 0 || count <= self.core.jmap.upload_tmp_quota_amount as u64) && (self.core.jmap.upload_tmp_quota_size == 0 - || size <= self.core.jmap.upload_tmp_quota_size as u64) + || size <= self.core.jmap.upload_tmp_quota_size as u64); + + BlobQuotaStatus { + allowed, + expires_in, + } }) } else { - Ok(true) + Ok(BlobQuotaStatus { + allowed: true, + expires_in: 0, + }) } } diff --git a/crates/common/src/telemetry/webhooks/mod.rs b/crates/common/src/telemetry/webhooks/mod.rs index c02e550e..ff8047ad 100644 --- a/crates/common/src/telemetry/webhooks/mod.rs +++ b/crates/common/src/telemetry/webhooks/mod.rs @@ -5,8 +5,8 @@ */ use crate::{LONG_1Y_SLUMBER, config::telemetry::WebhookTracer}; -use base64::{Engine, engine::general_purpose::STANDARD}; use aws_lc_rs::hmac; +use base64::{Engine, engine::general_purpose::STANDARD}; use serde::Serialize; use std::{ sync::{ diff --git a/crates/dav-proto/Cargo.toml b/crates/dav-proto/Cargo.toml index 1bea0776..c2511bf5 100644 --- a/crates/dav-proto/Cargo.toml +++ b/crates/dav-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dav-proto" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/dav/Cargo.toml b/crates/dav/Cargo.toml index 06a604f6..f232c534 100644 --- a/crates/dav/Cargo.toml +++ b/crates/dav/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dav" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index c032682b..a07bd2e1 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "directory" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml index 965fe163..9a634257 100644 --- a/crates/email/Cargo.toml +++ b/crates/email/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "email" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/groupware/Cargo.toml b/crates/groupware/Cargo.toml index 631daf3b..97cb5a1e 100644 --- a/crates/groupware/Cargo.toml +++ b/crates/groupware/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "groupware" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/http-proto/Cargo.toml b/crates/http-proto/Cargo.toml index 35686105..4a1d255b 100644 --- a/crates/http-proto/Cargo.toml +++ b/crates/http-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "http_proto" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/http/Cargo.toml b/crates/http/Cargo.toml index 61ef4f26..64f3918e 100644 --- a/crates/http/Cargo.toml +++ b/crates/http/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "http" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/imap-proto/Cargo.toml b/crates/imap-proto/Cargo.toml index c69792e7..28f3195d 100644 --- a/crates/imap-proto/Cargo.toml +++ b/crates/imap-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap_proto" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index 6cadfab2..70c9978c 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/jmap-proto/Cargo.toml b/crates/jmap-proto/Cargo.toml index 715a993c..589700e8 100644 --- a/crates/jmap-proto/Cargo.toml +++ b/crates/jmap-proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap_proto" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/jmap-proto/src/error/request.rs b/crates/jmap-proto/src/error/request.rs index e2a6d3ea..e0f39114 100644 --- a/crates/jmap-proto/src/error/request.rs +++ b/crates/jmap-proto/src/error/request.rs @@ -34,6 +34,33 @@ pub enum RequestErrorType { Other, } +#[derive(Debug, Clone)] +pub struct RateLimitPolicy { + pub name: &'static str, + pub limit: u64, + pub remaining: u64, + pub window: Option, + pub reset: Option, + pub unit: RateLimitUnit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RateLimitUnit { + Requests, + ContentBytes, + ConcurrentRequests, +} + +impl RateLimitUnit { + pub fn as_str(self) -> &'static str { + match self { + RateLimitUnit::Requests => "requests", + RateLimitUnit::ContentBytes => "content-bytes", + RateLimitUnit::ConcurrentRequests => "concurrent-requests", + } + } +} + #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct RequestError<'x> { #[serde(rename = "type")] @@ -44,6 +71,10 @@ pub struct RequestError<'x> { pub detail: Cow<'x, str>, #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, + #[serde(skip)] + pub rate_limit: Vec, + #[serde(skip)] + pub retry_after: Option, } impl<'x> RequestError<'x> { @@ -58,9 +89,28 @@ impl<'x> RequestError<'x> { title: Some(title.into()), detail: detail.into(), limit: None, + rate_limit: Vec::new(), + retry_after: None, } } + pub fn with_rate_limit(mut self, policy: RateLimitPolicy) -> Self { + if let Some(reset) = policy.reset + && self.retry_after.is_none_or(|r| reset > r) + { + self.retry_after = Some(reset); + } + self.rate_limit.push(policy); + self + } + + pub fn with_retry_after(mut self, seconds: u64) -> Self { + if self.retry_after.is_none_or(|r| seconds > r) { + self.retry_after = Some(seconds); + } + self + } + pub fn internal_server_error() -> Self { RequestError::blank( 500, @@ -101,7 +151,7 @@ impl<'x> RequestError<'x> { pub fn over_blob_quota(max_files: usize, max_bytes: usize) -> Self { RequestError::blank( - 403, + 429, "Quota exceeded", format!( "You have exceeded the blob upload quota of {} files or {} bytes.", @@ -171,6 +221,8 @@ impl<'x> RequestError<'x> { } .into(), limit: Some(limit_type), + rate_limit: Vec::new(), + retry_after: None, } } @@ -201,6 +253,8 @@ impl<'x> RequestError<'x> { capability ) .into(), + rate_limit: Vec::new(), + retry_after: None, } } @@ -211,6 +265,8 @@ impl<'x> RequestError<'x> { title: None, status: 400, detail: format!("Failed to parse JSON: {detail}").into(), + rate_limit: Vec::new(), + retry_after: None, } } @@ -221,12 +277,137 @@ impl<'x> RequestError<'x> { title: None, status: 400, detail: detail.into(), + rate_limit: Vec::new(), + retry_after: None, } } } +impl RateLimitPolicy { + pub fn new(name: &'static str, limit: u64) -> Self { + RateLimitPolicy { + name, + limit, + remaining: 0, + window: None, + reset: None, + unit: RateLimitUnit::Requests, + } + } + + pub fn with_window(mut self, window: u64) -> Self { + self.window = Some(window); + self + } + + pub fn with_reset(mut self, reset: u64) -> Self { + self.reset = Some(reset); + self + } + + pub fn with_remaining(mut self, remaining: u64) -> Self { + self.remaining = remaining; + self + } + + pub fn with_unit(mut self, unit: RateLimitUnit) -> Self { + self.unit = unit; + self + } + + pub fn fmt_policy(&self, out: &mut String) { + use std::fmt::Write; + let _ = write!(out, "\"{}\";q={}", self.name, self.limit); + if let Some(window) = self.window { + let _ = write!(out, ";w={window}"); + } + if !matches!(self.unit, RateLimitUnit::Requests) { + let _ = write!(out, ";qu=\"{}\"", self.unit.as_str()); + } + } + + pub fn fmt_state(&self, out: &mut String) { + use std::fmt::Write; + let _ = write!(out, "\"{}\";r={}", self.name, self.remaining); + if let Some(reset) = self.reset { + let _ = write!(out, ";t={reset}"); + } + } +} + +impl<'x> RequestError<'x> { + pub fn rate_limit_policy_header(&self) -> Option { + if self.rate_limit.is_empty() { + return None; + } + let mut out = String::new(); + for (i, policy) in self.rate_limit.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + policy.fmt_policy(&mut out); + } + Some(out) + } + + pub fn rate_limit_state_header(&self) -> Option { + if self.rate_limit.is_empty() { + return None; + } + let mut out = String::new(); + for (i, policy) in self.rate_limit.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + policy.fmt_state(&mut out); + } + Some(out) + } +} + impl Display for RequestError<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.detail) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rate_limit_headers_match_spec() { + // Spec example: RateLimit-Policy: "burst";q=100;w=60,"daily";q=1000;w=86400 + let mut p1 = String::new(); + RateLimitPolicy::new("burst", 100).with_window(60).fmt_policy(&mut p1); + assert_eq!(p1, r#""burst";q=100;w=60"#); + + // Spec example: RateLimit-Policy: "peruser";q=65535;qu="content-bytes";w=10 + let mut p2 = String::new(); + RateLimitPolicy::new("peruser", 65535) + .with_window(10) + .with_unit(RateLimitUnit::ContentBytes) + .fmt_policy(&mut p2); + assert_eq!(p2, r#""peruser";q=65535;w=10;qu="content-bytes""#); + + // Spec example: RateLimit: "default";r=50;t=30 + let mut s = String::new(); + RateLimitPolicy::new("default", 100).with_remaining(50).with_reset(30).fmt_state(&mut s); + assert_eq!(s, r#""default";r=50;t=30"#); + + // Two policies in one header + let err = RequestError::too_many_requests() + .with_rate_limit(RateLimitPolicy::new("burst", 100).with_window(60).with_reset(30)) + .with_rate_limit(RateLimitPolicy::new("daily", 1000).with_window(86400).with_reset(3600)); + assert_eq!( + err.rate_limit_policy_header().as_deref(), + Some(r#""burst";q=100;w=60, "daily";q=1000;w=86400"#), + ); + assert_eq!( + err.rate_limit_state_header().as_deref(), + Some(r#""burst";r=0;t=30, "daily";r=0;t=3600"#), + ); + assert_eq!(err.retry_after, Some(3600)); + } +} + diff --git a/crates/jmap-proto/src/object/file_node.rs b/crates/jmap-proto/src/object/file_node.rs index cd872263..b8d24dbb 100644 --- a/crates/jmap-proto/src/object/file_node.rs +++ b/crates/jmap-proto/src/object/file_node.rs @@ -399,7 +399,6 @@ impl<'x> DeserializeArguments<'x> for FileNodeQueryArguments { } } - impl FromStr for FileNodeProperty { type Err = (); diff --git a/crates/jmap-proto/src/references/mod.rs b/crates/jmap-proto/src/references/mod.rs index fa1c3307..f4fa820f 100644 --- a/crates/jmap-proto/src/references/mod.rs +++ b/crates/jmap-proto/src/references/mod.rs @@ -85,6 +85,8 @@ fn topological_sort( #[cfg(test)] mod tests { + use crate::references::Graph; + use crate::references::eval::EvalObjectReferences; use crate::{ method::{changes::ChangesResponse, get::GetResponse, query::QueryResponse}, object::{ @@ -98,8 +100,6 @@ mod tests { }, response::{ChangesResponseMethod, GetResponseMethod, Response, ResponseMethod}, }; - use crate::references::Graph; - use crate::references::eval::EvalObjectReferences; use jmap_tools::{Key, Map, Value}; use std::collections::HashMap; use types::id::Id; @@ -311,8 +311,8 @@ mod tests { response.method_responses.push(Call { id: call.id, name: call.name, - method: ResponseMethod::Changes(ChangesResponseMethod::Mailbox( - Box::new(ChangesResponse { + method: ResponseMethod::Changes(ChangesResponseMethod::Mailbox(Box::new( + ChangesResponse { account_id: Id::new(1), old_state: Default::default(), new_state: Default::default(), @@ -324,8 +324,8 @@ mod tests { MailboxProperty::Name.into(), MailboxProperty::ParentId.into(), ]), - }), - )), + }, + ))), }); } 1 => { @@ -767,14 +767,13 @@ mod tests { created_ids.insert("b".to_string(), Id::new(2).into()); let response = Response::new(0, created_ids, 0); - let mut value: Value<'_, MailboxProperty, MailboxValue> = - Value::Object(Map::from(vec![( - Key::Property(MailboxProperty::ParentId), - Value::Array(vec![ - Value::Element(MailboxValue::IdReference("a".into())), - Value::Element(MailboxValue::IdReference("b".into())), - ]), - )])); + let mut value: Value<'_, MailboxProperty, MailboxValue> = Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Array(vec![ + Value::Element(MailboxValue::IdReference("a".into())), + Value::Element(MailboxValue::IdReference("b".into())), + ]), + )])); value .eval_object_references(&response, &mut Graph::None, 0, 5, true) @@ -798,14 +797,13 @@ mod tests { created_ids.insert("a".to_string(), Id::new(7).into()); let response = Response::new(0, created_ids, 0); - let mut value: Value<'_, MailboxProperty, MailboxValue> = - Value::Object(Map::from(vec![( + let mut value: Value<'_, MailboxProperty, MailboxValue> = Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), + Value::Array(vec![Value::Object(Map::from(vec![( Key::Property(MailboxProperty::ParentId), - Value::Array(vec![Value::Object(Map::from(vec![( - Key::Property(MailboxProperty::ParentId), - Value::Element(MailboxValue::IdReference("a".into())), - )]))]), - )])); + Value::Element(MailboxValue::IdReference("a".into())), + )]))]), + )])); value .eval_object_references(&response, &mut Graph::None, 0, 5, true) @@ -831,14 +829,13 @@ mod tests { let mut graph_map: HashMap> = HashMap::new(); let child_id = "outer".to_string(); - let mut value: Value<'_, MailboxProperty, MailboxValue> = + let mut value: Value<'_, MailboxProperty, MailboxValue> = Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), Value::Object(Map::from(vec![( Key::Property(MailboxProperty::ParentId), - Value::Object(Map::from(vec![( - Key::Property(MailboxProperty::ParentId), - Value::Element(MailboxValue::IdReference("inner".into())), - )])), - )])); + Value::Element(MailboxValue::IdReference("inner".into())), + )])), + )])); { let mut graph = Graph::Some { @@ -857,22 +854,19 @@ mod tests { fn eval_unresolved_nested_ref_errors_without_graph() { let response = Response::new(0, HashMap::new(), 0); - let mut value: Value<'_, MailboxProperty, MailboxValue> = + let mut value: Value<'_, MailboxProperty, MailboxValue> = Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), Value::Object(Map::from(vec![( Key::Property(MailboxProperty::ParentId), - Value::Object(Map::from(vec![( - Key::Property(MailboxProperty::ParentId), - Value::Element(MailboxValue::IdReference("missing".into())), - )])), - )])); + Value::Element(MailboxValue::IdReference("missing".into())), + )])), + )])); let err = value .eval_object_references(&response, &mut Graph::None, 0, 5, true) .unwrap_err(); assert!( - err.matches(trc::EventType::Jmap( - trc::JmapEvent::InvalidResultReference - )), + err.matches(trc::EventType::Jmap(trc::JmapEvent::InvalidResultReference)), "{:?}", err ); @@ -884,17 +878,16 @@ mod tests { created_ids.insert("inner".to_string(), Id::new(99).into()); let response = Response::new(0, created_ids, 0); - let mut value: Value<'_, MailboxProperty, MailboxValue> = + let mut value: Value<'_, MailboxProperty, MailboxValue> = Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), Value::Object(Map::from(vec![( Key::Property(MailboxProperty::ParentId), Value::Object(Map::from(vec![( Key::Property(MailboxProperty::ParentId), - Value::Object(Map::from(vec![( - Key::Property(MailboxProperty::ParentId), - Value::Element(MailboxValue::IdReference("inner".into())), - )])), + Value::Element(MailboxValue::IdReference("inner".into())), )])), - )])); + )])), + )])); value .eval_object_references(&response, &mut Graph::None, 0, 2, true) @@ -925,14 +918,13 @@ mod tests { created_ids.insert("inner".to_string(), Id::new(99).into()); let response = Response::new(0, created_ids, 0); - let mut value: Value<'_, MailboxProperty, MailboxValue> = + let mut value: Value<'_, MailboxProperty, MailboxValue> = Value::Object(Map::from(vec![( + Key::Property(MailboxProperty::ParentId), Value::Object(Map::from(vec![( Key::Property(MailboxProperty::ParentId), - Value::Object(Map::from(vec![( - Key::Property(MailboxProperty::ParentId), - Value::Element(MailboxValue::IdReference("inner".into())), - )])), - )])); + Value::Element(MailboxValue::IdReference("inner".into())), + )])), + )])); value .eval_object_references(&response, &mut Graph::None, 0, 2, true) diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 6a43dfb6..0334585d 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -218,7 +218,7 @@ impl MethodName { } pub fn parse(s: &str) -> Option { - hashify::tiny_map!(s.as_bytes(), + hashify::tiny_map!(s.as_bytes(), "PushSubscription/get" => (MethodObject::PushSubscription, MethodFunction::Get), "PushSubscription/set" => (MethodObject::PushSubscription, MethodFunction::Set), @@ -331,7 +331,7 @@ impl MethodName { ).or_else(|| { let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?; let obj = ObjectType::parse(obj)?; - let fnc = hashify::tiny_map!(fnc.as_bytes(), + let fnc = hashify::tiny_map!(fnc.as_bytes(), "get" => MethodFunction::Get, "set" => MethodFunction::Set, "query" => MethodFunction::Query, diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 670fe425..c9b72ab1 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -535,7 +535,9 @@ impl From> for ResponseMethod<'_> { impl From> for ResponseMethod<'_> { fn from(response: ChangesResponse) -> Self { - ResponseMethod::Changes(ChangesResponseMethod::CalendarEventNotification(Box::new(response))) + ResponseMethod::Changes(ChangesResponseMethod::CalendarEventNotification(Box::new( + response, + ))) } } @@ -607,6 +609,8 @@ impl From for ResponseMethod<'_> { impl From> for ResponseMethod<'_> { fn from(value: SetResponse) -> Self { - ResponseMethod::Set(SetResponseMethod::CalendarEventNotification(Box::new(value))) + ResponseMethod::Set(SetResponseMethod::CalendarEventNotification(Box::new( + value, + ))) } } diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index c904875d..158a6099 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/jmap/src/api/mod.rs b/crates/jmap/src/api/mod.rs index 85b1303b..757692b1 100644 --- a/crates/jmap/src/api/mod.rs +++ b/crates/jmap/src/api/mod.rs @@ -10,7 +10,7 @@ use common::ipc::{CalendarAlert, PushNotification}; use http_proto::{HttpResponse, JsonResponse, ToHttpResponse}; use hyper::StatusCode; use jmap_proto::{ - error::request::{RequestError, RequestLimitError}, + error::request::{RateLimitPolicy, RateLimitUnit, RequestError, RequestLimitError}, request::capability::Session, response::{Response, status::PushObject}, types::state::State, @@ -49,7 +49,18 @@ impl ToJmapHttpResponse for Session { impl ToJmapHttpResponse for RequestError<'_> { fn into_http_response(self) -> HttpResponse { - HttpResponse::new(StatusCode::from_u16(self.status).unwrap_or(StatusCode::BAD_REQUEST)) + let mut response = + HttpResponse::new(StatusCode::from_u16(self.status).unwrap_or(StatusCode::BAD_REQUEST)); + if let Some(retry_after) = self.retry_after { + response = response.with_header("Retry-After", retry_after.to_string()); + } + if let Some(policy) = self.rate_limit_policy_header() { + response = response.with_header("RateLimit-Policy", policy); + } + if let Some(state) = self.rate_limit_state_header() { + response = response.with_header("RateLimit", state); + } + response .with_content_type("application/problem+json") .with_text_body(serde_json::to_string(&self).unwrap_or_default()) } @@ -74,33 +85,90 @@ impl ToRequestError for trc::Error { trc::JmapEvent::NotRequest => RequestError::not_request(details), _ => RequestError::invalid_parameters(), }, - trc::EventType::Limit(cause) => match cause { - trc::LimitEvent::SizeRequest => RequestError::limit(RequestLimitError::SizeRequest), - trc::LimitEvent::SizeUpload => RequestError::limit(RequestLimitError::SizeUpload), - trc::LimitEvent::CallsIn => RequestError::limit(RequestLimitError::CallsIn), - trc::LimitEvent::ConcurrentRequest | trc::LimitEvent::ConcurrentConnection => { - RequestError::limit(RequestLimitError::ConcurrentRequest) + trc::EventType::Limit(cause) => { + let reset = self.value(trc::Key::Expires).and_then(|v| v.to_uint()); + let limit = self.value(trc::Key::Limit).and_then(|v| v.to_uint()); + let total = self.value(trc::Key::Total).and_then(|v| v.to_uint()); + let size = self.value(trc::Key::Size).and_then(|v| v.to_uint()); + + match cause { + trc::LimitEvent::SizeRequest => { + RequestError::limit(RequestLimitError::SizeRequest) + } + trc::LimitEvent::SizeUpload => { + RequestError::limit(RequestLimitError::SizeUpload) + } + trc::LimitEvent::CallsIn => RequestError::limit(RequestLimitError::CallsIn), + trc::LimitEvent::ConcurrentRequest | trc::LimitEvent::ConcurrentConnection => { + let mut policy = + RateLimitPolicy::new("concurrent-requests", limit.unwrap_or(0)) + .with_unit(RateLimitUnit::ConcurrentRequests); + if let Some(reset) = reset { + policy = policy.with_reset(reset); + } + RequestError::limit(RequestLimitError::ConcurrentRequest) + .with_rate_limit(policy) + } + trc::LimitEvent::ConcurrentUpload => { + let mut policy = + RateLimitPolicy::new("concurrent-uploads", limit.unwrap_or(0)) + .with_unit(RateLimitUnit::ConcurrentRequests); + if let Some(reset) = reset { + policy = policy.with_reset(reset); + } + RequestError::limit(RequestLimitError::ConcurrentUpload) + .with_rate_limit(policy) + } + trc::LimitEvent::Quota => RequestError::over_quota(), + trc::LimitEvent::TenantQuota => RequestError::tenant_over_quota(), + trc::LimitEvent::BlobQuota => { + let mut err = RequestError::over_blob_quota( + total.unwrap_or(0) as usize, + size.unwrap_or(0) as usize, + ); + if let Some(total) = total { + let mut policy = RateLimitPolicy::new("blob-upload-files", total); + if let Some(reset) = reset { + policy = policy.with_reset(reset); + } + err = err.with_rate_limit(policy); + } + if let Some(size) = size { + let mut policy = RateLimitPolicy::new("blob-upload-bytes", size) + .with_unit(RateLimitUnit::ContentBytes); + if let Some(reset) = reset { + policy = policy.with_reset(reset); + } + err = err.with_rate_limit(policy); + } + err + } + trc::LimitEvent::TooManyRequests => { + let mut err = RequestError::too_many_requests(); + if let Some(limit) = limit { + let mut policy = RateLimitPolicy::new("requests", limit); + if let Some(reset) = reset { + policy = policy.with_reset(reset); + } + err = err.with_rate_limit(policy); + } else if let Some(reset) = reset { + err = err.with_retry_after(reset); + } + err + } } - trc::LimitEvent::ConcurrentUpload => { - RequestError::limit(RequestLimitError::ConcurrentUpload) - } - trc::LimitEvent::Quota => RequestError::over_quota(), - trc::LimitEvent::TenantQuota => RequestError::tenant_over_quota(), - trc::LimitEvent::BlobQuota => RequestError::over_blob_quota( - self.value(trc::Key::Total) - .and_then(|v| v.to_uint()) - .unwrap_or_default() as usize, - self.value(trc::Key::Size) - .and_then(|v| v.to_uint()) - .unwrap_or_default() as usize, - ), - trc::LimitEvent::TooManyRequests => RequestError::too_many_requests(), - }, + } trc::EventType::Auth(cause) => match cause { trc::AuthEvent::MfaRequired => { RequestError::blank(402, "MFA code required", self.as_ref().message()) } - trc::AuthEvent::TooManyAttempts => RequestError::too_many_auth_attempts(), + trc::AuthEvent::TooManyAttempts => { + let mut err = RequestError::too_many_auth_attempts(); + if let Some(reset) = self.value(trc::Key::Expires).and_then(|v| v.to_uint()) { + err = err.with_retry_after(reset); + } + err + } _ => RequestError::unauthorized(), }, trc::EventType::Security(cause) => match cause { @@ -108,7 +176,13 @@ impl ToRequestError for trc::Error { | trc::SecurityEvent::ScanBan | trc::SecurityEvent::AbuseBan | trc::SecurityEvent::LoiterBan - | trc::SecurityEvent::IpBlocked => RequestError::too_many_auth_attempts(), + | trc::SecurityEvent::IpBlocked => { + let mut err = RequestError::too_many_auth_attempts(); + if let Some(reset) = self.value(trc::Key::Expires).and_then(|v| v.to_uint()) { + err = err.with_retry_after(reset); + } + err + } trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => { RequestError::forbidden() } diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 7badbc0f..748a6f10 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -515,7 +515,9 @@ impl RequestHandler for Server { set_account_id_if_missing(&mut req.account_id, access_token); access_token.assert_has_access(req.account_id, Collection::FileNode)?; - self.file_node_set(*req, access_token, session).await?.into() + self.file_node_set(*req, access_token, session) + .await? + .into() } SetRequestMethod::ShareNotification(mut req) => { set_account_id_if_missing(&mut req.account_id, access_token); diff --git a/crates/jmap/src/blob/copy.rs b/crates/jmap/src/blob/copy.rs index 98a48b8d..78f6c06d 100644 --- a/crates/jmap/src/blob/copy.rs +++ b/crates/jmap/src/blob/copy.rs @@ -48,6 +48,7 @@ impl BlobCopy for Server { .blob_has_quota(account_id, 1) .await .caused_by(trc::location!())? + .allowed { response.not_copied.append( blob_id, diff --git a/crates/jmap/src/blob/upload.rs b/crates/jmap/src/blob/upload.rs index 24e98c0c..8411e6cf 100644 --- a/crates/jmap/src/blob/upload.rs +++ b/crates/jmap/src/blob/upload.rs @@ -169,6 +169,7 @@ impl BlobUpload for Server { .blob_has_quota(account_id, data.len()) .await .caused_by(trc::location!())? + .allowed { response.not_created.append( create_id, @@ -216,24 +217,26 @@ impl BlobUpload for Server { } // Enforce quota - if !access_token.has_permission(Permission::UnlimitedUploads) - && !self + if !access_token.has_permission(Permission::UnlimitedUploads) { + let status = self .blob_has_quota(account_id.document_id(), data.len()) .await - .caused_by(trc::location!())? - { - let err = Err(trc::LimitEvent::BlobQuota - .into_err() - .ctx(trc::Key::Size, self.core.jmap.upload_tmp_quota_size) - .ctx(trc::Key::Total, self.core.jmap.upload_tmp_quota_amount)); + .caused_by(trc::location!())?; + if !status.allowed { + let err = Err(trc::LimitEvent::BlobQuota + .into_err() + .ctx(trc::Key::Size, self.core.jmap.upload_tmp_quota_size) + .ctx(trc::Key::Total, self.core.jmap.upload_tmp_quota_amount) + .ctx(trc::Key::Expires, status.expires_in)); - #[cfg(feature = "test_mode")] - if !DISABLE_UPLOAD_QUOTA.load(std::sync::atomic::Ordering::Relaxed) { + #[cfg(feature = "test_mode")] + if !DISABLE_UPLOAD_QUOTA.load(std::sync::atomic::Ordering::Relaxed) { + return err; + } + + #[cfg(not(feature = "test_mode"))] return err; } - - #[cfg(not(feature = "test_mode"))] - return err; } Ok(UploadResponse { diff --git a/crates/jmap/src/calendar_event/copy.rs b/crates/jmap/src/calendar_event/copy.rs index 9454c5d4..0502c796 100644 --- a/crates/jmap/src/calendar_event/copy.rs +++ b/crates/jmap/src/calendar_event/copy.rs @@ -209,16 +209,14 @@ impl JmapCalendarEventCopy for Server { *next_call = Call { id: String::new(), name: MethodName::new(MethodObject::CalendarEvent, MethodFunction::Set), - method: RequestMethod::Set(SetRequestMethod::CalendarEvent(Box::new( - SetRequest { - account_id: request.from_account_id, - if_in_state: request.destroy_from_if_in_state, - create: None, - update: None, - destroy: MaybeResultReference::Value(destroy_ids).into(), - arguments: Default::default(), - }, - ))), + method: RequestMethod::Set(SetRequestMethod::CalendarEvent(Box::new(SetRequest { + account_id: request.from_account_id, + if_in_state: request.destroy_from_if_in_state, + create: None, + update: None, + destroy: MaybeResultReference::Value(destroy_ids).into(), + arguments: Default::default(), + }))), } .into(); } diff --git a/crates/jmap/src/changes/query.rs b/crates/jmap/src/changes/query.rs index 78e97788..15662b13 100644 --- a/crates/jmap/src/changes/query.rs +++ b/crates/jmap/src/changes/query.rs @@ -161,7 +161,9 @@ impl QueryChanges for Server { } up_to_id = request.up_to_id; - results = self.file_node_query((*request).into(), access_token).await?; + results = self + .file_node_query((*request).into(), access_token) + .await?; } QueryChangesRequestMethod::CalendarEvent(mut request) => { // Query changes diff --git a/crates/jmap/src/file/copy.rs b/crates/jmap/src/file/copy.rs index 7599f496..4a2875c8 100644 --- a/crates/jmap/src/file/copy.rs +++ b/crates/jmap/src/file/copy.rs @@ -71,7 +71,11 @@ impl FileNodeCopy for Server { } let cache = self - .fetch_dav_resources(access_token.account_id(), account_id, SyncCollection::FileNode) + .fetch_dav_resources( + access_token.account_id(), + account_id, + SyncCollection::FileNode, + ) .await .caused_by(trc::location!())?; let old_state = cache.assert_state(false, &request.if_in_state)?; @@ -167,7 +171,8 @@ impl FileNodeCopy for Server { // ACLs are account-scoped; do not carry the source account's grants over. file_node.acls.clear(); - let has_acl_changes = match update_file_node(create, &mut file_node, true, &NoResolver) { + let has_acl_changes = match update_file_node(create, &mut file_node, true, &NoResolver) + { Ok(result) => { if let Some(blob_id) = result.blob_id { let file_details = file_node.file.get_or_insert_default(); diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index 6a86a899..7016344b 100644 --- a/crates/main/Cargo.toml +++ b/crates/main/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art" keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"] categories = ["email"] license = "AGPL-3.0-only OR LicenseRef-SEL" -version = "0.16.6" +version = "0.16.7" edition = "2024" [[bin]] diff --git a/crates/managesieve/Cargo.toml b/crates/managesieve/Cargo.toml index da82cdd9..9b0a4c89 100644 --- a/crates/managesieve/Cargo.toml +++ b/crates/managesieve/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "managesieve" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/migration/Cargo.toml b/crates/migration/Cargo.toml index 7b2601f2..862ad6be 100644 --- a/crates/migration/Cargo.toml +++ b/crates/migration/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "migration" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/nlp/Cargo.toml b/crates/nlp/Cargo.toml index 025760ed..b9843deb 100644 --- a/crates/nlp/Cargo.toml +++ b/crates/nlp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nlp" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/pop3/Cargo.toml b/crates/pop3/Cargo.toml index ec32f1d3..262b125d 100644 --- a/crates/pop3/Cargo.toml +++ b/crates/pop3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pop3" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/pop3/src/client.rs b/crates/pop3/src/client.rs index a58847dd..ab171bd4 100644 --- a/crates/pop3/src/client.rs +++ b/crates/pop3/src/client.rs @@ -147,11 +147,11 @@ impl Session { self.handle_stls().await.map(|_| SessionResult::UpgradeTls) } Command::Utf8 => self.handle_utf8().await.map(|_| SessionResult::Continue), - Command::Auth { mechanism, params } => Box::pin( - self.handle_sasl(mechanism, params), - ) - .await - .map(|_| SessionResult::Continue), + Command::Auth { mechanism, params } => { + Box::pin(self.handle_sasl(mechanism, params)) + .await + .map(|_| SessionResult::Continue) + } Command::Apop { .. } => Err(trc::Pop3Event::Error .into_err() .details("APOP not supported.")), diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index 9c0046fb..b8f964ec 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "services" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/services/src/task_manager/spam_classifier.rs b/crates/services/src/task_manager/spam_classifier.rs index 16b13238..502fb9b0 100644 --- a/crates/services/src/task_manager/spam_classifier.rs +++ b/crates/services/src/task_manager/spam_classifier.rs @@ -260,10 +260,9 @@ async fn update_spam_rules(server: &Server) -> trc::Result { } if reload_lookups { - if let Err(err) = Box::pin( - server.reload_registry(RegistryChange::Reload(ObjectType::MemoryLookupKey)), - ) - .await + if let Err(err) = + Box::pin(server.reload_registry(RegistryChange::Reload(ObjectType::MemoryLookupKey))) + .await { trc::error!(err.details("Failed to reload registry after updating spam rules")); } diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index eddd9ac1..c0c7645e 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp" keywords = ["smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only OR LicenseRef-SEL" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/spam-filter/Cargo.toml b/crates/spam-filter/Cargo.toml index ab348ec4..81525c9a 100644 --- a/crates/spam-filter/Cargo.toml +++ b/crates/spam-filter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spam-filter" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 29caf6d4..0db8a243 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "store" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/store/src/backend/ephemeral/read.rs b/crates/store/src/backend/ephemeral/read.rs index 9fbe30b0..41036ea7 100644 --- a/crates/store/src/backend/ephemeral/read.rs +++ b/crates/store/src/backend/ephemeral/read.rs @@ -78,11 +78,7 @@ impl EphemeralStore { .and_then(|m| m.get(&key_bytes)) { Some(bytes) => Ok(i64::from_le_bytes(bytes[..].try_into().map_err(|_| { - trc::Error::corrupted_key( - &key_bytes, - Some(bytes.as_slice()), - trc::location!(), - ) + trc::Error::corrupted_key(&key_bytes, Some(bytes.as_slice()), trc::location!()) })?)), None => Ok(0), } diff --git a/crates/store/src/backend/ephemeral/write.rs b/crates/store/src/backend/ephemeral/write.rs index ae4e4260..23b87408 100644 --- a/crates/store/src/backend/ephemeral/write.rs +++ b/crates/store/src/backend/ephemeral/write.rs @@ -163,7 +163,10 @@ impl EphemeralStore { let to_key = to.serialize(0); let mut state = self.state.write(); if let Some(map) = state.subspaces.get_mut(&subspace) { - let keys: Vec> = map.range(from_key..to_key).map(|(k, _)| k.clone()).collect(); + let keys: Vec> = map + .range(from_key..to_key) + .map(|(k, _)| k.clone()) + .collect(); for k in keys { map.remove(&k); } diff --git a/crates/store/src/backend/postgres/tls.rs b/crates/store/src/backend/postgres/tls.rs index cd69acbc..b843074a 100644 --- a/crates/store/src/backend/postgres/tls.rs +++ b/crates/store/src/backend/postgres/tls.rs @@ -15,8 +15,8 @@ use std::{ task::{Context, Poll}, }; -use futures::future::{FutureExt, TryFutureExt}; use aws_lc_rs::digest; +use futures::future::{FutureExt, TryFutureExt}; use rustls::ClientConfig; use rustls_pki_types::ServerName; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; diff --git a/crates/trc/Cargo.toml b/crates/trc/Cargo.toml index 260c4a99..ed34fb18 100644 --- a/crates/trc/Cargo.toml +++ b/crates/trc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trc" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/trc/event-macro/Cargo.toml b/crates/trc/event-macro/Cargo.toml index 9c00ef7a..f0f87d37 100644 --- a/crates/trc/event-macro/Cargo.toml +++ b/crates/trc/event-macro/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "event_macro" -version = "0.16.6" +version = "0.16.7" edition = "2024" [lib] diff --git a/crates/trc/src/event/enums_impl.rs b/crates/trc/src/event/enums_impl.rs index fd2c6855..619ca607 100644 --- a/crates/trc/src/event/enums_impl.rs +++ b/crates/trc/src/event/enums_impl.rs @@ -6,7 +6,7 @@ // This file is auto-generated. Do not edit directly. -use crate::{event::enums::*, Level}; +use crate::{Level, event::enums::*}; use std::borrow::Cow; impl EventType { @@ -3737,7 +3737,9 @@ impl EventType { EventType::Auth(AuthEvent::Success) => "Authentication error", EventType::Auth(AuthEvent::Failed) => "Authentication failed", EventType::Auth(AuthEvent::TokenExpired) => "Authentication error", - EventType::Auth(AuthEvent::MfaRequired) => "This account requires multi-factor authentication. Alternatively, you can use an app password if your account has one.", + EventType::Auth(AuthEvent::MfaRequired) => { + "This account requires multi-factor authentication. Alternatively, you can use an app password if your account has one." + } EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error", EventType::Auth(AuthEvent::Error) => "Authentication error", @@ -3790,7 +3792,9 @@ impl EventType { EventType::Jmap(JmapEvent::InvalidResultReference) => "Invalid result reference", EventType::Jmap(JmapEvent::Forbidden) => "Forbidden", EventType::Jmap(JmapEvent::AccountNotFound) => "Account not found", - EventType::Jmap(JmapEvent::AccountNotSupportedByMethod) => "Account not supported by method", + EventType::Jmap(JmapEvent::AccountNotSupportedByMethod) => { + "Account not supported by method" + } EventType::Jmap(JmapEvent::AccountReadOnly) => "Account read-only", EventType::Jmap(JmapEvent::NotFound) => "Not found", EventType::Jmap(JmapEvent::CannotCalculateChanges) => "Cannot calculate changes", @@ -3956,7 +3960,9 @@ impl EventType { EventType::Smtp(SmtpEvent::UnsupportedParameter) => "SMTP error", EventType::Smtp(SmtpEvent::SyntaxError) => "SMTP error", EventType::Smtp(SmtpEvent::RequestTooLarge) => "SMTP error", - EventType::Store(StoreEvent::AssertValueFailed) => "Another process has modified the value", + EventType::Store(StoreEvent::AssertValueFailed) => { + "Another process has modified the value" + } EventType::Store(StoreEvent::FoundationdbError) => "FoundationDB error", EventType::Store(StoreEvent::MysqlError) => "MySQL error", EventType::Store(StoreEvent::PostgresqlError) => "PostgreSQL error", diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml index 8c7409e5..5bdf73c8 100644 --- a/crates/types/Cargo.toml +++ b/crates/types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "types" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index c9eadbd3..031e5f2f 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "utils" -version = "0.16.6" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/utils/proc-macros/Cargo.toml b/crates/utils/proc-macros/Cargo.toml index d44125ce..b13042a5 100644 --- a/crates/utils/proc-macros/Cargo.toml +++ b/crates/utils/proc-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proc_macros" -version = "0.16.6" +version = "0.16.7" edition = "2024" [lib] diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 7519ec3a..6ff161e7 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tests" -version = "0.16.6" +version = "0.16.7" edition = "2024" [features] diff --git a/tests/src/smtp/lookup/utils.rs b/tests/src/smtp/lookup/utils.rs index 778ef7ba..b415fa70 100644 --- a/tests/src/smtp/lookup/utils.rs +++ b/tests/src/smtp/lookup/utils.rs @@ -18,7 +18,7 @@ use registry::{ enums::MtaIpStrategy, structs::{ Expression, MtaConnectionIpHost, MtaConnectionStrategy, MtaOutboundStrategy, MtaRoute, - MtaRouteMx, + MtaRouteMx, }, }, types::{ipaddr::IpAddr, list::List}, diff --git a/tests/src/store/blob.rs b/tests/src/store/blob.rs index 2503ccef..b2575434 100644 --- a/tests/src/store/blob.rs +++ b/tests/src/store/blob.rs @@ -41,10 +41,10 @@ pub async fn blob_tests() { ); // Test blob quota - assert!(test.server.blob_has_quota(0, 1024).await.unwrap()); - assert!(!test.server.blob_has_quota(0, 1024).await.unwrap()); + assert!(test.server.blob_has_quota(0, 1024).await.unwrap().allowed); + assert!(!test.server.blob_has_quota(0, 1024).await.unwrap().allowed); tokio::time::sleep(std::time::Duration::from_secs(1)).await; - assert!(test.server.blob_has_quota(0, 1024).await.unwrap()); + assert!(test.server.blob_has_quota(0, 1024).await.unwrap().allowed); // Test and reset store test_store(blob_store.clone()).await; diff --git a/tests/src/system/quota.rs b/tests/src/system/quota.rs index 707abe25..167f8b45 100644 --- a/tests/src/system/quota.rs +++ b/tests/src/system/quota.rs @@ -4,7 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::utils::{account::Account, jmap::JmapUtils, server::TestServer, smtp::SmtpConnection}; +use crate::utils::{ + account::Account, http::HttpRequest, jmap::JmapUtils, server::TestServer, smtp::SmtpConnection, +}; use email::{cache::MessageCacheFetch, mailbox::INBOX_ID}; use jmap::blob::upload::DISABLE_UPLOAD_QUOTA; use jmap_client::{ @@ -102,6 +104,12 @@ pub async fn test(test: &mut TestServer) { // Test temporary blob quota (3 files) DISABLE_UPLOAD_QUOTA.store(false, std::sync::atomic::Ordering::Relaxed); let client = account.jmap_client().await; + let raw_http = HttpRequest::with_credentials( + 8899, + "user1@example.org", + "this is a very strong password1", + ); + let upload_url = format!("/jmap/upload/{account_id}"); for i in 0..3 { assert_eq!( client @@ -112,14 +120,45 @@ pub async fn test(test: &mut TestServer) { 1024 ); } - match client - .upload(None, vec![b'Z'; 1024], None) - .await - .unwrap_err() - { - jmap_client::Error::Problem(err) if err.detail().unwrap().contains("quota") => (), - other => panic!("Unexpected error: {:?}", other), - } + let resp = raw_http + .send_full( + hyper::Method::POST, + &upload_url, + Some(vec![b'Z'; 1024]), + Some("application/octet-stream"), + ) + .await; + assert_eq!( + resp.status.as_u16(), + 429, + "blob-files-quota body: {}", + resp.body + ); + let policy = resp + .rate_limit_policy() + .unwrap_or_else(|| panic!("missing RateLimit-Policy on {:?}", resp.headers)); + assert!( + policy.contains("\"blob-upload-files\";q=3"), + "RateLimit-Policy = {policy}" + ); + assert!( + policy.contains("\"blob-upload-bytes\";q=50000") + && policy.contains(r#"qu="content-bytes""#), + "RateLimit-Policy = {policy}" + ); + let state = resp + .rate_limit() + .unwrap_or_else(|| panic!("missing RateLimit on {:?}", resp.headers)); + assert!( + state.contains("\"blob-upload-files\";r=0") && state.contains("t="), + "RateLimit = {state}" + ); + assert!( + resp.retry_after().is_some(), + "missing Retry-After on {:?}", + resp.headers + ); + assert!(resp.body.contains("quota"), "body = {}", resp.body); test.blob_expire_all().await; // Test temporary blob quota (50000 bytes) @@ -134,14 +173,40 @@ pub async fn test(test: &mut TestServer) { 25000 ); } - match client - .upload(None, vec![b'z'; 1024], None) - .await - .unwrap_err() - { - jmap_client::Error::Problem(err) if err.detail().unwrap().contains("quota") => (), - other => panic!("Unexpected error: {:?}", other), - } + let resp = raw_http + .send_full( + hyper::Method::POST, + &upload_url, + Some(vec![b'z'; 1024]), + Some("application/octet-stream"), + ) + .await; + assert_eq!( + resp.status.as_u16(), + 429, + "blob-bytes-quota body: {}", + resp.body + ); + let policy = resp + .rate_limit_policy() + .unwrap_or_else(|| panic!("missing RateLimit-Policy on {:?}", resp.headers)); + assert!( + policy.contains("\"blob-upload-bytes\";q=50000") + && policy.contains(r#"qu="content-bytes""#), + "RateLimit-Policy = {policy}" + ); + let state = resp + .rate_limit() + .unwrap_or_else(|| panic!("missing RateLimit on {:?}", resp.headers)); + assert!( + state.contains("\"blob-upload-bytes\";r=0") && state.contains("t="), + "RateLimit = {state}" + ); + assert!( + resp.retry_after().is_some(), + "missing Retry-After on {:?}", + resp.headers + ); test.blob_expire_all().await; tokio::time::sleep(std::time::Duration::from_millis(1100)).await; diff --git a/tests/src/system/security.rs b/tests/src/system/security.rs index b9a821d1..171aee6f 100644 --- a/tests/src/system/security.rs +++ b/tests/src/system/security.rs @@ -7,6 +7,7 @@ use crate::{ system::authentication::validate_password_with_ip, utils::{ + http::HttpRequest, imap::{ImapConnection, Type}, registry::UnwrapRegistryId, server::TestServer, @@ -271,6 +272,11 @@ pub async fn test(test: &mut TestServer) { // Concurrent requests check let client = Arc::new(client); + let raw_http = HttpRequest::with_credentials( + 8899, + "user@example.org", + "this is a very strong password", + ); for _ in 0..8 { let client_ = client.clone(); tokio::spawn(async move { @@ -283,14 +289,48 @@ pub async fn test(test: &mut TestServer) { }); } tokio::time::sleep(Duration::from_millis(500)).await; - assert!(matches!( - client - .mailbox_query( - mailbox::query::Filter::name("__sleep").into(), - [mailbox::query::Comparator::name()].into(), - ) - .await, - Err(jmap_client::Error::Problem(err)) if err.status() == Some(400))); + let body = serde_json::to_vec(&json!({ + "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + "methodCalls": [ + ["Mailbox/query", { + "accountId": user_id.to_string(), + "filter": { "name": "__sleep" } + }, "c1"] + ] + })) + .unwrap(); + let resp = raw_http + .send_full( + hyper::Method::POST, + "/jmap/", + Some(body), + Some("application/json"), + ) + .await; + assert_eq!( + resp.status.as_u16(), + 400, + "concurrent-requests body: {}", + resp.body + ); + let policy = resp + .rate_limit_policy() + .unwrap_or_else(|| panic!("missing RateLimit-Policy header on {:?}", resp.headers)); + assert!( + policy.contains("\"concurrent-requests\"") && policy.contains("q=8"), + "RateLimit-Policy = {policy}" + ); + assert!( + policy.contains(r#"qu="concurrent-requests""#), + "RateLimit-Policy = {policy}" + ); + let state = resp + .rate_limit() + .unwrap_or_else(|| panic!("missing RateLimit header on {:?}", resp.headers)); + assert!( + state.contains("\"concurrent-requests\"") && state.contains("r=0"), + "RateLimit = {state}" + ); // Wait for sleep to be done tokio::time::sleep(Duration::from_millis(1000)).await; @@ -303,9 +343,37 @@ pub async fn test(test: &mut TestServer) { }); } tokio::time::sleep(Duration::from_millis(500)).await; - assert!(matches!( - client.upload(None, b"sleep".to_vec(), None).await, - Err(jmap_client::Error::Problem(err)) if err.status() == Some(400))); + let resp = raw_http + .send_full( + hyper::Method::POST, + &format!("/jmap/upload/{user_id}"), + Some(b"sleep".to_vec()), + Some("application/octet-stream"), + ) + .await; + assert_eq!( + resp.status.as_u16(), + 400, + "concurrent-uploads body: {}", + resp.body + ); + let policy = resp + .rate_limit_policy() + .unwrap_or_else(|| panic!("missing RateLimit-Policy header on {:?}", resp.headers)); + assert!( + policy.contains("\"concurrent-uploads\"") && policy.contains("q=4"), + "RateLimit-Policy = {policy}" + ); + let state = resp + .rate_limit() + .unwrap_or_else(|| panic!("missing RateLimit header on {:?}", resp.headers)); + assert!( + state.contains("\"concurrent-uploads\"") && state.contains("r=0"), + "RateLimit = {state}" + ); + + // Wait for sleep to be done before continuing + tokio::time::sleep(Duration::from_millis(1000)).await; // Disable X-Forwarded-For processing admin diff --git a/tests/src/system/task.rs b/tests/src/system/task.rs index cee9b178..8eceb2b8 100644 --- a/tests/src/system/task.rs +++ b/tests/src/system/task.rs @@ -158,7 +158,12 @@ async fn pagination_test(test: &mut TestServer) { .await .object_ids() .collect(); - assert_eq!(asc_order.len(), 12, "expected 12 tasks, got {}", asc_order.len()); + assert_eq!( + asc_order.len(), + 12, + "expected 12 tasks, got {}", + asc_order.len() + ); let desc_order: Vec = asc_order.iter().rev().copied().collect(); diff --git a/tests/src/telemetry/metrics.rs b/tests/src/telemetry/metrics.rs index d966f4ab..f1ef62cf 100644 --- a/tests/src/telemetry/metrics.rs +++ b/tests/src/telemetry/metrics.rs @@ -87,7 +87,11 @@ pub async fn test(test: &TestServer) { .await .object_ids() .collect(); - assert!(asc_order.len() > 100, "expected >100 metrics, got {}", asc_order.len()); + assert!( + asc_order.len() > 100, + "expected >100 metrics, got {}", + asc_order.len() + ); let desc_order: Vec = asc_order.iter().rev().copied().collect(); let total = asc_order.len(); let limit = 25usize; diff --git a/tests/src/telemetry/webhooks.rs b/tests/src/telemetry/webhooks.rs index 7de8b834..b20bde5c 100644 --- a/tests/src/telemetry/webhooks.rs +++ b/tests/src/telemetry/webhooks.rs @@ -6,6 +6,7 @@ use crate::utils::server::TestServer; use crate::utils::smtp::SmtpConnection; +use aws_lc_rs::hmac; use base64::{Engine, engine::general_purpose::STANDARD}; use common::{manager::application::Resource, telemetry::tracers::store::TracingStore}; use http_proto::{ToHttpResponse, request::fetch_body}; @@ -21,7 +22,6 @@ use registry::{ }, types::map::Map, }; -use aws_lc_rs::hmac; use std::{ sync::{ Arc, diff --git a/tests/src/utils/http.rs b/tests/src/utils/http.rs index c7eae2e3..86f664ea 100644 --- a/tests/src/utils/http.rs +++ b/tests/src/utils/http.rs @@ -5,6 +5,7 @@ */ use hyper::Method; +use reqwest::header::HeaderMap; use serde::{Serialize, de::DeserializeOwned}; use std::time::Duration; @@ -14,6 +15,30 @@ pub struct HttpRequest { pub password: Option, } +pub struct HttpResponseFull { + pub status: reqwest::StatusCode, + pub headers: HeaderMap, + pub body: String, +} + +impl HttpResponseFull { + pub fn header(&self, name: &str) -> Option<&str> { + self.headers.get(name).and_then(|v| v.to_str().ok()) + } + + pub fn rate_limit_policy(&self) -> Option<&str> { + self.header("RateLimit-Policy") + } + + pub fn rate_limit(&self) -> Option<&str> { + self.header("RateLimit") + } + + pub fn retry_after(&self) -> Option { + self.header("Retry-After").and_then(|v| v.parse().ok()) + } +} + impl Default for HttpRequest { fn default() -> Self { Self { @@ -94,6 +119,43 @@ impl HttpRequest { }) } + pub async fn send_full( + &self, + method: Method, + query: &str, + body: Option>, + content_type: Option<&str>, + ) -> HttpResponseFull { + let mut request = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .danger_accept_invalid_certs(true) + .build() + .unwrap() + .request(method, format!("https://127.0.0.1:{}{query}", self.port)); + + if let Some(body) = body { + request = request.body(body); + } + + if let Some(ct) = content_type { + request = request.header(hyper::header::CONTENT_TYPE, ct); + } + + if let (Some(username), Some(password)) = (&self.username, &self.password) { + request = request.basic_auth(username, Some(password)); + } + + let response = request.send().await.expect("HTTP request failed"); + let status = response.status(); + let headers = response.headers().clone(); + let body = response.text().await.unwrap_or_default(); + HttpResponseFull { + status, + headers, + body, + } + } + async fn request_raw( &self, method: Method, diff --git a/tests/src/webdav/cal_query.rs b/tests/src/webdav/cal_query.rs index 7d1c5a50..07d4e991 100644 --- a/tests/src/webdav/cal_query.rs +++ b/tests/src/webdav/cal_query.rs @@ -19,7 +19,10 @@ use types::TimeRange; pub async fn test(test: &TestServer) { println!("Running REPORT calendar-query & free-busy-query tests..."); let client = test.account("john@example.com").webdav_client(); - let cal_path = format!("{}/john%40example.com/default/", DavResourceName::Cal.base_path()); + let cal_path = format!( + "{}/john%40example.com/default/", + DavResourceName::Cal.base_path() + ); #[allow(clippy::never_loop)] for (num, ics) in [ diff --git a/tests/src/webdav/card_query.rs b/tests/src/webdav/card_query.rs index df1238b4..c8d01887 100644 --- a/tests/src/webdav/card_query.rs +++ b/tests/src/webdav/card_query.rs @@ -15,7 +15,10 @@ pub async fn test(test: &TestServer) { let client = test.account("john@example.com").webdav_client(); // Create test data - let default_path = format!("{}/john%40example.com/default/", DavResourceName::Card.base_path()); + let default_path = format!( + "{}/john%40example.com/default/", + DavResourceName::Card.base_path() + ); let mut hrefs = Vec::with_capacity(3); for (i, vcard) in [VCARD1, VCARD2, VCARD3].iter().enumerate() { let href = format!("{default_path}contact-{i}.vcf",); diff --git a/tests/src/webdav/mkcol.rs b/tests/src/webdav/mkcol.rs index 981d9240..493eb3eb 100644 --- a/tests/src/webdav/mkcol.rs +++ b/tests/src/webdav/mkcol.rs @@ -10,7 +10,6 @@ use crate::webdav::{TEST_FILE_1, TEST_ICAL_1, TEST_VCARD_1, TEST_VTIMEZONE_1}; use crate::utils::server::TestServer; - pub async fn test(test: &TestServer) { println!("Running MKCOL tests..."); let client = test.account("john@example.com").webdav_client(); @@ -42,9 +41,18 @@ pub async fn test(test: &TestServer) { // Create resources under the newly created collections for (path, content) in [ - ("/dav/file/john%40example.com/my-files/file1.txt", TEST_FILE_1), - ("/dav/card/john%40example.com/my-cards/card1.vcf", TEST_VCARD_1), - ("/dav/cal/john%40example.com/my-events/event1.ics", TEST_ICAL_1), + ( + "/dav/file/john%40example.com/my-files/file1.txt", + TEST_FILE_1, + ), + ( + "/dav/card/john%40example.com/my-cards/card1.vcf", + TEST_VCARD_1, + ), + ( + "/dav/cal/john%40example.com/my-events/event1.ics", + TEST_ICAL_1, + ), ] { client .request("PUT", path, content) @@ -69,7 +77,10 @@ pub async fn test(test: &TestServer) { // Creating a sub-collections is allowed in FileDAV but in CalDAV and CardDAV for (path, expected_status) in [ - ("/dav/file/john%40example.com/my-files/my-sub-files", StatusCode::CREATED), + ( + "/dav/file/john%40example.com/my-files/my-sub-files", + StatusCode::CREATED, + ), ( "/dav/card/john%40example.com/my-cards/my-sub-cards", StatusCode::METHOD_NOT_ALLOWED, @@ -87,9 +98,15 @@ pub async fn test(test: &TestServer) { // Extended MKCOL with an unsupported resource types should fail for (path, resource_type) in [ - ("/dav/file/john%40example.com/my-named-files", "B:addressbook"), + ( + "/dav/file/john%40example.com/my-named-files", + "B:addressbook", + ), ("/dav/card/john%40example.com/my-named-cards", "A:calendar"), - ("/dav/cal/john%40example.com/my-named-events", "B:addressbook"), + ( + "/dav/cal/john%40example.com/my-named-events", + "B:addressbook", + ), ] { client .mkcol("MKCOL", path, ["D:collection", resource_type], []) diff --git a/tests/src/webdav/multiget.rs b/tests/src/webdav/multiget.rs index fa56302a..34b0549b 100644 --- a/tests/src/webdav/multiget.rs +++ b/tests/src/webdav/multiget.rs @@ -21,7 +21,11 @@ pub async fn test(test: &TestServer) { let mut paths = Vec::new(); for name in ["file1", "file2"] { let contents = resource_type.generate(); - let path = format!("{}/john%40example.com/default/{}", resource_type.base_path(), name); + let path = format!( + "{}/john%40example.com/default/{}", + resource_type.base_path(), + name + ); let etag = client .request("PUT", &path, contents.as_str()) .await