diff --git a/crates/features/src/undelete/accounts.rs b/crates/features/src/undelete/accounts.rs new file mode 100644 index 0000000..cd2b366 --- /dev/null +++ b/crates/features/src/undelete/accounts.rs @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Deleted accounts kept for their period (UD-15 to UD-17a). The account's +//! record is removed as upstream removes it; a copy waits here with its +//! shares, both ways, until it's restored or its `DestroyAccount` task runs. + +use crate::undelete::{ + data::{self, Share}, + records, +}; +use store::{ + Deserialize, IterateParams, RegistryStore, SerializeInfallible, Store, U32_LEN, ValueKey, + write::{BatchBuilder, ValueClass, key::DeserializeBigEndian}, +}; +use trc::AddContext; +use types::collection::Collection; + +/// Every share `account_id` is part of, either way. +async fn shares_of(data: &Store, account_id: u32) -> trc::Result> { + let mut shares = Vec::new(); + data.iterate( + IterateParams::new( + ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Acl(0), + }, + ValueKey { + account_id: u32::MAX, + collection: u8::MAX, + document_id: u32::MAX, + class: ValueClass::Acl(u32::MAX), + }, + ) + .ascending(), + |key, value| { + // grantee, owner, collection, document + let grantee = key.deserialize_be_u32(0)?; + let owner = key.deserialize_be_u32(U32_LEN)?; + if grantee == account_id || owner == account_id { + shares.push(Share { + grantee, + owner, + collection: *key.get(U32_LEN * 2).ok_or_else(|| { + trc::StoreEvent::DataCorruption.caused_by(trc::location!()) + })?, + document_id: key.deserialize_be_u32(U32_LEN * 2 + 1)?, + permissions: u64::deserialize(value)?, + }); + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + Ok(shares) +} + +fn write_shares<'x>( + batch: &mut BatchBuilder, + shares: impl Iterator, + grant: bool, +) { + for share in shares { + batch + .with_account_id(share.owner) + .with_collection(Collection::from(share.collection)) + .with_document(share.document_id); + if grant { + batch.acl_grant(share.grantee, share.permissions.serialize()); + } else { + batch.acl_revoke(share.grantee); + } + } +} + +/// Revokes every share an account is part of and returns them (UD-17a). +pub async fn suspend_shares(data: &Store, account_id: u32) -> trc::Result> { + let shares = shares_of(data, account_id).await?; + for chunk in shares.chunks(1000) { + let mut batch = BatchBuilder::new(); + write_shares(&mut batch, chunk.iter(), false); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + Ok(shares) +} + +/// Grants back the suspended shares whose other account still exists +/// (UD-17a). Returns the other accounts, whose access changes. +pub async fn reinstate_shares( + data: &Store, + account_id: u32, + shares: &[Share], + exists: impl Fn(u32) -> bool, +) -> trc::Result> { + let shares = shares + .iter() + .filter(|share| { + let other = if share.owner == account_id { + share.grantee + } else { + share.owner + }; + other == account_id || exists(other) + }) + .collect::>(); + for chunk in shares.chunks(1000) { + let mut batch = BatchBuilder::new(); + write_shares(&mut batch, chunk.iter().copied(), true); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + let mut others = shares + .iter() + .flat_map(|share| [share.owner, share.grantee]) + .filter(|id| *id != account_id) + .collect::>(); + others.sort_unstable(); + others.dedup(); + Ok(others) +} + +/// When the account is finally destroyed: its hold, and everything undelete +/// kept for it, go too. +pub async fn forget(data: &Store, registry: &RegistryStore, account_id: u32) -> trc::Result<()> { + if let Some(kept) = data::kept_account(data, account_id).await? { + let mut batch = BatchBuilder::new(); + data::clear_kept_account(&mut batch, account_id, &kept); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + for (id, item) in records::of_account(data, registry, account_id).await? { + records::remove(data, registry, id, &item).await?; + } + data::clear_account(data, account_id).await +} diff --git a/crates/features/src/undelete/data.rs b/crates/features/src/undelete/data.rs index c69813d..c398800 100644 --- a/crates/features/src/undelete/data.rs +++ b/crates/features/src/undelete/data.rs @@ -163,6 +163,22 @@ pub struct KeptAccount { pub member_tenant_id: Option, pub deleted_at: u64, pub kept_until: u64, + /// The id of its `DestroyAccount` task, due at `kept_until`. + #[serde(default)] + pub task_id: u64, + /// Its shares, both ways, suspended while it's kept (UD-17a). + #[serde(default)] + pub shares: Vec, +} + +/// One share: `grantee` may reach `owner`'s document with `permissions`. +#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +pub struct Share { + pub grantee: u32, + pub owner: u32, + pub collection: u8, + pub document_id: u32, + pub permissions: u64, } pub fn note_email( @@ -382,6 +398,44 @@ pub async fn kept_accounts(data: &Store) -> trc::Result> Ok(kept) } +/// Clears what's kept under an account's id: its notes, archive links and +/// change log. The records themselves go with `records::remove`. +pub async fn clear_account(data: &Store, account_id: u32) -> trc::Result<()> { + // Account ids stop short of u32::MAX, the store's sentinel + let (from, to) = (account_id.to_be_bytes(), (account_id + 1).to_be_bytes()); + let mut ranges = vec![ + (vec![KIND_NOTE], vec![KIND_NOTE]), + (vec![KIND_BLOB], vec![KIND_BLOB]), + (vec![KIND_CHANGE], vec![KIND_CHANGE]), + ]; + // The groupware notes, `Ug` + kind + account + document + for kind in 0u8..3 { + ranges.push((vec![b'g', kind], vec![b'g', kind])); + } + for (mut start, mut end) in ranges { + start.extend_from_slice(&from); + end.extend_from_slice(&to); + data.delete_range( + ValueKey::from(class_raw(&start)), + ValueKey::from(class_raw(&end)), + ) + .await + .caused_by(trc::location!())?; + } + Ok(()) +} + +/// A key under `U` spelled out in full (the groupware notes, `Ug`). +fn class_raw(rest: &[u8]) -> ValueClass { + let mut key = Vec::with_capacity(1 + rest.len()); + key.push(FEATURE); + key.extend_from_slice(rest); + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + /// The kept account an address is reserved for (UD-16). pub async fn reserved_by(data: &Store, address: &str) -> trc::Result> { data.get_value::(key(KIND_RESERVED, address.to_lowercase().as_bytes())) diff --git a/crates/features/src/undelete/mod.rs b/crates/features/src/undelete/mod.rs index aea2b44..6eba891 100644 --- a/crates/features/src/undelete/mod.rs +++ b/crates/features/src/undelete/mod.rs @@ -13,6 +13,7 @@ //! beyond them, and the fork's bookkeeping, live in the fork's own subspace //! (`data`). Requirements are named `UD-n`, after the spec. +pub mod accounts; pub mod data; pub mod email; pub mod groupware; diff --git a/crates/jmap-proto/src/object/inbuxa_deleted_account.rs b/crates/jmap-proto/src/object/inbuxa_deleted_account.rs new file mode 100644 index 0000000..f76a140 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_deleted_account.rs @@ -0,0 +1,181 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:DeletedAccount/get` and `/set` under `urn:inbuxa:jmap`: deleted +//! accounts kept for their period, listed, restored or destroyed for good +//! (undelete spec, UD-15 to UD-17). + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + types::date::UTCDate, +}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct DeletedAccount; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeletedAccountProperty { + Id, + Name, + Addresses, + MemberTenantId, + DeletedAt, + KeptUntil, + Restore, + Password, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeletedAccountValue { + Id(Id), + Date(UTCDate), +} + +impl Property for DeletedAccountProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + DeletedAccountProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + DeletedAccountProperty::Id => "id", + DeletedAccountProperty::Name => "name", + DeletedAccountProperty::Addresses => "addresses", + DeletedAccountProperty::MemberTenantId => "memberTenantId", + DeletedAccountProperty::DeletedAt => "deletedAt", + DeletedAccountProperty::KeptUntil => "keptUntil", + DeletedAccountProperty::Restore => "restore", + DeletedAccountProperty::Password => "password", + } + .into() + } +} + +impl DeletedAccountProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => DeletedAccountProperty::Id, + b"name" => DeletedAccountProperty::Name, + b"addresses" => DeletedAccountProperty::Addresses, + b"memberTenantId" => DeletedAccountProperty::MemberTenantId, + b"deletedAt" => DeletedAccountProperty::DeletedAt, + b"keptUntil" => DeletedAccountProperty::KeptUntil, + b"restore" => DeletedAccountProperty::Restore, + b"password" => DeletedAccountProperty::Password, + ) + } +} + +impl FromStr for DeletedAccountProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + DeletedAccountProperty::parse(s).ok_or(()) + } +} + +impl Element for DeletedAccountValue { + type Property = DeletedAccountProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(DeletedAccountProperty::Id | DeletedAccountProperty::MemberTenantId) => { + Id::from_str(value).ok().map(DeletedAccountValue::Id) + } + Key::Property(DeletedAccountProperty::DeletedAt | DeletedAccountProperty::KeptUntil) => { + UTCDate::from_str(value).ok().map(DeletedAccountValue::Date) + } + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + DeletedAccountValue::Id(id) => id.to_string().into(), + DeletedAccountValue::Date(date) => date.to_string().into(), + } + } +} + +impl JmapObject for DeletedAccount { + type Property = DeletedAccountProperty; + + type Element = DeletedAccountValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = DeletedAccountProperty::Id; +} + +impl From for DeletedAccountValue { + fn from(id: Id) -> Self { + DeletedAccountValue::Id(id) + } +} + +impl JmapObjectId for DeletedAccountValue { + fn as_id(&self) -> Option { + match self { + DeletedAccountValue::Id(id) => Some(*id), + _ => None, + } + } + + fn as_any_id(&self) -> Option { + match self { + DeletedAccountValue::Id(id) => Some(AnyId::Id(*id)), + _ => None, + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = DeletedAccountValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for DeletedAccountProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index cdc77b4..e3bf4fb 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -19,6 +19,7 @@ pub mod contact; pub mod email; pub mod email_submission; pub mod fastmail_masked_email; // inbuxa: masked email +pub mod inbuxa_deleted_account; // inbuxa: undelete pub mod file_node; pub mod identity; pub mod mailbox; diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 9db52fb..beb46b5 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -53,6 +53,9 @@ impl Response<'_> { GetResponseMethod::MaskedEmail(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::DeletedAccount(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::Principal(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index 4f6faed..f0354bf 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -42,6 +42,7 @@ impl Response<'_> { GetRequestMethod::Sieve(request) => request.resolve_references(self)?, GetRequestMethod::VacationResponse(request) => request.resolve_references(self)?, GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?, + GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?, GetRequestMethod::Principal(request) => request.resolve_references(self)?, GetRequestMethod::Quota(request) => request.resolve_references(self)?, GetRequestMethod::Blob(request) => request.resolve_references(self)?, @@ -79,6 +80,9 @@ impl Response<'_> { SetRequestMethod::MaskedEmail(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::DeletedAccount(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::AddressBook(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 9ce5c81..f896b62 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -43,6 +43,8 @@ pub enum MethodObject { Registry(ObjectType), // inbuxa: Fastmail's MaskedEmail MaskedEmail, + // inbuxa: deleted accounts (UD-17) + DeletedAccount, } impl MethodObject { @@ -67,6 +69,7 @@ impl MethodObject { MethodObject::FileNode => Capability::FileNode, MethodObject::Registry(_) => Capability::Stalwart, MethodObject::MaskedEmail => Capability::FastmailMaskedEmail, + MethodObject::DeletedAccount => Capability::Inbuxa, } } } @@ -240,6 +243,8 @@ impl MethodName { (MethodFunction::Echo, MethodObject::Core) => "Core/echo", (MethodFunction::Get, MethodObject::MaskedEmail) => "MaskedEmail/get", (MethodFunction::Set, MethodObject::MaskedEmail) => "MaskedEmail/set", + (MethodFunction::Get, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/get", + (MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set", (method, MethodObject::Registry(obj)) => { return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str())); } @@ -361,6 +366,8 @@ impl MethodName { "MaskedEmail/get" => (MethodObject::MaskedEmail, MethodFunction::Get), "MaskedEmail/set" => (MethodObject::MaskedEmail, MethodFunction::Set), + "inbuxa:DeletedAccount/get" => (MethodObject::DeletedAccount, MethodFunction::Get), + "inbuxa:DeletedAccount/set" => (MethodObject::DeletedAccount, MethodFunction::Set), ).or_else(|| { let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?; @@ -412,6 +419,7 @@ impl Display for MethodObject { MethodObject::CalendarEventNotification => "CalendarEventNotification", MethodObject::ShareNotification => "ShareNotification", MethodObject::MaskedEmail => "MaskedEmail", + MethodObject::DeletedAccount => "inbuxa:DeletedAccount", MethodObject::Registry(obj) => { f.write_str("x:")?; return f.write_str(obj.as_str()); diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 597112b..ff74e53 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -112,6 +112,7 @@ pub enum GetRequestMethod { ShareNotification(Box>), Registry(Box>), MaskedEmail(Box>), + DeletedAccount(Box>), } #[derive(Debug)] @@ -133,6 +134,7 @@ pub enum SetRequestMethod<'x> { ParticipantIdentity(Box>), Registry(Box>), MaskedEmail(Box>), + DeletedAccount(Box>), } #[derive(Debug)] diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index d905ef2..1b986cc 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -153,6 +153,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Get, MethodObject::DeletedAccount) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DeletedAccount(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), @@ -304,6 +311,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Set, MethodObject::DeletedAccount) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::DeletedAccount(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)), Err(err) => RequestMethod::invalid(err), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 7160267..9ff16ac 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -99,6 +99,7 @@ pub enum GetResponseMethod { ShareNotification(GetResponse), Registry(GetResponse), MaskedEmail(GetResponse), + DeletedAccount(GetResponse), } #[derive(Debug, serde::Serialize)] @@ -121,6 +122,7 @@ pub enum SetResponseMethod { ParticipantIdentity(Box>), Registry(Box>), MaskedEmail(Box>), + DeletedAccount(Box>), } #[derive(Debug, serde::Serialize)] @@ -280,6 +282,19 @@ impl<'x> From From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::DeletedAccount(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::DeletedAccount(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::VacationResponse(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 6577ba1..a3eb981 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -71,6 +71,8 @@ impl JmapAuthorization for AccessToken { GetRequestMethod::VacationResponse(_) => Permission::JmapVacationResponseGet, // inbuxa: Fastmail's MaskedEmail (ME-18) GetRequestMethod::MaskedEmail(_) => Permission::SysMaskedEmailGet, + // inbuxa: deleted accounts (UD-17) + GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet, GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet, GetRequestMethod::Quota(_) => Permission::JmapQuotaGet, GetRequestMethod::Blob(_) => Permission::JmapBlobGet, @@ -151,6 +153,14 @@ impl JmapAuthorization for AccessToken { Permission::SysMaskedEmailUpdate, Permission::SysMaskedEmailDestroy, ), + // inbuxa: deleted accounts; a restore creates the account again (UD-17) + SetRequestMethod::DeletedAccount(s) => validate_set( + s, + self, + Permission::SysAccountCreate, + Permission::SysAccountCreate, + Permission::SysAccountDestroy, + ), SetRequestMethod::VacationResponse(s) => validate_set( s, self, @@ -258,7 +268,8 @@ impl JmapAuthorization for AccessToken { | MethodObject::SearchSnippet | MethodObject::VacationResponse | MethodObject::SieveScript - | MethodObject::MaskedEmail => Permission::JmapEmailChanges, + | MethodObject::MaskedEmail + | MethodObject::DeletedAccount => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads MethodObject::Registry(object_type) => object_type.get_permission(), }, diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 4c0b6e6..47ae7c0 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -165,6 +165,9 @@ impl RequestHandler for Server { SetResponseMethod::MaskedEmail(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::DeletedAccount(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::AddressBook(set_response) => { set_response.update_created_ids(&mut response); } @@ -306,6 +309,13 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:DeletedAccount/get (UD-17) + GetRequestMethod::DeletedAccount(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::deleted_account::get(self, access_token, *req) + .await? + .into() + } GetRequestMethod::Principal(req) => { self.principal_get(*req, access_token).await?.into() } @@ -533,6 +543,13 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa:DeletedAccount/set (UD-17) + SetRequestMethod::DeletedAccount(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::deleted_account::set(self, access_token, *req) + .await? + .into() + } SetRequestMethod::AddressBook(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; access_token.assert_has_access(req.account_id, Collection::AddressBook)?; diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 454cb66..8dd5f51 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -414,6 +414,7 @@ impl IntermediateChangesResponse { | MethodObject::Principal | MethodObject::Quota | MethodObject::MaskedEmail + | MethodObject::DeletedAccount | MethodObject::Registry(_) => unreachable!(), }) } diff --git a/crates/jmap/src/inbuxa/deleted_account.rs b/crates/jmap/src/inbuxa/deleted_account.rs new file mode 100644 index 0000000..a635bae --- /dev/null +++ b/crates/jmap/src/inbuxa/deleted_account.rs @@ -0,0 +1,529 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Deleted accounts kept for their period (undelete spec, UD-15 to UD-17a): +//! kept at deletion, their addresses held, and listed, restored or +//! destroyed for good through `inbuxa:DeletedAccount/get` and `/set`. + +use common::{ + Server, + auth::AccessToken, + cache::invalidate::CacheInvalidationBuilder, + ipc::CacheInvalidation, +}; +use directory::core::secret::hash_secret; +use inbuxa_features::undelete::{ + accounts, + data::{self, KeptAccount}, + settings::retention, +}; +use jmap_proto::{ + error::set::{SetError, SetErrorType}, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_deleted_account::{ + DeletedAccount, DeletedAccountProperty as P, DeletedAccountValue, + }, + request::IntoValid, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Value}; +use registry::{ + pickle::PickledStream, + schema::{ + enums::{AccountType, Permission}, + prelude::{Object, ObjectInner, ObjectType, Property}, + structs::{Account, Credential, Task, TaskDestroyAccount, TaskStatus}, + }, + types::{EnumImpl, datetime::UTCDateTime, id::ObjectId}, +}; +use std::borrow::Cow; +use store::{ + registry::write::{RegistryWrite, RegistryWriteResult}, + write::{BatchBuilder, TaskQueueClass, ValueClass, now}, +}; +use trc::AddContext; +use types::id::Id; +use utils::snowflake::SnowflakeIdGenerator; + +type DValue = Value<'static, P, DeletedAccountValue>; + +const ALL: &[P] = &[ + P::Id, + P::Name, + P::Addresses, + P::MemberTenantId, + P::DeletedAt, + P::KeptUntil, +]; + +/// The addresses an object answers to, with the property that names each. +async fn addresses_of(server: &Server, inner: &ObjectInner) -> trc::Result> { + let (name, domain_id, aliases) = match inner { + ObjectInner::Account(Account::User(account)) => { + (&account.name, account.domain_id, &account.aliases) + } + ObjectInner::Account(Account::Group(account)) => { + (&account.name, account.domain_id, &account.aliases) + } + ObjectInner::MailingList(list) => (&list.name, list.domain_id, &list.aliases), + ObjectInner::MaskedEmail(mask) => { + return Ok(vec![(Property::Email, mask.email.to_lowercase())]); + } + _ => return Ok(vec![]), + }; + let mut addresses = Vec::new(); + for (property, local, domain_id) in std::iter::once((Property::Name, name, domain_id)).chain( + aliases + .values() + .map(|alias| (Property::Aliases, &alias.name, alias.domain_id)), + ) { + if let Some(domain) = server.domain_by_id(domain_id.document_id()).await? { + for domain in domain.names.iter() { + addresses.push((property, format!("{local}@{domain}").to_lowercase())); + } + } + } + Ok(addresses) +} + +/// UD-16: nothing new may take an address a kept account holds. +pub async fn reserved( + server: &Server, + old: Option<&Object>, + new: &Object, +) -> trc::Result>> { + let before = match old { + Some(old) => addresses_of(server, &old.inner).await?, + None => vec![], + }; + for (property, address) in addresses_of(server, &new.inner).await? { + if before.iter().any(|(_, a)| *a == address) { + continue; + } + if let Some(kept_id) = data::reserved_by(&server.core.storage.data, &address).await? { + return Ok(Some( + SetError::new(SetErrorType::PrimaryKeyViolation) + .with_property(property) + .with_object_id(ObjectId::new(ObjectType::Account, Id::from(kept_id))) + .with_description(format!( + "{address} is held by a deleted account until it's destroyed." + )), + )); + } + } + Ok(None) +} + +/// UD-15: keeps a destroyed account for the period, if one is set, instead +/// of upstream's immediate destruction. Returns the other accounts whose +/// access changed, or `None` when nothing is kept. +pub async fn keep(server: &Server, id: Id, account: &Account) -> trc::Result>> { + let Some(period) = retention(server.registry()).await?.accounts else { + return Ok(None); + }; + let account_id = id.document_id(); + let deleted_at = now(); + let kept_until = deleted_at + period; + let inner = ObjectInner::Account(account.clone()); + let addresses = addresses_of(server, &inner) + .await? + .into_iter() + .map(|(_, address)| address) + .collect(); + let (domain_id, name, account_type, tenant) = match account { + Account::User(a) => (a.domain_id, a.name.clone(), AccountType::User, a.member_tenant_id), + Account::Group(a) => (a.domain_id, a.name.clone(), AccountType::Group, a.member_tenant_id), + }; + + // UD-17a: its shares are suspended both ways + let data = &server.core.storage.data; + let shares = accounts::suspend_shares(data, account_id).await?; + let mut others = shares + .iter() + .flat_map(|share| [share.owner, share.grantee]) + .filter(|other| *other != account_id) + .collect::>(); + others.sort_unstable(); + others.dedup(); + + // UD-15a: upstream's DestroyAccount task, due at the end of the period + let task_id = SnowflakeIdGenerator::global_id().unwrap_or_default(); + let kept = KeptAccount { + record: inner.to_pickled_vec(), + name: name.clone(), + addresses, + member_tenant_id: tenant.map(|id| id.id()), + deleted_at, + kept_until, + task_id, + shares, + }; + let mut batch = BatchBuilder::new(); + batch.schedule_task_with_id( + task_id, + Task::DestroyAccount(TaskDestroyAccount { + account_domain_id: domain_id, + account_id: id, + account_name: name, + account_type, + status: TaskStatus::at(kept_until as i64), + }), + ); + data::set_kept_account(&mut batch, account_id, &kept)?; + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + server.notify_task_queue(); + Ok(Some(others)) +} + +/// Who may see or act on a kept account: server administrators, and tenant +/// administrators for their own tenant's (MT-1). +fn may_reach(access_token: &AccessToken, kept: &KeptAccount, permission: Permission) -> bool { + access_token.has_permission(permission) + && access_token + .tenant_id() + .is_none_or(|tenant| kept.member_tenant_id == Some(tenant as u64)) +} + +fn date(timestamp: u64) -> DValue { + Value::Element(DeletedAccountValue::Date(UTCDate::from_timestamp( + timestamp as i64, + ))) +} + +fn to_value(account_id: u32, kept: &KeptAccount, properties: &[P]) -> DValue { + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(DeletedAccountValue::Id(Id::from(account_id))), + P::Name => Value::Str(Cow::Owned(kept.name.clone())), + P::Addresses => Value::Array( + kept.addresses + .iter() + .map(|a| Value::Str(Cow::Owned(a.clone()))) + .collect(), + ), + P::MemberTenantId => match kept.member_tenant_id { + Some(id) => Value::Element(DeletedAccountValue::Id(Id::from(id))), + None => Value::Null, + }, + P::DeletedAt => date(kept.deleted_at), + P::KeptUntil => date(kept.kept_until), + P::Restore | P::Password => continue, + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// `inbuxa:DeletedAccount/get`. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + let properties = request.unwrap_properties(ALL); + let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let data = &server.core.storage.data; + match ids { + None => { + for (account_id, kept) in data::kept_accounts(data).await? { + if may_reach(access_token, &kept, Permission::SysAccountGet) { + response.list.push(to_value(account_id, &kept, &properties)); + } + } + } + Some(ids) => { + for id in ids { + match data::kept_account(data, id.document_id()).await? { + Some(kept) if may_reach(access_token, &kept, Permission::SysAccountGet) => { + response + .list + .push(to_value(id.document_id(), &kept, &properties)); + } + _ => response.push_not_found(id), + } + } + } + } + Ok(response) +} + +/// `inbuxa:DeletedAccount/set`: update `{"restore": true, "password": ...}` +/// restores; destroy destroys for good. +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, DeletedAccount>, +) -> trc::Result> { + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + let will_destroy = request.unwrap_destroy().into_valid().collect::>(); + let data = &server.core.storage.data; + + for (client_id, _) in request.unwrap_create() { + response.not_created.append( + client_id, + SetError::forbidden().with_description("Only a deleted account can be restored."), + ); + } + + for (id, value) in request.unwrap_update().into_valid() { + let kept = match data::kept_account(data, id.document_id()).await? { + Some(kept) if may_reach(access_token, &kept, Permission::SysAccountCreate) => kept, + _ => { + response.not_updated.append(id, SetError::not_found()); + continue; + } + }; + let (mut restore, mut password) = (false, None); + let mut invalid = None; + for (key, value) in value.into_expanded_object() { + match (&key, value) { + (Key::Property(P::Restore), Value::Bool(value)) => restore = value, + (Key::Property(P::Password), Value::Str(value)) => password = Some(value.into_owned()), + (Key::Property(P::Password), Value::Null) => password = None, + _ => invalid = Some(key.into_owned()), + } + } + if let Some(key) = invalid { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(key) + .with_description("Only restore and password can be set."), + ); + continue; + } + if !restore { + response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(P::Restore) + .with_description("Set restore to true to restore the account."), + ); + continue; + } + match restore_account(server, access_token, id, kept, password).await? { + Ok(()) => response.updated.append(id, None), + Err(err) => response.not_updated.append(id, err), + } + } + + for id in will_destroy { + match data::kept_account(data, id.document_id()).await? { + Some(kept) if may_reach(access_token, &kept, Permission::SysAccountDestroy) => { + destroy_now(server, id, &kept).await?; + response.destroyed.push(id); + } + _ => response.not_destroyed.append(id, SetError::not_found()), + } + } + + Ok(response) +} + +fn failed(err: SetError) -> SetError

{ + let mut out = SetError::new(err.error_type().clone()); + if let Some(description) = err.description() { + out = out.with_description(description.to_string()); + } + out +} + +/// UD-17: writes the record back with the same id and a new password, +/// cancels the pending destruction and reinstates its shares (UD-17a). +async fn restore_account( + server: &Server, + access_token: &AccessToken, + id: Id, + kept: KeptAccount, + password: Option, +) -> trc::Result>> { + let account_id = id.document_id(); + let Some(ObjectInner::Account(mut account)) = PickledStream::new(&kept.record) + .and_then(|mut stream| ObjectInner::unpickle(ObjectType::Account, &mut stream)) + else { + return Ok(Err(SetError::forbidden().with_description("The kept record can't be read."))); + }; + + // A user comes back with a new password; its other credentials stay + if let Account::User(user) = &mut account { + let Some(password) = password else { + return Ok(Err(SetError::invalid_properties() + .with_property(P::Password) + .with_description("A restored account needs a new password."))); + }; + if let Err(err) = server.is_secure_password(&password, &[]) { + return Ok(Err(SetError::invalid_properties() + .with_property(P::Password) + .with_description(err))); + } + let secret = hash_secret( + server.core.network.security.password_hash_algorithm, + password.into_bytes(), + ) + .await + .caused_by(trc::location!())?; + let expires_at = server + .core + .network + .security + .password_default_expiration + .map(|expires| UTCDateTime::from_timestamp((now() + expires) as i64)); + match user + .credentials + .values_mut() + .find_map(|credential| match credential { + Credential::Password(credential) => Some(credential), + _ => None, + }) { + Some(credential) => { + credential.secret = secret; + credential.expires_at = expires_at; + } + None => { + return Ok(Err(SetError::forbidden() + .with_description("The kept account has no password credential."))); + } + } + } + + // The restorer may only bring back what it could grant + if server.can_set_permissions(access_token, &account).await?.is_err() { + return Ok(Err(SetError::forbidden().with_description( + "You can't grant the permissions this account holds.", + ))); + } + + let object = Object { + inner: ObjectInner::Account(account), + revision: 0, + }; + if let Err(err) = + inbuxa_features::tenancy::writes::check(server.registry(), None, None, &object).await? + { + return Ok(Err(failed(err))); + } + + // Its addresses are free for it alone + let mut batch = BatchBuilder::new(); + data::clear_kept_account(&mut batch, account_id, &kept); + let data = &server.core.storage.data; + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + match server + .registry() + .write(RegistryWrite::Insert { + object: &object, + id: Some(id), + }) + .await? + { + RegistryWriteResult::Success(_) => {} + err => { + // Put the hold back + let mut batch = BatchBuilder::new(); + data::set_kept_account(&mut batch, account_id, &kept)?; + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + return Ok(Err(match err { + RegistryWriteResult::PrimaryKeyConflict { property, .. } => { + SetError::new(SetErrorType::PrimaryKeyViolation).with_description(format!( + "Another object now has this account's {}.", + property.as_str() + )) + } + RegistryWriteResult::InvalidForeignKey { object_id } => { + SetError::new(SetErrorType::InvalidForeignKey).with_description(format!( + "{} {} no longer exists.", + object_id.object().as_str(), + object_id.id() + )) + } + _ => SetError::forbidden().with_description("The account can't be restored."), + })); + } + } + + // Its destruction is off + let mut batch = BatchBuilder::new(); + batch + .clear(ValueClass::TaskQueue(TaskQueueClass::Task { id: kept.task_id })) + .clear(ValueClass::TaskQueue(TaskQueueClass::Due { + id: kept.task_id, + due: kept.kept_until, + })); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + // UD-17a: its shares come back where the other account still exists + let mut existing = Vec::new(); + for share in &kept.shares { + for other in [share.owner, share.grantee] { + if other != account_id + && !existing.contains(&other) + && server + .registry() + .object::(Id::from(other)) + .await? + .is_some() + { + existing.push(other); + } + } + } + let others = + accounts::reinstate_shares(data, account_id, &kept.shares, |id| existing.contains(&id)) + .await?; + + let mut invalidator = CacheInvalidationBuilder::default(); + invalidator.process_create(&object); + invalidator.invalidate(CacheInvalidation::AccessToken(account_id)); + for other in others { + invalidator.invalidate(CacheInvalidation::AccessToken(other)); + } + server.invalidate_caches(invalidator).await?; + Ok(Ok(())) +} + +/// Destroys a kept account for good: its `DestroyAccount` task runs now. +async fn destroy_now(server: &Server, id: Id, kept: &KeptAccount) -> trc::Result<()> { + let data = &server.core.storage.data; + let task_key = ValueClass::TaskQueue(TaskQueueClass::Task { id: kept.task_id }); + let mut batch = BatchBuilder::new(); + if let Some(mut task) = data + .get_value::(store::ValueKey::from(task_key)) + .await? + { + task.set_status(TaskStatus::now()); + batch + .clear(ValueClass::TaskQueue(TaskQueueClass::Due { + id: kept.task_id, + due: kept.kept_until, + })) + .schedule_task_with_id(kept.task_id, task); + } + data::clear_kept_account(&mut batch, id.document_id(), kept); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + server.notify_task_queue(); + Ok(()) +} diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index f32906e..576cef8 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -8,6 +8,7 @@ //! `crates/features`; this module only speaks JMAP for them. pub mod access; +pub mod deleted_account; pub mod fastmail; pub mod masked_email; pub mod undelete; diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index c7a8185..fea2a61 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -572,6 +572,14 @@ impl RegistrySet for Server { } }; + // inbuxa: UD-16: a kept account's addresses stay its own + if let Some(err) = + crate::inbuxa::deleted_account::reserved(self, stored, &new_object).await? + { + set.failed(modification, err); + continue 'outer; + } + // Validate expressions if let Some(expressions) = new_object.inner.expression_ctxs() { let mut bp = Bootstrap::new_uninitialized(self.registry().clone()); @@ -750,7 +758,17 @@ impl RegistrySet for Server { .await? { RegistryWriteResult::Success(_) => { - if let ObjectInner::Account(account) = &object.inner { + // inbuxa: UD-15, UD-17a: kept for its period, shares suspended + if let ObjectInner::Account(account) = &object.inner + && let Some(others) = + crate::inbuxa::deleted_account::keep(self, id, account) + .await? + { + for other in others { + cache_invalidator + .invalidate(CacheInvalidation::AccessToken(other)); + } + } else if let ObjectInner::Account(account) = &object.inner { for sharee_id in self .store() .acl_revoke_all(id.document_id()) @@ -837,6 +855,7 @@ impl RegistrySet for Server { Ok(set.into_response()) } #[cfg(not(feature = "enterprise"))] + #[allow(unreachable_patterns)] // inbuxa: ArchivedItem was the last one _ => { set.fail_all_create("Enterprise objects cannot be created"); set.fail_all_update("Enterprise objects cannot be modified"); diff --git a/crates/services/src/task_manager/destroy_account.rs b/crates/services/src/task_manager/destroy_account.rs index 4a33f32..f9d4c67 100644 --- a/crates/services/src/task_manager/destroy_account.rs +++ b/crates/services/src/task_manager/destroy_account.rs @@ -91,6 +91,14 @@ async fn destroy_account(server: &Server, task: &TaskDestroyAccount) -> trc::Res } } + // inbuxa: UD-15: the account's hold and undelete's own records go first + inbuxa_features::undelete::accounts::forget( + &server.core.storage.data, + server.registry(), + account_id, + ) + .await?; + // Remove archived items let mut batch = BatchBuilder::new(); let ids = server diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index 3b46d51..3012a43 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -277,7 +277,7 @@ is written. |---|---|---|---| | 1 | Multi-tenancy | Tenants with their own domains, admins, quotas and queue visibility | Needed for anybody hosting mail for others. ihasmail already has a Tenants screen. Built 2026-09-18 in `crates/features`; status in `features/multi-tenancy.md`. | | 2 | Masked email | Per-sender disposable addresses that deliver to the account | Existing addresses must keep delivering (§3.4). Built 2026-09-18 in `crates/features`; status in `features/masked-email.md`. | -| 3 | Undelete | Deleted mail held for a set period and restorable | Existing archived items must stay restorable. Spec: `features/undelete.md`. | +| 3 | Undelete | Deleted mail held for a set period and restorable | Existing archived items must stay restorable. Built 2026-09-18 in `crates/features`; status in `features/undelete.md`. | | 4 | Branding and templates | Operator logo, and the text of calendar alarm and invitation emails | INBUXA's branding is the default. Spec: `features/branding-and-templates.md`. | | 5 | AI spam classification | An optional model's opinion as one spam signal, and a Sieve function that asks a model | Local and auditable model only: no hosted API by default. Spec: `features/ai-spam-classification.md`. | | 6 | Monitoring history, live tracing, alerts | Stored metrics and traces, a live trace view, and threshold alerts | ihasmail's dashboard shows them. Spec: `features/monitoring.md`. | diff --git a/docs/spec/features/undelete.md b/docs/spec/features/undelete.md index 899285f..c5e7125 100644 --- a/docs/spec/features/undelete.md +++ b/docs/spec/features/undelete.md @@ -265,6 +265,34 @@ Stalwart-facing (SPEC.md §5). 16. **(compat)** Archived items already held at INBUXA read back unchanged through `x:ArchivedItem` after cutover, and restore. +## Implementation status + +Built 2026-09-18 from this spec, clean-room, under the multi-tenancy hand-off +brief's rules. The rules live in `crates/features` (`inbuxa-features`, module +`undelete`); the JMAP glue in `crates/jmap/src/inbuxa/`, with the deleted +account object type in `crates/jmap-proto/src/object/inbuxa_deleted_account.rs`; +restoring in `crates/services/src/task_manager/`; upstream files carry hooks +marked `inbuxa:`. Acceptance tests 1 to 15 pass as +`tests/src/system/undelete.rs`, with `/changes` and the `/query` filters. + +- **UD-1 to UD-17a:** built. +- **ihasmail changes** belong to ihasmail-inbuxa and aren't part of this + repository. +- **Test 16 (compat)** is written as `undelete_compat`, ignored, and unrun + until a copy of INBUXA's data with archived items made on it is provided. + Its doc comment says how to run it. +- **Known limits, not requirements of this spec:** + - A kept account holds the addresses it had under its domain's names at + deletion. A domain renamed while it's kept doesn't move the hold. + - The hold is checked when accounts, aliases, mailing lists and masks are + created or changed through `x:`. A mask made through Fastmail's API + isn't checked; its random address makes a clash unlikely. + - A restored user keeps its other credentials (app passwords, API keys, + its one-time-password setup) as they were; only the password is new. + - Restoring needs `sysAccountCreate` and the right to grant everything the + account holds, so a tenant administrator can't bring back an account + with more than it could create. + ## Observed Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart diff --git a/tests/src/system/undelete.rs b/tests/src/system/undelete.rs index fc6568b..7dfc7cd 100644 --- a/tests/src/system/undelete.rs +++ b/tests/src/system/undelete.rs @@ -11,25 +11,31 @@ use crate::utils::{ account::Account, webdav::DummyWebDavClient, imap::{ImapConnection, Type}, - jmap::JmapUtils, + jmap::{JmapResponse, JmapUtils}, pop3::{self, Pop3Connection}, server::{TestServer, TestServerBuilder}, + smtp::SmtpConnection, }; use email::{ mailbox::{INBOX_ID, TRASH_ID}, message::delete::EmailDeletion, }; use imap_proto::ResponseType; -use jmap_client::client::Client; +use jmap_client::client::{Client, Credentials}; use registry::{ schema::{ prelude::{ObjectType, Property}, - structs::{DataRetention, Expression, Imap, MtaStageAuth}, + structs::{DataRetention, Expression, Imap, MtaStageAuth, MtaStageRcpt, Task}, }, types::duration::Duration, }; use hyper::StatusCode; use serde_json::{Value, json}; +use store::{ + Deserialize, IterateParams, ValueKey, + query::acl::AclQuery, + write::{TaskQueueClass, ValueClass}, +}; use types::id::Id; const SECRET: &str = "undelete test user passphrase"; @@ -398,6 +404,138 @@ pub async fn test(test: &mut TestServer) { "test 12" ); + // Acceptance test 14: a deleted account is kept: it can't sign in or + // receive mail, its name stays held, and an admin restores it whole, + // shares both ways included (UD-15 to UD-17a) + admin + .registry_update_setting( + MtaStageRcpt { + wait_on_fail: Expression { + else_: "1ms".into(), + ..Default::default() + }, + ..Default::default() + }, + &[Property::WaitOnFail], + ) + .await; + admin.reload_settings().await; + admin.set_account_retention(Some(30 * DAY)).await; + let gone = admin + .create_user_account("gone@example.org", SECRET, "Gone", &[], vec![]) + .await; + let gone_id = gone.id(); + import(&gone.jmap_client().await, "Kept with the account", &[INBOX_ID], &[]).await; + share_inbox(&gone, user.id()).await; + share_inbox(&user, gone_id).await; + assert!(has_access(test, user.id(), gone_id).await, "test 14: shared"); + assert!(has_access(test, gone_id, user.id()).await, "test 14: shared"); + + admin + .registry_destroy(ObjectType::Account, [gone_id]) + .await + .assert_destroyed(&[gone_id]); + assert!( + matches!( + Client::new() + .credentials(Credentials::basic("gone@example.org", SECRET)) + .accept_invalid_certs(true) + .follow_redirects(["127.0.0.1"]) + .connect(&gone.base_url()) + .await, + Err(jmap_client::Error::Problem(err)) if err.status() == Some(401) + ), + "test 14: can't sign in" + ); + let mut lmtp = SmtpConnection::connect().await; + lmtp.mail_from("sender@remote.example.org", 2).await; + let reply = lmtp.rcpt_to("gone@example.org", 5).await; + assert!( + reply.iter().any(|line| line.starts_with("550 5.1.2")), + "test 14: mail refused, {reply:?}" + ); + assert!(!has_access(test, user.id(), gone_id).await, "test 14: suspended"); + assert!(!has_access(test, gone_id, user.id()).await, "test 14: suspended"); + assert!(pending_destroy(test, gone_id).await, "test 14: destruction due"); + + // Its name is held (UD-16) + let domain_id = admin + .jmap_method_call( + "x:Account/get", + json!({"ids": [user.id_string()], "properties": ["domainId"]}), + ) + .await + .list()[0]["domainId"] + .clone(); + let response = admin + .jmap_method_call( + "x:Account/set", + json!({"create": {"i0": {"@type": "User", "name": "gone", "domainId": domain_id}}}), + ) + .await; + assert_eq!( + response.not_created(0)["type"], + "primaryKeyViolation", + "test 14: name held" + ); + + // Listed for admins only (UD-17) + let listed = admin.deleted_accounts().await; + assert_eq!(listed.len(), 1, "test 14: listed"); + assert_eq!(listed[0]["id"], gone_id.to_string()); + assert_eq!(listed[0]["name"], "gone"); + assert_eq!(listed[0]["addresses"], json!(["gone@example.org"])); + assert_eq!( + seconds(&listed[0]["keptUntil"]) - seconds(&listed[0]["deletedAt"]), + (30 * DAY) as i64 + ); + let response = user + .jmap_request( + &["urn:ietf:params:jmap:core", "urn:inbuxa:jmap"], + json!([["inbuxa:DeletedAccount/get", {"accountId": user.id_string(), "ids": null}, "0"]]), + ) + .await; + assert_eq!( + response.0.pointer("/methodResponses/0/0"), + Some(&json!("error")), + "test 14: not for users" + ); + + // Restored with the same id, its mail and its shares + let response = admin + .deleted_account_set(json!({"update": {gone_id.to_string(): {"restore": true, "password": SECRET}}})) + .await; + response.updated_id(gone_id); + assert_eq!(gone.count_with_subject("Kept with the account").await, 1, "test 14: data intact"); + assert!(has_access(test, user.id(), gone_id).await, "test 14: reinstated"); + assert!(has_access(test, gone_id, user.id()).await, "test 14: reinstated"); + assert!(!pending_destroy(test, gone_id).await, "test 14: destruction off"); + assert!(admin.deleted_accounts().await.is_empty()); + test.wait_for_tasks().await; + + // Destroyed for good: the task runs now and the name is free + admin + .registry_destroy(ObjectType::Account, [gone_id]) + .await + .assert_destroyed(&[gone_id]); + admin + .deleted_account_set(json!({"destroy": [gone_id.to_string()]})) + .await + .assert_destroyed(&[gone_id]); + test.wait_for_tasks().await; + assert!(admin.deleted_accounts().await.is_empty(), "test 14: gone"); + assert!(!pending_destroy(test, gone_id).await); + admin.set_account_retention(None).await; + let again = admin + .create_user_account("gone@example.org", SECRET, "Gone", &[], vec![]) + .await; + assert_eq!(again.count_with_subject("Kept with the account").await, 0); + admin.destroy_account(again).await; + admin + .registry_update_setting(MtaStageRcpt::default(), &[Property::WaitOnFail]) + .await; + admin.reload_settings().await; + // Clean up admin.set_retention(None).await; for item in user.archived().await { @@ -441,6 +579,92 @@ pub async fn undelete_tests() { } } +/// Acceptance test 16 (compat): archived items written before the cutover +/// read back unchanged through `x:ArchivedItem`, and restore. +/// +/// INBUXA held no archived items (spec, observed 1), so the items to check +/// are made on a copy of its data, through the Enterprise server, before the +/// copy is opened here: +/// +/// - `INBUXA_COMPAT_ADMIN`: `name:password` of a server-level administrator +/// in that data; +/// - `INBUXA_COMPAT_ARCHIVED`: a JSON file of `x:ArchivedItem/get` results +/// recorded against the Enterprise server, each with its `id` and +/// `accountId`; +/// +/// and the data itself in place of the test store: run with `NO_INSERT=1` +/// and the store's `TMPDIR`/`STORE` pointing at the copy, so it isn't reset. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn undelete_compat() { + let admin = std::env::var("INBUXA_COMPAT_ADMIN").expect("INBUXA_COMPAT_ADMIN"); + let items: Vec = serde_json::from_slice( + &std::fs::read(std::env::var("INBUXA_COMPAT_ARCHIVED").expect("INBUXA_COMPAT_ARCHIVED")) + .expect("archived items file"), + ) + .expect("archived items JSON"); + assert!( + std::env::var("NO_INSERT").is_ok(), + "NO_INSERT must be set, or the copy of INBUXA's data is wiped" + ); + + let test = TestServerBuilder::new("undelete_compat") + .await + .with_default_listeners() + .await + .build_with_opts(false) + .await; + let (name, secret) = admin.split_once(':').expect("name:password"); + let admin = Account::new( + Box::leak(name.to_string().into_boxed_str()), + Box::leak(secret.to_string().into_boxed_str()), + &[], + "Compat admin", + Id::from(u32::MAX), + ); + + for recorded in &items { + let id = recorded["id"].as_str().unwrap(); + let account = recorded["accountId"].as_str().unwrap(); + + // Reads back unchanged: every recorded property, same value + let response = admin + .jmap_method_call( + "x:ArchivedItem/get", + json!({"accountId": account, "ids": [id]}), + ) + .await; + let stored = &response.method_response()["list"][0]; + for (property, value) in recorded.as_object().unwrap() { + assert_eq!(&stored[property], value, "{id} {property}: {response:?}"); + } + + // Restores: the record goes once the item is back (UD-9) + admin + .jmap_method_call( + "x:ArchivedItem/set", + json!({"accountId": account, "update": {id: {"status": "requestRestore"}}}), + ) + .await + .updated(id); + } + test.wait_for_tasks_skip_failures().await; + for recorded in &items { + let id = recorded["id"].as_str().unwrap(); + let response = admin + .jmap_method_call( + "x:ArchivedItem/get", + json!({"accountId": recorded["accountId"], "ids": [id]}), + ) + .await; + assert_eq!( + response.method_response()["notFound"], + json!([id]), + "{id} not restored" + ); + } +} + fn seconds(value: &Value) -> i64 { value .as_str() @@ -469,7 +693,92 @@ async fn import(client: &Client, subject: &str, mailboxes: &[u32], keywords: &[& .take_id() } +/// Shares `owner`'s Inbox with `grantee`, read-only. +async fn share_inbox(owner: &Account, grantee: Id) { + owner + .jmap_method_call( + "Mailbox/set", + json!({ + "accountId": owner.id_string(), + "update": { + Id::from(INBOX_ID).to_string(): { + "shareWith": { grantee.to_string(): { "mayReadItems": true } } + } + } + }), + ) + .await + .updated_id(Id::from(INBOX_ID)); +} + +/// Whether `grantee` may reach anything of `owner`'s. +async fn has_access(test: &TestServer, grantee: Id, owner: Id) -> bool { + test.server + .store() + .acl_query(AclQuery::HasAccess { + grant_account_id: grantee.document_id(), + }) + .await + .unwrap() + .iter() + .any(|item| item.to_account_id == owner.document_id()) +} + +/// Whether a `DestroyAccount` task for the account is queued. +async fn pending_destroy(test: &TestServer, account: Id) -> bool { + let mut found = false; + test.server + .store() + .iterate( + IterateParams::new( + ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Task { id: 0 })), + ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Task { id: u64::MAX })), + ) + .ascending(), + |_, value| { + if let Task::DestroyAccount(task) = Task::deserialize(value)? { + found |= task.account_id == account; + } + Ok(!found) + }, + ) + .await + .unwrap(); + found +} + impl Account { + async fn set_account_retention(&self, keep_for: Option) { + self.registry_update_setting( + DataRetention { + archive_deleted_accounts_for: keep_for + .map(|secs| Duration::from_millis(secs * 1000)), + ..Default::default() + }, + &[Property::ArchiveDeletedAccountsFor], + ) + .await; + } + + async fn deleted_accounts(&self) -> Vec { + self.jmap_request( + &["urn:ietf:params:jmap:core", "urn:inbuxa:jmap"], + json!([["inbuxa:DeletedAccount/get", {"accountId": self.id_string(), "ids": null}, "0"]]), + ) + .await + .list() + .to_vec() + } + + async fn deleted_account_set(&self, mut args: Value) -> JmapResponse { + args["accountId"] = json!(self.id_string()); + self.jmap_request( + &["urn:ietf:params:jmap:core", "urn:inbuxa:jmap"], + json!([["inbuxa:DeletedAccount/set", args, "0"]]), + ) + .await + } + async fn set_retention(&self, keep_for: Option) { self.registry_update_setting( DataRetention {