diff --git a/crates/features/src/masked_email/data.rs b/crates/features/src/masked_email/data.rs index 6d42e69..9d69a9c 100644 --- a/crates/features/src/masked_email/data.rs +++ b/crates/features/src/masked_email/data.rs @@ -196,15 +196,14 @@ fn change_key(account_id: u32, change_id: u64) -> ValueKey { key(KIND_CHANGE, &rest) } -/// The account's changes after `since`, oldest first, and the latest change -/// id (`since` when there are none). +/// The account's changes after `since`, oldest first, each with its change +/// id. pub async fn changes_since( data: &Store, account_id: u32, since: u64, -) -> trc::Result<(Vec<(Id, Change)>, u64)> { +) -> trc::Result> { let mut changes = Vec::new(); - let mut latest = since; data.iterate( IterateParams::new( change_key(account_id, since.saturating_add(1)), @@ -213,13 +212,13 @@ pub async fn changes_since( .ascending(), |key, value| { if key.len() >= 8 && value.len() == 9 { - latest = latest.max(u64::from_be_bytes(key[key.len() - 8..].try_into().unwrap())); let change = match value[8] { 0 => Change::Created, 1 => Change::Updated, _ => Change::Destroyed, }; changes.push(( + u64::from_be_bytes(key[key.len() - 8..].try_into().unwrap()), Id::new(u64::from_be_bytes(value[0..8].try_into().unwrap())), change, )); @@ -229,7 +228,7 @@ pub async fn changes_since( ) .await .caused_by(trc::location!())?; - Ok((changes, latest)) + Ok(changes) } /// The account's latest change id, 0 when there's none. diff --git a/crates/features/src/masked_email/ops.rs b/crates/features/src/masked_email/ops.rs index 82af2c3..34e6f72 100644 --- a/crates/features/src/masked_email/ops.rs +++ b/crates/features/src/masked_email/ops.rs @@ -346,6 +346,55 @@ pub async fn sends_as( .is_some_and(|mask| mask.object.account_id.document_id() == account_id)) } +/// Changes to an account's masks since a state, for `/changes`. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Changes { + pub created: Vec, + pub updated: Vec, + pub destroyed: Vec, + /// The state after the changes returned. + pub new_state: u64, + pub has_more: bool, +} + +/// The changes since `since`, at most `max` masks' worth. A mask created and +/// destroyed within the window isn't reported; one created and changed is +/// reported as created. +pub fn collapse(since: u64, entries: &[(u64, Id, Change)], max: usize) -> Changes { + use ahash::AHashMap; + let mut state: AHashMap = AHashMap::new(); // (created, destroyed) + let mut order = Vec::new(); + let mut result = Changes { + new_state: since, + ..Default::default() + }; + for (change_id, id, change) in entries { + if !state.contains_key(id) { + if order.len() == max { + result.has_more = true; + break; + } + order.push(*id); + } + let entry = state.entry(*id).or_insert((false, false)); + match change { + Change::Created => entry.0 = true, + Change::Destroyed => entry.1 = true, + Change::Updated => {} + } + result.new_state = *change_id; + } + for id in order { + match state[&id] { + (true, true) => {} + (true, false) => result.created.push(id), + (false, true) => result.destroyed.push(id), + (false, false) => result.updated.push(id), + } + } + result +} + /// What an address is, as far as masks go. #[derive(Debug)] pub enum Lookup { @@ -406,6 +455,30 @@ pub fn with_header(address: &str, message: &[u8]) -> Vec { mod tests { use super::*; + #[test] + fn changes_collapse() { + let (a, b, c) = (Id::new(1), Id::new(2), Id::new(3)); + let entries = [ + (10, a, Change::Created), + (11, a, Change::Updated), + (12, b, Change::Updated), + (13, c, Change::Created), + (14, c, Change::Destroyed), + (15, b, Change::Destroyed), + ]; + let all = collapse(9, &entries, 100); + assert_eq!(all.created, vec![a]); + assert_eq!(all.destroyed, vec![b]); + assert!(all.updated.is_empty()); + assert_eq!(all.new_state, 15); + assert!(!all.has_more); + + let first = collapse(9, &entries, 1); + assert_eq!(first.created, vec![a]); + assert_eq!(first.new_state, 11); + assert!(first.has_more); + } + #[test] fn header_goes_first() { let out = with_header("shop_ab12@example.org", b"Subject: hi\r\n\r\nbody"); diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index c3cb663..a543559 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -128,6 +128,10 @@ impl Response<'_> { ChangesResponseMethod::ShareNotification(response) => { response.eval_jptr(path, &mut results) } + // inbuxa: x:MaskedEmail/changes + ChangesResponseMethod::Registry(response) => { + response.eval_jptr(path, &mut results) + } }, ResponseMethod::Query(response) => response.eval_jptr(path, &mut results), ResponseMethod::QueryChanges(response) => { diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 3e45c8c..45b9d9b 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -361,7 +361,12 @@ impl MethodName { "get" => MethodFunction::Get, "set" => MethodFunction::Set, "query" => MethodFunction::Query, + "changes" => MethodFunction::Changes, )?; + // inbuxa: only masked email has /changes (a fork addition) + if fnc == MethodFunction::Changes && obj != ObjectType::MaskedEmail { + return None; + } if obj.flags() & OBJ_SINGLETON == 0 || fnc != MethodFunction::Query { (MethodObject::Registry(obj), fnc).into() diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index c9b72ab..4c9463a 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -137,6 +137,8 @@ pub enum ChangesResponseMethod { CalendarEvent(Box>), CalendarEventNotification(Box>), ShareNotification(Box>), + // inbuxa: x:MaskedEmail/changes + Registry(Box>), } #[derive(Debug, serde::Serialize)] diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 110e2d2..4969014 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -247,8 +247,9 @@ impl JmapAuthorization for AccessToken { | MethodObject::PushSubscription | MethodObject::SearchSnippet | MethodObject::VacationResponse - | MethodObject::SieveScript - | MethodObject::Registry(_) => Permission::JmapEmailChanges, + | MethodObject::SieveScript => Permission::JmapEmailChanges, + // inbuxa: x:MaskedEmail/changes reads what /get reads + MethodObject::Registry(object_type) => object_type.get_permission(), }, RequestMethod::Copy(m) => match &m { CopyRequestMethod::Email(_) => Permission::JmapEmailCopy, diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index a6b4103..68e160d 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -592,9 +592,14 @@ impl RequestHandler for Server { RequestMethod::Changes(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; - self.changes(*req, method_name.obj, access_token) - .await? - .into_method_response() + // inbuxa: x:MaskedEmail/changes + 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) + .await? + .into_method_response() + } } RequestMethod::Copy(req) => match req { CopyRequestMethod::Email(mut req) => { diff --git a/crates/jmap/src/inbuxa/masked_email.rs b/crates/jmap/src/inbuxa/masked_email.rs index e619236..cbe8ee8 100644 --- a/crates/jmap/src/inbuxa/masked_email.rs +++ b/crates/jmap/src/inbuxa/masked_email.rs @@ -350,3 +350,67 @@ pub async fn query(mut req: RegistryQueryResponse<'_>) -> trc::Result trc::Result { + let latest = + inbuxa_features::masked_email::data::latest_change(&server.core.storage.data, account_id) + .await?; + Ok(if latest == 0 { + JmapState::Initial + } else { + JmapState::Exact(latest) + }) +} + +/// `x:MaskedEmail/changes` (a fork addition). +pub async fn changes( + server: &Server, + access_token: &AccessToken, + request: jmap_proto::method::changes::ChangesRequest, +) -> trc::Result> { + 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 = inbuxa_features::masked_email::data::changes_since( + &server.core.storage.data, + account_id, + since, + ) + .await?; + let changes = ops::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, + }), + ))) +} diff --git a/crates/jmap/src/registry/get.rs b/crates/jmap/src/registry/get.rs index 5d34917..4b03273 100644 --- a/crates/jmap/src/registry/get.rs +++ b/crates/jmap/src/registry/get.rs @@ -357,6 +357,12 @@ impl RegistryGet for Server { get.insert(id, object); } + // inbuxa: a state for masked email (a fork addition) + if object_type == ObjectType::MaskedEmail { + get.response.state = + Some(crate::inbuxa::masked_email::state(self, get.account_id).await?); + } + Ok(get.into_response()) } ObjectType::QueuedMessage => {