From 14224c5ee0035e58b58fa3235deb66c3dd1cd853 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Sun, 7 Jul 2024 16:44:37 +0200 Subject: [PATCH] =?UTF-8?q?Undelete=20emails=20=F0=9F=92=8E=20(closes=20#5?= =?UTF-8?q?89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 32 +-- crates/cli/Cargo.toml | 2 +- crates/common/Cargo.toml | 2 +- crates/common/src/config/mod.rs | 12 +- crates/common/src/lib.rs | 3 +- crates/directory/Cargo.toml | 2 +- crates/imap/Cargo.toml | 2 +- crates/jmap-proto/src/types/collection.rs | 23 +- crates/jmap/Cargo.toml | 3 +- crates/jmap/src/api/management/enterprise.rs | 236 +++++++++++++++++++ crates/jmap/src/api/management/mod.rs | 29 ++- crates/jmap/src/api/management/stores.rs | 15 +- crates/jmap/src/auth/oauth/auth.rs | 2 + crates/jmap/src/email/delete.rs | 47 ++++ crates/main/Cargo.toml | 2 +- crates/managesieve/Cargo.toml | 2 +- crates/nlp/Cargo.toml | 2 +- crates/pop3/Cargo.toml | 2 +- crates/se-common/Cargo.toml | 5 +- crates/se-common/src/lib.rs | 1 + crates/se-common/src/undelete.rs | 130 ++++++++++ crates/se-licensing/Cargo.toml | 2 +- crates/smtp/Cargo.toml | 2 +- crates/store/Cargo.toml | 2 +- crates/store/src/write/blob.rs | 2 +- crates/utils/Cargo.toml | 2 +- tests/src/jmap/auth_oauth.rs | 1 + 27 files changed, 526 insertions(+), 39 deletions(-) create mode 100644 crates/jmap/src/api/management/enterprise.rs create mode 100644 crates/se-common/src/undelete.rs diff --git a/Cargo.lock b/Cargo.lock index 93815e22b..745568bc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1016,7 +1016,7 @@ dependencies = [ [[package]] name = "common" -version = "0.8.4" +version = "0.8.5" dependencies = [ "ahash 0.8.11", "arc-swap", @@ -1626,7 +1626,7 @@ dependencies = [ [[package]] name = "directory" -version = "0.8.4" +version = "0.8.5" dependencies = [ "ahash 0.8.11", "argon2", @@ -2939,7 +2939,7 @@ checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" [[package]] name = "imap" -version = "0.8.4" +version = "0.8.5" dependencies = [ "ahash 0.8.11", "common", @@ -3141,7 +3141,7 @@ dependencies = [ [[package]] name = "jmap" -version = "0.8.4" +version = "0.8.5" dependencies = [ "aes", "aes-gcm", @@ -3180,6 +3180,7 @@ dependencies = [ "reqwest 0.12.5", "rev_lines", "rsa", + "se_common", "sequoia-openpgp", "serde", "serde_json", @@ -3578,7 +3579,7 @@ dependencies = [ [[package]] name = "mail-server" -version = "0.8.4" +version = "0.8.5" dependencies = [ "common", "directory", @@ -3598,7 +3599,7 @@ dependencies = [ [[package]] name = "managesieve" -version = "0.8.4" +version = "0.8.5" dependencies = [ "ahash 0.8.11", "bincode", @@ -3875,7 +3876,7 @@ dependencies = [ [[package]] name = "nlp" -version = "0.8.4" +version = "0.8.5" dependencies = [ "ahash 0.8.11", "bincode", @@ -4457,7 +4458,7 @@ dependencies = [ [[package]] name = "pop3" -version = "0.8.4" +version = "0.8.5" dependencies = [ "common", "imap", @@ -5628,15 +5629,18 @@ checksum = "b84345e4c9bd703274a082fb80caaa99b7612be48dfaa1dd9266577ec412309d" [[package]] name = "se_common" -version = "0.8.4" +version = "0.8.5" dependencies = [ "common", + "serde", + "store", "tracing", + "utils", ] [[package]] name = "se_licensing" -version = "0.8.4" +version = "0.8.5" dependencies = [ "base64 0.22.1", "ring 0.17.8", @@ -6016,7 +6020,7 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "smtp" -version = "0.8.4" +version = "0.8.5" dependencies = [ "ahash 0.8.11", "bincode", @@ -6133,7 +6137,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "stalwart-cli" -version = "0.8.4" +version = "0.8.5" dependencies = [ "clap", "console", @@ -6164,7 +6168,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "store" -version = "0.8.4" +version = "0.8.5" dependencies = [ "ahash 0.8.11", "arc-swap", @@ -7104,7 +7108,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utils" -version = "0.8.4" +version = "0.8.5" dependencies = [ "ahash 0.8.11", "base64 0.22.1", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index e5ea7793e..75dcd49c3 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. "] license = "AGPL-3.0-only OR LicenseRef-SEL" repository = "https://github.com/stalwartlabs/cli" homepage = "https://github.com/stalwartlabs/cli" -version = "0.8.4" +version = "0.8.5" edition = "2021" readme = "README.md" resolver = "2" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 90157faae..5a5638b19 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "common" -version = "0.8.4" +version = "0.8.5" edition = "2021" resolver = "2" diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index eb5e72907..0563eefbd 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use arc_swap::ArcSwap; use directory::{Directories, Directory}; @@ -149,7 +149,15 @@ impl Core { } None } - _ => Some(Enterprise { license }), + _ => Some(Enterprise { + license, + undelete_period: config + .property_or_default::>( + "enterprise.undelete-period", + "false", + ) + .unwrap_or_default(), + }), } } Some(Err(e)) => { diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index f5f193e1a..458714e93 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{borrow::Cow, net::IpAddr, sync::Arc}; +use std::{borrow::Cow, net::IpAddr, sync::Arc, time::Duration}; use arc_swap::ArcSwap; use config::{ @@ -88,6 +88,7 @@ pub struct Network { #[derive(Clone)] pub struct Enterprise { pub license: LicenseKey, + pub undelete_period: Option, } // SPDX-SnippetEnd diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index 22363100a..607703a85 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "directory" -version = "0.8.4" +version = "0.8.5" edition = "2021" resolver = "2" diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index 900cae014..fb3519093 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap" -version = "0.8.4" +version = "0.8.5" edition = "2021" resolver = "2" diff --git a/crates/jmap-proto/src/types/collection.rs b/crates/jmap-proto/src/types/collection.rs index 4147a91cd..ede1fd148 100644 --- a/crates/jmap-proto/src/types/collection.rs +++ b/crates/jmap-proto/src/types/collection.rs @@ -4,7 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::fmt::{self, Display, Formatter}; +use std::{ + fmt::{self, Display, Formatter}, + str::FromStr, +}; use utils::map::bitmap::BitmapItem; @@ -101,6 +104,24 @@ impl Display for Collection { } } +impl FromStr for Collection { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "pushSubscription" => Ok(Collection::PushSubscription), + "email" => Ok(Collection::Email), + "mailbox" => Ok(Collection::Mailbox), + "thread" => Ok(Collection::Thread), + "identity" => Ok(Collection::Identity), + "emailSubmission" => Ok(Collection::EmailSubmission), + "sieveScript" => Ok(Collection::SieveScript), + "principal" => Ok(Collection::Principal), + _ => Err(()), + } + } +} + impl BitmapItem for Collection { fn max() -> u64 { Collection::None as u64 diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index 34914b05b..5abca4e97 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap" -version = "0.8.4" +version = "0.8.5" edition = "2021" resolver = "2" @@ -11,6 +11,7 @@ jmap_proto = { path = "../jmap-proto" } smtp = { path = "../smtp" } utils = { path = "../utils" } common = { path = "../common" } +se_common = { path = "../se-common" } directory = { path = "../directory" } smtp-proto = { version = "0.1" } mail-parser = { version = "0.9", features = ["full_encoding", "serde_support", "ludicrous_mode"] } diff --git a/crates/jmap/src/api/management/enterprise.rs b/crates/jmap/src/api/management/enterprise.rs new file mode 100644 index 000000000..39c752f68 --- /dev/null +++ b/crates/jmap/src/api/management/enterprise.rs @@ -0,0 +1,236 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: LicenseRef-SEL + * + * This file is subject to the Stalwart Enterprise License Agreement (SEL) and + * is not open source software. It must not be modified or distributed without + * explicit permission from Stalwart Labs Ltd. + * Unauthorized use, modification, or distribution is strictly prohibited. + */ + +use std::str::FromStr; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use directory::backend::internal::manage::ManageDirectory; +use hyper::Method; +use jmap_proto::{error::request::RequestError, types::collection::Collection}; +use mail_parser::{DateTime, MessageParser}; +use se_common::undelete::{DeletedBlob, Undelete}; +use serde_json::json; +use store::write::{BatchBuilder, BlobOp, ValueClass}; +use utils::{url_params::UrlParams, BlobHash}; + +use crate::{ + api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}, + email::ingest::{IngestEmail, IngestSource}, + mailbox::INBOX_ID, + IngestError, JMAP, +}; + +#[derive(serde::Deserialize)] +struct UndeleteRequest { + hash: H, + collection: C, + #[serde(rename = "restoreTime")] + time: T, + #[serde(rename = "cancelDeletion")] + #[serde(default)] + cancel_deletion: Option, +} + +#[derive(serde::Serialize)] +#[serde(tag = "type")] +#[serde(rename_all = "camelCase")] +enum UndeleteResponse { + Success, + NotFound, + Error { reason: String }, +} + +impl JMAP { + pub async fn handle_enterprise_api_request( + &self, + req: &HttpRequest, + path: Vec<&str>, + body: Option>, + ) -> HttpResponse { + match path.get(1).copied().unwrap_or_default() { + "undelete" => self.handle_undelete_api_request(req, path, body).await, + _ => RequestError::not_found().into_http_response(), + } + } + + async fn handle_undelete_api_request( + &self, + req: &HttpRequest, + path: Vec<&str>, + body: Option>, + ) -> HttpResponse { + match (path.get(2).copied(), req.method()) { + (Some(account_name), &Method::GET) => { + match self.core.storage.data.get_account_id(account_name).await { + Ok(Some(account_id)) => match self.core.list_deleted(account_id).await { + Ok(mut deleted) => { + let params = UrlParams::new(req.uri().query()); + let limit = params.parse::("limit").unwrap_or_default(); + let mut offset = params + .parse::("page") + .unwrap_or_default() + .saturating_sub(1) + * limit; + + // Sort ascending by deleted_at + let total = deleted.len(); + deleted.sort_by(|a, b| a.deleted_at.cmp(&b.deleted_at)); + let mut results = + Vec::with_capacity(if limit > 0 { limit } else { total }); + + for blob in deleted { + if offset == 0 { + results.push(DeletedBlob { + hash: URL_SAFE_NO_PAD.encode(blob.hash.as_slice()), + size: blob.size, + deleted_at: DateTime::from_timestamp( + blob.deleted_at as i64, + ) + .to_rfc3339(), + expires_at: DateTime::from_timestamp( + blob.expires_at as i64, + ) + .to_rfc3339(), + collection: Collection::from(blob.collection).to_string(), + }); + if results.len() == limit { + break; + } + } else { + offset -= 1; + } + } + + JsonResponse::new(json!({ + "data":{ + "items": results, + "total": total, + }, + })) + .into_http_response() + } + Err(err) => err.into_http_response(), + }, + Ok(None) => RequestError::not_found().into_http_response(), + Err(err) => err.into_http_response(), + } + } + (Some(account_name), &Method::POST) => { + match self.core.storage.data.get_account_id(account_name).await { + Ok(Some(account_id)) => { + match serde_json::from_slice::>>( + body.as_deref().unwrap_or_default(), + ) + .ok() + .and_then(|request| { + request.into_iter().map(|request| { + UndeleteRequest { + hash: BlobHash::try_from_hash_slice( + URL_SAFE_NO_PAD + .decode(request.hash.as_bytes()) + .ok()? + .as_slice(), + ) + .ok()?, + collection: Collection::from_str( + request.collection.as_str(), + ) + .ok()?, + time: DateTime::parse_rfc3339(request.time.as_str())? + .to_timestamp(), + cancel_deletion: if let Some(cancel_deletion) = request.cancel_deletion { + DateTime::parse_rfc3339(cancel_deletion.as_str())? + .to_timestamp().into() + } else { + None + } + } + .into() + }).collect::>>() + }) { + Some(requests) => { + let mut results = Vec::with_capacity(requests.len()); + let mut batch = BatchBuilder::new(); + batch.with_account_id(account_id); + for request in requests { + match request.collection { + Collection::Email => { + match self.get_blob(&request.hash, 0..usize::MAX).await + { + Ok(Some(bytes)) => { + match self + .email_ingest(IngestEmail { + raw_message: &bytes, + message: MessageParser::new().parse(&bytes), + account_id, + account_quota: 0, + mailbox_ids: vec![INBOX_ID], + keywords: vec![], + received_at: (request.time as u64).into(), + source: IngestSource::Smtp, + encrypt: false, + }) + .await + { + Ok(_) => { + results.push(UndeleteResponse::Success); + if let Some(cancel_deletion) = request.cancel_deletion { + batch.clear(ValueClass::Blob(BlobOp::Reserve { hash: request.hash, until: cancel_deletion as u64 })); + } + }, + Err(IngestError::Permanent { reason, .. }) => { + results.push(UndeleteResponse::Error { reason }); + } + Err(_) => { + return RequestError::internal_server_error().into_http_response(); + }, + } + } + Ok(None) => { + results.push(UndeleteResponse::NotFound); + }, + Err(_) => { + return RequestError::internal_server_error().into_http_response(); + }, + } + } + _ => { + results.push(UndeleteResponse::Error { + reason: "Unsupported collection".to_string(), + }); + } + } + } + + // Commit batch + if !batch.is_empty() { + match self.core.storage.data.write(batch.build()).await { + Ok(_) => (), + Err(err) => return err.into_http_response(), + } + } + + JsonResponse::new(json!({ + "data": results, + })) + .into_http_response() + }, + None => RequestError::invalid_parameters().into_http_response(), + } + } + Ok(None) => RequestError::not_found().into_http_response(), + Err(err) => err.into_http_response(), + } + } + _ => RequestError::not_found().into_http_response(), + } + } +} diff --git a/crates/jmap/src/api/management/mod.rs b/crates/jmap/src/api/management/mod.rs index fffcad915..97a925713 100644 --- a/crates/jmap/src/api/management/mod.rs +++ b/crates/jmap/src/api/management/mod.rs @@ -6,6 +6,7 @@ pub mod dkim; pub mod domain; +pub mod enterprise; pub mod log; pub mod principal; pub mod queue; @@ -21,9 +22,9 @@ use hyper::Method; use jmap_proto::error::request::RequestError; use serde::Serialize; -use crate::{auth::AccessToken, JMAP}; - use super::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}; +use crate::{auth::AccessToken, JMAP}; +use se_common::EnterpriseCore; #[derive(Serialize)] #[serde(tag = "error")] @@ -90,6 +91,30 @@ impl JMAP { } _ => RequestError::not_found().into_http_response(), }, + + // SPDX-SnippetBegin + // SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + // SPDX-License-Identifier: LicenseRef-SEL + "pro" if is_superuser => { + // WARNING: TAMPERING WITH THIS FUNCTION IS STRICTLY PROHIBITED + // Any attempt to modify, bypass, or disable this license validation mechanism + // constitutes a severe violation of the Stalwart Enterprise License Agreement. + // Such actions may result in immediate termination of your license, legal action, + // and substantial financial penalties. Stalwart Labs Ltd. actively monitors for + // unauthorized modifications and will pursue all available legal remedies against + // violators to the fullest extent of the law, including but not limited to claims + // for copyright infringement, breach of contract, and fraud. + + if self.core.is_enterprise_edition() { + self.handle_enterprise_api_request(req, path, body).await + } else { + ManagementApiError::Unsupported { + details: "This feature is only available in the Enterprise version".into(), + } + .into_http_response() + } + } + // SPDX-SnippetEnd _ => RequestError::not_found().into_http_response(), } } diff --git a/crates/jmap/src/api/management/stores.rs b/crates/jmap/src/api/management/stores.rs index 742526c5d..c31ac8849 100644 --- a/crates/jmap/src/api/management/stores.rs +++ b/crates/jmap/src/api/management/stores.rs @@ -6,6 +6,7 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use common::manager::webadmin::Resource; +use directory::backend::internal::manage::ManageDirectory; use hyper::Method; use jmap_proto::error::request::RequestError; use serde_json::json; @@ -101,10 +102,16 @@ impl JMAP { } (Some("purge"), Some("account"), id, &Method::GET) => { let account_id = if let Some(id) = id { - if let Ok(account_id) = id.parse::() { - account_id.into() - } else { - return RequestError::invalid_parameters().into_http_response(); + match self + .core + .storage + .data + .get_account_id(decode_path_element(id).as_ref()) + .await + { + Ok(Some(id)) => id.into(), + Ok(None) => return RequestError::not_found().into_http_response(), + Err(err) => return err.into_http_response(), } } else { None diff --git a/crates/jmap/src/auth/oauth/auth.rs b/crates/jmap/src/auth/oauth/auth.rs index 40d9fd6b5..33a17e102 100644 --- a/crates/jmap/src/auth/oauth/auth.rs +++ b/crates/jmap/src/auth/oauth/auth.rs @@ -23,6 +23,7 @@ use crate::{ auth::{oauth::OAuthStatus, AccessToken}, JMAP, }; +use se_common::EnterpriseCore; use super::{ DeviceAuthResponse, FormData, OAuthCode, OAuthCodeRequest, CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, @@ -93,6 +94,7 @@ impl JMAP { "data": { "code": client_code, "is_admin": access_token.is_super_user(), + "is_enterprise": self.core.is_enterprise_edition(), }, }) } diff --git a/crates/jmap/src/email/delete.rs b/crates/jmap/src/email/delete.rs index 39a79b698..987a44ba5 100644 --- a/crates/jmap/src/email/delete.rs +++ b/crates/jmap/src/email/delete.rs @@ -13,6 +13,7 @@ use jmap_proto::{ type_state::DataType, }, }; +use se_common::undelete::Undelete; use store::{ ahash::AHashMap, roaring::RoaringBitmap, @@ -250,6 +251,36 @@ impl JMAP { pub async fn purge_account(&self, account_id: u32) { // Lock account + match self + .core + .storage + .lookup + .counter_get(format!("purge:{account_id}").into_bytes()) + .await + { + Ok(count) => { + if count > 0 { + tracing::debug!( + event = "skipped", + context = "email_purge_account", + account_id = account_id, + count, + "Account is already being purged." + ); + return; + } + } + Err(err) => { + tracing::error!( + event = "error", + context = "email_purge_account", + account_id = account_id, + error = ?err, + "Failed to lock account." + ); + return; + } + } match self .core .storage @@ -493,7 +524,23 @@ impl JMAP { }) .await? { + // SPDX-SnippetBegin + // SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + // SPDX-License-Identifier: LicenseRef-SEL + + // Hold blob for undeletion + self.core.hold_undelete( + &mut batch, + Collection::Email.into(), + &metadata.inner.blob_hash, + metadata.inner.size, + ); + + // SPDX-SnippetEnd + + // Delete message batch.custom(EmailIndexBuilder::clear(metadata.inner)); + // Commit batch self.core.storage.data.write(batch.build()).await?; } else { diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index 815e63052..6e1d41760 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", "server"] categories = ["email"] license = "AGPL-3.0-only OR LicenseRef-SEL" -version = "0.8.4" +version = "0.8.5" edition = "2021" resolver = "2" diff --git a/crates/managesieve/Cargo.toml b/crates/managesieve/Cargo.toml index 2e9ae4802..c95104113 100644 --- a/crates/managesieve/Cargo.toml +++ b/crates/managesieve/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "managesieve" -version = "0.8.4" +version = "0.8.5" edition = "2021" resolver = "2" diff --git a/crates/nlp/Cargo.toml b/crates/nlp/Cargo.toml index cf8bfbf95..7d89ada7e 100644 --- a/crates/nlp/Cargo.toml +++ b/crates/nlp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nlp" -version = "0.8.4" +version = "0.8.5" edition = "2021" resolver = "2" diff --git a/crates/pop3/Cargo.toml b/crates/pop3/Cargo.toml index 58a20ed37..54b4aafb4 100644 --- a/crates/pop3/Cargo.toml +++ b/crates/pop3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pop3" -version = "0.8.4" +version = "0.8.5" edition = "2021" resolver = "2" diff --git a/crates/se-common/Cargo.toml b/crates/se-common/Cargo.toml index 8a28a13b7..16cad6350 100644 --- a/crates/se-common/Cargo.toml +++ b/crates/se-common/Cargo.toml @@ -1,13 +1,16 @@ [package] name = "se_common" -version = "0.8.4" +version = "0.8.5" edition = "2021" license = "LicenseRef-SEL" resolver = "2" [dependencies] common = { path = "../common" } +store = { path = "../store" } +utils = { path = "../utils" } tracing = "0.1" +serde = { version = "1.0", features = ["derive"]} [features] test_mode = [] diff --git a/crates/se-common/src/lib.rs b/crates/se-common/src/lib.rs index ee05adec1..1d2869602 100644 --- a/crates/se-common/src/lib.rs +++ b/crates/se-common/src/lib.rs @@ -9,6 +9,7 @@ * Unauthorized use, modification, or distribution is strictly prohibited. */ +pub mod undelete; use common::Core; pub trait EnterpriseCore { diff --git a/crates/se-common/src/undelete.rs b/crates/se-common/src/undelete.rs new file mode 100644 index 000000000..fc9a19aee --- /dev/null +++ b/crates/se-common/src/undelete.rs @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: LicenseRef-SEL + * + * This file is subject to the Stalwart Enterprise License Agreement (SEL) and + * is not open source software. It must not be modified or distributed without + * explicit permission from Stalwart Labs Ltd. + * Unauthorized use, modification, or distribution is strictly prohibited. + */ + +use std::future::Future; + +use common::Core; +use serde::{Deserialize, Serialize}; +use store::{ + write::{ + key::{DeserializeBigEndian, KeySerializer}, + now, BatchBuilder, BlobOp, ValueClass, + }, + IterateParams, ValueKey, U32_LEN, U64_LEN, +}; +use utils::{BlobHash, BLOB_HASH_LEN}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeletedBlob { + pub hash: H, + pub size: usize, + #[serde(rename = "deletedAt")] + pub deleted_at: T, + #[serde(rename = "expiresAt")] + pub expires_at: T, + pub collection: C, +} + +pub trait Undelete: Sync + Send { + fn hold_undelete( + &self, + batch: &mut BatchBuilder, + collection: u8, + blob_hash: &BlobHash, + blob_size: usize, + ); + fn list_deleted( + &self, + account_id: u32, + ) -> impl Future>>> + Send; +} + +impl Undelete for Core { + fn hold_undelete( + &self, + batch: &mut BatchBuilder, + collection: u8, + blob_hash: &BlobHash, + blob_size: usize, + ) { + if let Some(hold_period) = self.enterprise.as_ref().and_then(|e| e.undelete_period) { + let now = now(); + + batch.set( + BlobOp::Reserve { + hash: blob_hash.clone(), + until: now + hold_period.as_secs(), + }, + KeySerializer::new(U64_LEN + U64_LEN) + .write(blob_size as u32) + .write(now) + .write(collection) + .finalize(), + ); + } + } + + async fn list_deleted( + &self, + account_id: u32, + ) -> store::Result>> { + let from_key = ValueKey { + account_id, + collection: 0, + document_id: 0, + class: ValueClass::Blob(BlobOp::Reserve { + hash: BlobHash::default(), + until: 0, + }), + }; + let to_key = ValueKey { + account_id: account_id + 1, + collection: 0, + document_id: 0, + class: ValueClass::Blob(BlobOp::Reserve { + hash: BlobHash::default(), + until: 0, + }), + }; + + let now = now(); + let mut results = Vec::new(); + + self.storage + .data + .iterate( + IterateParams::new(from_key, to_key).ascending(), + |key, value| { + let expires_at = key.deserialize_be_u64(key.len() - U64_LEN)?; + if value.len() == U32_LEN + U64_LEN + 1 && expires_at > now { + results.push(DeletedBlob { + hash: BlobHash::try_from_hash_slice( + key.get(U32_LEN..U32_LEN + BLOB_HASH_LEN).ok_or_else(|| { + store::Error::InternalError(format!( + "Invalid key {key:?} in blob hash tables" + )) + })?, + ) + .unwrap(), + size: value.deserialize_be_u32(0)? as usize, + deleted_at: value.deserialize_be_u64(U32_LEN)?, + expires_at, + collection: *value.last().unwrap(), + }); + } + Ok(true) + }, + ) + .await?; + + Ok(results) + } +} diff --git a/crates/se-licensing/Cargo.toml b/crates/se-licensing/Cargo.toml index 9054a6e79..cd3f489aa 100644 --- a/crates/se-licensing/Cargo.toml +++ b/crates/se-licensing/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "se_licensing" -version = "0.8.4" +version = "0.8.5" edition = "2021" license = "LicenseRef-SEL" resolver = "2" diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index 630a38ad3..d4627a8f2 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.8.4" +version = "0.8.5" edition = "2021" resolver = "2" diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 83ca04fbf..9fd6426aa 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "store" -version = "0.8.4" +version = "0.8.5" edition = "2021" resolver = "2" diff --git a/crates/store/src/write/blob.rs b/crates/store/src/write/blob.rs index 4462fa2fc..9a75ab2f4 100644 --- a/crates/store/src/write/blob.rs +++ b/crates/store/src/write/blob.rs @@ -64,7 +64,7 @@ impl Store { IterateParams::new(from_key, to_key).ascending(), |key, value| { let until = key.deserialize_be_u64(key.len() - U64_LEN)?; - if until > now { + if until > now && value.len() == U32_LEN { let bytes = u32::deserialize(value)?; if bytes > 0 { quota.bytes += bytes as usize; diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index f8979f1cb..9a7bf19ae 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "utils" -version = "0.8.4" +version = "0.8.5" edition = "2021" resolver = "2" diff --git a/tests/src/jmap/auth_oauth.rs b/tests/src/jmap/auth_oauth.rs index 8f3c7e516..c89ff1687 100644 --- a/tests/src/jmap/auth_oauth.rs +++ b/tests/src/jmap/auth_oauth.rs @@ -28,6 +28,7 @@ use super::JMAPTest; struct OAuthCodeResponse { code: String, is_admin: bool, + is_enterprise: bool, } pub async fn test(params: &mut JMAPTest) {