Undelete: deleted email is kept, restored where it was, and managed over x:ArchivedItem (UD-1 to UD-14 for email)

Every way of deleting mail for good (JMAP, IMAP expunge, POP3, Trash
emptying, mailbox removal) notes the message's mailboxes and keywords while
archiving is on, fixing its deadline then; when its data is finally removed
it becomes an x:ArchivedItem record, written as upstream writes them, with
its copy held until the deadline. Retention is read at deletion time, so a
change applies at once. Restore puts a message back in the mailboxes it was
in (Trash only if that was all), with its keywords, and removes the record;
over quota it stays archived. x:ArchivedItem/get returns status and
accountId; query filters on type, archivedAt and text; set requests a
restore once or destroys; /changes is a fork addition. Expired items go in
the data purge. The shared account-access rule moves to jmap::inbuxa::access.
system_tests now calls undelete::test, and the archiving gate is gone.
This commit is contained in:
2026-09-18 20:05:56 -07:00
parent a1ce14b76b
commit 4a631bd0b5
27 changed files with 1965 additions and 52 deletions
+13 -3
View File
@@ -609,8 +609,12 @@ impl RequestHandler for Server {
RequestMethod::Changes(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
// inbuxa: x:MaskedEmail/changes
if matches!(method_name.obj, MethodObject::Registry(_)) {
// inbuxa: x:MaskedEmail/changes and x:ArchivedItem/changes
if method_name.obj
== MethodObject::Registry(registry::schema::prelude::ObjectType::ArchivedItem)
{
crate::inbuxa::undelete::changes(self, access_token, *req).await?
} else if matches!(method_name.obj, MethodObject::Registry(_)) {
crate::inbuxa::masked_email::changes(self, access_token, *req).await?
} else {
self.changes(*req, method_name.obj, access_token)
@@ -750,7 +754,13 @@ async fn assert_registry_account(
access_token: &AccessToken,
account_id: Id,
) -> trc::Result<()> {
if obj == MethodObject::Registry(registry::schema::prelude::ObjectType::MaskedEmail) {
if matches!(
obj,
MethodObject::Registry(
registry::schema::prelude::ObjectType::MaskedEmail
| registry::schema::prelude::ObjectType::ArchivedItem
)
) {
crate::inbuxa::masked_email::assert_can_manage(
server,
access_token,
+40
View File
@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Reaching another account's fork-managed objects.
use common::{Server, auth::AccessToken};
use registry::schema::enums::Permission;
use types::id::Id;
/// Who may manage an account's masks (ME-18, ME-19) or archive (UD-7). The account itself (or
/// a group it's in); at server level, a holder of `impersonate`; inside a
/// tenant, a holder of `sysAccountUpdate`, for accounts in its own tenant
/// only, `impersonate` or not.
pub async fn assert_can_manage(
server: &Server,
access_token: &AccessToken,
account_id: u32,
) -> trc::Result<()> {
if access_token.is_account_id(account_id) {
return Ok(());
}
let allowed = if let Some(tenant_id) = access_token.tenant_id() {
let target = server.account(account_id).await?;
target.id_tenant == Some(tenant_id)
&& (access_token.has_permission(Permission::SysAccountUpdate)
|| access_token.is_member(account_id))
} else {
access_token.is_member(account_id)
};
if allowed {
Ok(())
} else {
Err(trc::JmapEvent::Forbidden
.into_err()
.details(format!("You can't manage account {}", Id::from(account_id))))
}
}
+1 -29
View File
@@ -49,35 +49,7 @@ pub enum CreateRefusal {
RateLimited,
}
/// ME-18, ME-19: who may manage an account's masks. The account itself (or
/// a group it's in); at server level, a holder of `impersonate`; inside a
/// tenant, a holder of `sysAccountUpdate`, for accounts in its own tenant
/// only, `impersonate` or not.
pub async fn assert_can_manage(
server: &Server,
access_token: &AccessToken,
account_id: u32,
) -> trc::Result<()> {
if access_token.is_account_id(account_id) {
return Ok(());
}
let allowed = if let Some(tenant_id) = access_token.tenant_id() {
let target = server.account(account_id).await?;
target.id_tenant == Some(tenant_id)
&& (access_token.has_permission(Permission::SysAccountUpdate)
|| access_token.is_member(account_id))
} else {
access_token.is_member(account_id)
};
if allowed {
Ok(())
} else {
Err(trc::JmapEvent::Forbidden.into_err().details(format!(
"You can't manage masked addresses of account {}",
Id::from(account_id)
)))
}
}
pub use crate::inbuxa::access::assert_can_manage;
/// The domains an account may have masks on, as (id, name): every domain
/// and alias domain it's linked to, its primary domain first (ME-12).
+2
View File
@@ -7,5 +7,7 @@
//! JMAP glue for INBUXA's rebuilt features. The features' rules live in
//! `crates/features`; this module only speaks JMAP for them.
pub mod access;
pub mod fastmail;
pub mod masked_email;
pub mod undelete;
+376
View File
@@ -0,0 +1,376 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Undelete over JMAP: `x:ArchivedItem` (`docs/spec/features/undelete.md`).
//! The rules themselves are in `inbuxa_features::undelete`.
use crate::{
api::query::QueryResponseBuilder,
inbuxa::access::assert_can_manage,
registry::{
mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse},
query::RegistryQueryFilters,
},
};
use common::{Server, auth::AccessToken};
use inbuxa_features::{masked_email::ops::collapse, undelete};
use jmap_proto::{error::set::SetError, types::state::State as JmapState};
use jmap_tools::Key;
use registry::{
jmap::{IntoValue, JmapValue},
schema::{
enums::ArchivedItemType,
prelude::Property,
structs::{ArchivedItem, Task, TaskRestoreArchivedItem, TaskStatus},
},
types::datetime::UTCDateTime,
};
use std::str::FromStr;
use store::{registry::RegistryFilterOp, write::BatchBuilder};
use types::id::Id;
fn kind(item: &ArchivedItem) -> (ArchivedItemType, &'static str) {
match item {
ArchivedItem::Email(_) => (ArchivedItemType::Email, "Email"),
ArchivedItem::FileNode(_) => (ArchivedItemType::FileNode, "FileNode"),
ArchivedItem::CalendarEvent(_) => (ArchivedItemType::CalendarEvent, "CalendarEvent"),
ArchivedItem::ContactCard(_) => (ArchivedItemType::ContactCard, "ContactCard"),
ArchivedItem::SieveScript(_) => (ArchivedItemType::SieveScript, "SieveScript"),
}
}
/// The item's original date: when a message was received, or when anything
/// else was created. Restore gives it back.
fn original_date(item: &ArchivedItem) -> UTCDateTime {
match item {
ArchivedItem::Email(item) => item.received_at,
ArchivedItem::FileNode(item) => item.created_at,
ArchivedItem::CalendarEvent(item) => item.created_at,
ArchivedItem::ContactCard(item) => item.created_at,
ArchivedItem::SieveScript(item) => item.created_at,
}
}
/// When the item was archived.
fn archived_at(item: &ArchivedItem) -> i64 {
match item {
ArchivedItem::Email(item) => item.archived_at,
ArchivedItem::FileNode(item) => item.archived_at,
ArchivedItem::CalendarEvent(item) => item.archived_at,
ArchivedItem::ContactCard(item) => item.archived_at,
ArchivedItem::SieveScript(item) => item.archived_at,
}
.timestamp()
}
/// Text a search matches: the summary fields (a fork addition).
fn summary_text(item: &ArchivedItem) -> String {
match item {
ArchivedItem::Email(item) => format!("{} {}", item.from, item.subject),
ArchivedItem::FileNode(item) => item.name.clone(),
ArchivedItem::CalendarEvent(item) => item.title.clone(),
ArchivedItem::ContactCard(item) => item.name.clone().unwrap_or_default(),
ArchivedItem::SieveScript(item) => item.name.clone(),
}
.to_lowercase()
}
/// The archive's state, for `/get` and `/changes`.
pub async fn state(server: &Server, account_id: u32) -> trc::Result<JmapState> {
let latest = undelete::data::latest_change(&server.core.storage.data, account_id).await?;
Ok(if latest == 0 {
JmapState::Initial
} else {
JmapState::Exact(latest)
})
}
/// The item as `/get` shows it: every property, `status` and `accountId`
/// included (upstream omits them).
async fn to_value(server: &Server, id: Id, item: ArchivedItem) -> trc::Result<JmapValue<'static>> {
let requested = undelete::data::is_restore_requested(&server.core.storage.data, id).await?;
let mut value = item.into_value();
if let JmapValue::Object(object) = &mut value {
object.insert_unchecked(Key::Property(Property::Id), JmapValue::Element(id.into()));
object.insert_unchecked(
Key::Property(Property::Status),
JmapValue::Str(
if requested {
"requestRestore"
} else {
"archived"
}
.into(),
),
);
}
Ok(value)
}
/// `x:ArchivedItem/get` (UD-7). Items past their deadline aren't returned
/// (UD-13).
pub(crate) async fn get(mut get: RegistryGetResponse<'_>) -> trc::Result<RegistryGetResponse<'_>> {
let account_id = get.account_id;
assert_can_manage(get.server, get.access_token, account_id).await?;
let data = &get.server.core.storage.data;
let registry = get.server.registry();
match get.ids.take() {
None => {
for (id, item) in undelete::records::of_account(data, registry, account_id).await? {
let value = to_value(get.server, id, item).await?;
get.response.list.push(value);
}
}
Some(ids) => {
for id in ids {
match undelete::records::get(data, registry, account_id, id).await? {
Some(item) => {
let value = to_value(get.server, id, item).await?;
get.response.list.push(value);
}
None => get.not_found(id),
}
}
}
}
get.response.state = Some(state(get.server, account_id).await?);
Ok(get)
}
/// `x:ArchivedItem/query`, filtering on `@type`, `archivedAt` and text over
/// the summary fields (a fork addition). Newest first.
pub(crate) async fn query(mut req: RegistryQueryResponse<'_>) -> trc::Result<QueryResponseBuilder> {
let account_id = req.request.account_id.document_id();
assert_can_manage(req.server, req.access_token, account_id).await?;
let mut typ = None;
let mut after = None;
let mut before = None;
let mut text = None;
req.request
.extract_filters(|property, op, value| match (property, op, value) {
(Property::Type, RegistryFilterOp::Equal, serde_json::Value::String(v)) => {
typ = Some(v);
true
}
(Property::ArchivedAt, op, serde_json::Value::String(v)) => {
let Ok(at) = UTCDateTime::from_str(&v) else {
return false;
};
match op {
RegistryFilterOp::GreaterThan | RegistryFilterOp::GreaterEqualThan => {
after = Some(at.timestamp());
true
}
RegistryFilterOp::LowerThan | RegistryFilterOp::LowerEqualThan => {
before = Some(at.timestamp());
true
}
_ => false,
}
}
(Property::Text, _, serde_json::Value::String(v)) => {
text = Some(v.to_lowercase());
true
}
(Property::AccountId, _, _) => true,
_ => false,
})?;
req.request
.extract_parameters(req.server.core.jmap.query_max_results, Some(Property::Id))?;
let mut items = undelete::records::of_account(
&req.server.core.storage.data,
req.server.registry(),
account_id,
)
.await?
.into_iter()
.filter(|(_, item)| {
let archived_at = archived_at(item);
typ.as_deref().is_none_or(|t| kind(item).1 == t)
&& after.is_none_or(|at| archived_at >= at)
&& before.is_none_or(|at| archived_at <= at)
&& text
.as_deref()
.is_none_or(|t| summary_text(item).contains(t))
})
.collect::<Vec<_>>();
items.sort_by(|(a_id, a), (b_id, b)| archived_at(b).cmp(&archived_at(a)).then(b_id.cmp(a_id)));
let mut response = QueryResponseBuilder::new(
items.len(),
req.server.core.jmap.query_max_results,
JmapState::Initial,
&req.request,
);
for (id, _) in items {
if !response.add_id(id) {
break;
}
}
Ok(response)
}
/// Asks for an item's restore: the server schedules the restore task, once
/// (UD-8, UD-11).
async fn request_restore(
server: &Server,
account_id: u32,
id: Id,
item: &ArchivedItem,
) -> trc::Result<()> {
let data = &server.core.storage.data;
if undelete::data::is_restore_requested(data, id).await? {
return Ok(());
}
let mut batch = BatchBuilder::new();
batch.schedule_task(Task::RestoreArchivedItem(TaskRestoreArchivedItem {
account_id: Id::from(account_id),
archived_item_type: kind(item).0,
blob_id: item.blob_id().clone(),
created_at: original_date(item),
archived_until: item.archived_until(),
status: TaskStatus::now(),
}));
undelete::data::set_restore_requested(&mut batch, id);
undelete::data::log_change(
&mut batch,
account_id,
server.registry().assign_id(),
id,
undelete::data::Change::Updated,
);
server.store().write(batch.build_all()).await?;
server.notify_task_queue();
Ok(())
}
/// `x:ArchivedItem/set`: `status: requestRestore` restores (UD-8), destroy
/// removes for good (UD-12). Items are never created over the API.
pub(crate) async fn set(mut set: RegistrySetResponse<'_>) -> trc::Result<RegistrySetResponse<'_>> {
let account_id = set.account_id;
assert_can_manage(set.server, set.access_token, account_id).await?;
let data = &set.server.core.storage.data;
let registry = set.server.registry();
set.fail_all_create("Archived items are created by deleting things, not directly.");
for (id, value) in std::mem::take(&mut set.update) {
let Some(item) = undelete::records::get(data, registry, account_id, id).await? else {
set.response.not_updated.append(id, SetError::not_found());
continue;
};
let mut restore = false;
let mut invalid = None;
for (key, value) in value.into_expanded_object() {
match (key, value.as_str().as_deref()) {
(Key::Property(Property::Status), Some("requestRestore")) => restore = true,
(Key::Property(Property::Status), Some("archived")) => {}
(Key::Property(property), _) => {
invalid = Some(property);
break;
}
_ => {
invalid = Some(Property::Status);
break;
}
}
}
if let Some(property) = invalid {
set.response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(property)
.with_description("Only status can be set, to requestRestore."),
);
continue;
}
if restore {
request_restore(set.server, account_id, id, &item).await?;
}
set.response.updated.append(id, None);
}
for id in std::mem::take(&mut set.destroy) {
match undelete::records::get(data, registry, account_id, id).await? {
Some(item) => {
undelete::records::remove(data, registry, id, &item).await?;
set.response.destroyed.push(id);
}
None => set.response.not_destroyed.append(id, SetError::not_found()),
}
}
Ok(set)
}
/// `x:ArchivedItem/changes` (a fork addition).
pub async fn changes(
server: &Server,
access_token: &AccessToken,
request: jmap_proto::method::changes::ChangesRequest,
) -> trc::Result<jmap_proto::response::ResponseMethod<'static>> {
use jmap_proto::{
method::changes::ChangesResponse,
response::{ChangesResponseMethod, ResponseMethod},
};
let account_id = request.account_id.document_id();
assert_can_manage(server, access_token, account_id).await?;
let since = match &request.since_state {
JmapState::Initial => 0,
JmapState::Exact(change_id) => *change_id,
JmapState::Intermediate(_) => {
return Err(trc::JmapEvent::CannotCalculateChanges.into_err());
}
};
let max = request
.max_changes
.filter(|max| *max != 0)
.unwrap_or(usize::MAX)
.min(server.core.jmap.changes_max_results);
let entries = undelete::data::changes_since(&server.core.storage.data, account_id, since)
.await?
.into_iter()
.map(|(change_id, id, change)| {
(
change_id,
id,
match change {
undelete::data::Change::Created => {
inbuxa_features::masked_email::data::Change::Created
}
undelete::data::Change::Updated => {
inbuxa_features::masked_email::data::Change::Updated
}
undelete::data::Change::Destroyed => {
inbuxa_features::masked_email::data::Change::Destroyed
}
},
)
})
.collect::<Vec<_>>();
let changes = collapse(since, &entries, max);
Ok(ResponseMethod::Changes(ChangesResponseMethod::Registry(
Box::new(ChangesResponse {
account_id: request.account_id,
old_state: request.since_state,
new_state: if changes.new_state == 0 {
JmapState::Initial
} else {
JmapState::Exact(changes.new_state)
},
has_more_changes: changes.has_more,
created: changes.created,
updated: changes.updated,
destroyed: changes.destroyed,
updated_properties: None,
}),
)))
}
+4
View File
@@ -386,6 +386,10 @@ impl RegistryGet for Server {
| ObjectType::AccountPassword
| ObjectType::AppPassword => account_get(get).await.map(|get| get.into_response()),
ObjectType::Action => Ok(get.not_found_any().into_response()),
// inbuxa: undelete (UD-7, UD-13)
ObjectType::ArchivedItem => crate::inbuxa::undelete::get(get)
.await
.map(|get| get.into_response()),
#[cfg(not(feature = "enterprise"))]
_ => Ok(get.not_found_any().into_response()),
}
+1 -2
View File
@@ -20,8 +20,7 @@ impl EnterpriseRegistry for Server {
fn assert_enterprise_object(&self, object_type: ObjectType) -> trc::Result<()> {
if !matches!(
object_type,
ObjectType::ArchivedItem
| ObjectType::Metric
ObjectType::Metric
| ObjectType::Trace
) {
return Ok(());
+10
View File
@@ -131,6 +131,16 @@ impl RegistryQuery for Server {
.await
.and_then(|response| response.build()),
// inbuxa: filters on type, archivedAt and text (undelete)
ObjectType::ArchivedItem => crate::inbuxa::undelete::query(RegistryQueryResponse {
server: self,
access_token,
object_type,
request,
})
.await
.and_then(|response| response.build()),
// inbuxa: filters on enabled, forDomain and text (masked email)
ObjectType::MaskedEmail => crate::inbuxa::masked_email::query(RegistryQueryResponse {
server: self,
+5
View File
@@ -817,6 +817,11 @@ impl RegistrySet for Server {
ObjectType::Task => task_set(set).await.map(|set| set.into_response()),
// inbuxa: undelete (UD-8, UD-12)
ObjectType::ArchivedItem => crate::inbuxa::undelete::set(set)
.await
.map(|set| set.into_response()),
ObjectType::Action => Box::pin(action_set(set))
.await
.map(|set| set.into_response()),