summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormdecimus <mauro@stalw.art>2024-07-07 16:44:37 +0200
committermdecimus <mauro@stalw.art>2024-07-07 16:44:37 +0200
commit14224c5ee0035e58b58fa3235deb66c3dd1cd853 (patch)
tree7dede4ec74344d3318a96224a29c0d92bc02be8f
parentaf89725d203ad302076f3d9a899bb263ac8b86ba (diff)
Undelete emails 💎 (closes #589)
-rw-r--r--Cargo.lock32
-rw-r--r--crates/cli/Cargo.toml2
-rw-r--r--crates/common/Cargo.toml2
-rw-r--r--crates/common/src/config/mod.rs12
-rw-r--r--crates/common/src/lib.rs3
-rw-r--r--crates/directory/Cargo.toml2
-rw-r--r--crates/imap/Cargo.toml2
-rw-r--r--crates/jmap-proto/src/types/collection.rs23
-rw-r--r--crates/jmap/Cargo.toml3
-rw-r--r--crates/jmap/src/api/management/enterprise.rs236
-rw-r--r--crates/jmap/src/api/management/mod.rs29
-rw-r--r--crates/jmap/src/api/management/stores.rs15
-rw-r--r--crates/jmap/src/auth/oauth/auth.rs2
-rw-r--r--crates/jmap/src/email/delete.rs47
-rw-r--r--crates/main/Cargo.toml2
-rw-r--r--crates/managesieve/Cargo.toml2
-rw-r--r--crates/nlp/Cargo.toml2
-rw-r--r--crates/pop3/Cargo.toml2
-rw-r--r--crates/se-common/Cargo.toml5
-rw-r--r--crates/se-common/src/lib.rs1
-rw-r--r--crates/se-common/src/undelete.rs130
-rw-r--r--crates/se-licensing/Cargo.toml2
-rw-r--r--crates/smtp/Cargo.toml2
-rw-r--r--crates/store/Cargo.toml2
-rw-r--r--crates/store/src/write/blob.rs2
-rw-r--r--crates/utils/Cargo.toml2
-rw-r--r--tests/src/jmap/auth_oauth.rs1
27 files changed, 526 insertions, 39 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 93815e22..745568bc 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 e5ea7793..75dcd49c 100644
--- a/crates/cli/Cargo.toml
+++ b/crates/cli/Cargo.toml
@@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. <hello@stalw.art>"]
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 90157faa..5a5638b1 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 eb5e7290..0563eefb 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::<Option<Duration>>(
+ "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 f5f193e1..458714e9 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<Duration>,
}
// SPDX-SnippetEnd
diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml
index 22363100..607703a8 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 900cae01..fb351909 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 4147a91c..ede1fd14 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<Self, Self::Err> {
+ 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 34914b05..5abca4e9 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 00000000..39c752f6
--- /dev/null
+++ b/crates/jmap/src/api/management/enterprise.rs
@@ -0,0 +1,236 @@
+/*
+ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
+ *
+ * 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<H, C, T> {
+ hash: H,
+ collection: C,
+ #[serde(rename = "restoreTime")]
+ time: T,
+ #[serde(rename = "cancelDeletion")]
+ #[serde(default)]
+ cancel_deletion: Option<T>,
+}
+
+#[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<Vec<u8>>,
+ ) -> 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<Vec<u8>>,
+ ) -> 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::<usize>("limit").unwrap_or_default();
+ let mut offset = params
+ .parse::<usize>("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::<Vec<UndeleteRequest<String, String, String>>>(
+ 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::<Option<Vec<_>>>()
+ }) {
+ 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 fffcad91..97a92571 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 <hello@stalw.art>
+ // 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 742526c5..c31ac884 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::<u32>() {
- 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 40d9fd6b..33a17e10 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 39a79b69..987a44ba 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,
@@ -254,6 +255,36 @@ impl JMAP {
.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
+ .lookup
.counter_incr(
format!("purge:{account_id}").into_bytes(),
1,
@@ -493,7 +524,23 @@ impl JMAP {
})
.await?
{
+ // SPDX-SnippetBegin
+ // SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
+ // 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 815e6305..6e1d4176 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 2e9ae480..c9510411 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 cf8bfbf9..7d89ada7 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 58a20ed3..54b4aafb 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 8a28a13b..16cad635 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 ee05adec..1d286960 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 00000000..fc9a19ae
--- /dev/null
+++ b/crates/se-common/src/undelete.rs
@@ -0,0 +1,130 @@
+/*
+ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
+ *
+ * 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<H, T, C> {
+ 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<Output = store::Result<Vec<DeletedBlob<BlobHash, u64, u8>>>> + 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<Vec<DeletedBlob<BlobHash, u64, u8>>> {
+ 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 9054a6e7..cd3f489a 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 630a38ad..d4627a8f 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 83ca04fb..9fd6426a 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 4462fa2f..9a75ab2f 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 f8979f1c..9a7bf19a 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 8f3c7e51..c89ff168 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) {