Undelete: deleted accounts are kept for their period, hold their addresses, and are restored or destroyed through inbuxa:DeletedAccount (UD-15 to UD-17a)
With archiveDeletedAccountsFor set, a destroyed account's record is kept in the fork subspace with its id, its DestroyAccount task is due at the end of the period, and its shares are suspended both ways. Its addresses can't be taken by new accounts, aliases, lists or masks. inbuxa:DeletedAccount/get lists kept accounts to server and tenant administrators; /set restores one with a new password (same id, task cancelled, shares reinstated) or destroys it now. The destroy task also clears undelete's own records. Acceptance test 14; test 16 written as the ignored undelete_compat.
This commit is contained in:
@@ -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<Vec<Share>> {
|
||||
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<Item = &'x Share>,
|
||||
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<Vec<Share>> {
|
||||
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<Vec<u32>> {
|
||||
let shares = shares
|
||||
.iter()
|
||||
.filter(|share| {
|
||||
let other = if share.owner == account_id {
|
||||
share.grantee
|
||||
} else {
|
||||
share.owner
|
||||
};
|
||||
other == account_id || exists(other)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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
|
||||
}
|
||||
@@ -163,6 +163,22 @@ pub struct KeptAccount {
|
||||
pub member_tenant_id: Option<u64>,
|
||||
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<Share>,
|
||||
}
|
||||
|
||||
/// 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<Vec<(u32, KeptAccount)>>
|
||||
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<Option<u32>> {
|
||||
data.get_value::<u32>(key(KIND_RESERVED, address.to_lowercase().as_bytes()))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Self> {
|
||||
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<Self> {
|
||||
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<Self, Self::Err> {
|
||||
DeletedAccountProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for DeletedAccountValue {
|
||||
type Property = DeletedAccountProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
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<Id> for DeletedAccountValue {
|
||||
fn from(id: Id) -> Self {
|
||||
DeletedAccountValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for DeletedAccountValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
DeletedAccountValue::Id(id) => Some(*id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
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<Id> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_id_ref(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)?
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -112,6 +112,7 @@ pub enum GetRequestMethod {
|
||||
ShareNotification(Box<GetRequest<ShareNotification>>),
|
||||
Registry(Box<GetRequest<Registry>>),
|
||||
MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -133,6 +134,7 @@ pub enum SetRequestMethod<'x> {
|
||||
ParticipantIdentity(Box<SetRequest<'x, ParticipantIdentity>>),
|
||||
Registry(Box<SetRequest<'x, Registry>>),
|
||||
MaskedEmail(Box<SetRequest<'x, crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -99,6 +99,7 @@ pub enum GetResponseMethod {
|
||||
ShareNotification(GetResponse<ShareNotification>),
|
||||
Registry(GetResponse<Registry>),
|
||||
MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>),
|
||||
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -121,6 +122,7 @@ pub enum SetResponseMethod {
|
||||
ParticipantIdentity(Box<SetResponse<ParticipantIdentity>>),
|
||||
Registry(Box<SetResponse<Registry>>),
|
||||
MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -280,6 +282,19 @@ impl<'x> From<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEm
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: deleted accounts (UD-17)
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::DeletedAccount(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::DeletedAccount(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<VacationResponse>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<VacationResponse>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::VacationResponse(value))
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -414,6 +414,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::Principal
|
||||
| MethodObject::Quota
|
||||
| MethodObject::MaskedEmail
|
||||
| MethodObject::DeletedAccount
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Vec<(Property, String)>> {
|
||||
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<Option<SetError<Property>>> {
|
||||
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<Option<Vec<u32>>> {
|
||||
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::<Vec<_>>();
|
||||
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<DeletedAccount>,
|
||||
) -> trc::Result<GetResponse<DeletedAccount>> {
|
||||
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<SetResponse<DeletedAccount>> {
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
let will_destroy = request.unwrap_destroy().into_valid().collect::<Vec<_>>();
|
||||
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<Property>) -> SetError<P> {
|
||||
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<String>,
|
||||
) -> trc::Result<Result<(), SetError<P>>> {
|
||||
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::<Account>(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::<Task>(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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("[email protected]", 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("[email protected]", 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("[email protected]", 2).await;
|
||||
let reply = lmtp.rcpt_to("[email protected]", 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!(["[email protected]"]));
|
||||
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("[email protected]", 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<Value> = 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<u64>) {
|
||||
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<Value> {
|
||||
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<u64>) {
|
||||
self.registry_update_setting(
|
||||
DataRetention {
|
||||
|
||||
Reference in New Issue
Block a user