Masked email: upstream's x:MaskedEmail API (ME-2, ME-3, ME-6a, ME-7a, ME-12 to ME-19)

x:MaskedEmail is no longer refused as unbuilt. Creates generate the address
on an allowed domain and check the prefix, maxMaskedAddresses and the create
rate; updates keep server-set fields; enabled reads and writes map to the
shared state; query filters on enabled, forDomain and text; a tenant
administrator reaches its tenant's accounts' masks.
This commit is contained in:
2026-09-18 16:20:39 -07:00
parent aaca8fe537
commit 3b052a57da
8 changed files with 444 additions and 6 deletions
+27 -3
View File
@@ -367,7 +367,8 @@ impl RequestHandler for Server {
} }
GetRequestMethod::Registry(mut req) => { GetRequestMethod::Registry(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
access_token.assert_is_member(req.account_id)?; assert_registry_account(self, method_name.obj, access_token, req.account_id)
.await?;
Box::pin(self.registry_get( Box::pin(self.registry_get(
method_name.obj.unwrap_registry(), method_name.obj.unwrap_registry(),
@@ -458,7 +459,8 @@ impl RequestHandler for Server {
} }
QueryRequestMethod::Registry(mut req) => { QueryRequestMethod::Registry(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
access_token.assert_is_member(req.account_id)?; assert_registry_account(self, method_name.obj, access_token, req.account_id)
.await?;
Box::pin(self.registry_query( Box::pin(self.registry_query(
method_name.obj.unwrap_registry(), method_name.obj.unwrap_registry(),
@@ -574,7 +576,8 @@ impl RequestHandler for Server {
} }
SetRequestMethod::Registry(mut req) => { SetRequestMethod::Registry(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
access_token.assert_is_member(req.account_id)?; assert_registry_account(self, method_name.obj, access_token, req.account_id)
.await?;
Box::pin(self.registry_set( Box::pin(self.registry_set(
method_name.obj.unwrap_registry(), method_name.obj.unwrap_registry(),
@@ -716,6 +719,27 @@ impl RequestHandler for Server {
} }
} }
// inbuxa: ME-19: a tenant administrator reaches the masks of its tenant's
// accounts without impersonate, and a tenant principal never reaches beyond
// its tenant
async fn assert_registry_account(
server: &Server,
obj: MethodObject,
access_token: &AccessToken,
account_id: Id,
) -> trc::Result<()> {
if obj == MethodObject::Registry(registry::schema::prelude::ObjectType::MaskedEmail) {
crate::inbuxa::masked_email::assert_can_manage(
server,
access_token,
account_id.document_id(),
)
.await
} else {
access_token.assert_is_member(account_id).map(|_| ())
}
}
pub(crate) fn resolve_account_id( pub(crate) fn resolve_account_id(
account_id: &mut Id, account_id: &mut Id,
obj: MethodObject, obj: MethodObject,
+352
View File
@@ -0,0 +1,352 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Masked email over JMAP (`docs/spec/features/masked-email.md`): what both
//! APIs share, and upstream's `x:MaskedEmail` hooks. The rules themselves
//! are in `inbuxa_features::masked_email`.
use crate::{
api::query::QueryResponseBuilder,
registry::{
mapping::{ObjectResponse, RegistryQueryResponse, RegistrySetResponse, ValidationResult},
query::RegistryQueryFilters,
},
};
use common::{Server, auth::AccessToken};
use inbuxa_features::masked_email::{
State, address,
ops::{self, Mask},
policy,
};
use jmap_proto::{error::set::SetError, types::state::State as JmapState};
use registry::{
jmap::JmapValue,
schema::{
enums::{Permission, StorageQuota},
prelude::Property,
structs::{MaskedEmail, Rate},
},
types::{datetime::UTCDateTime, duration::Duration},
};
use store::registry::RegistryFilterOp;
use trc::AddContext;
use types::id::Id;
use utils::map::vec_map::VecMap;
/// Why a mask can't be created.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CreateRefusal {
/// `emailPrefix` isn't 1 to 64 of `a-z0-9_` (acceptance test 7).
InvalidPrefix,
/// `emailDomain` isn't one the account is linked to (ME-12).
DomainNotAllowed,
/// `maxMaskedAddresses` is reached, or 0 (ME-14).
OverQuota,
/// Too many creates this hour (ME-15).
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)
)))
}
}
/// 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).
async fn domains_of(server: &Server, account_id: u32) -> trc::Result<Vec<(u32, String)>> {
let account = server.account(account_id).await?;
let mut domains: Vec<(u32, String)> = Vec::new();
for address in account.addresses.iter() {
if domains.iter().any(|(id, _)| *id == address.domain_id) {
continue;
}
if let Some(domain) = server.domain_by_id(address.domain_id).await? {
for name in domain.names.iter() {
domains.push((address.domain_id, name.to_lowercase()));
}
}
}
Ok(domains)
}
/// Fills in a new mask's address, owner and creation time, after checking
/// the prefix, the domain and the account's limits (ME-12 to ME-16).
pub async fn prepare_create(
server: &Server,
account_id: u32,
mask: &mut MaskedEmail,
prefix: Option<&str>,
domain: Option<&str>,
) -> trc::Result<Result<(), CreateRefusal>> {
let prefix = prefix.filter(|p| !p.is_empty());
if prefix.is_some_and(|p| !address::is_valid_prefix(p)) {
return Ok(Err(CreateRefusal::InvalidPrefix));
}
let domains = domains_of(server, account_id).await?;
let domain = match domain {
Some(name) => domains
.iter()
.find(|(_, candidate)| candidate.eq_ignore_ascii_case(name.trim())),
None => domains.first(),
};
let Some((domain_id, domain_name)) = domain.cloned() else {
return Ok(Err(CreateRefusal::DomainNotAllowed));
};
let data = &server.core.storage.data;
let account = server.account(account_id).await?;
let limit = server.object_quota(
account.quota_objects.as_deref(),
StorageQuota::MaxMaskedAddresses,
);
if !policy::within_limit(
limit,
ops::live_count(data, server.registry(), account_id).await?,
) {
return Ok(Err(CreateRefusal::OverQuota));
}
if let Some(per_hour) = policy::creates_per_hour()
&& server
.in_memory_store()
.is_rate_allowed(
policy::KV_CREATE_RATE,
&account_id.to_be_bytes(),
&Rate {
count: per_hour,
period: Duration::from_millis(3_600_000),
},
false,
)
.await
.caused_by(trc::location!())?
.is_some()
{
return Ok(Err(CreateRefusal::RateLimited));
}
mask.email =
address::generate(data, server.registry(), prefix, domain_id, &domain_name).await?;
mask.account_id = Id::from(account_id);
mask.created_at = UTCDateTime::now();
Ok(Ok(()))
}
impl CreateRefusal {
/// The refusal as a JMAP `SetError`, naming the property involved.
pub fn into_set_error<P: jmap_tools::Property>(self, prefix: P, domain: P) -> SetError<P> {
match self {
CreateRefusal::InvalidPrefix => SetError::invalid_properties()
.with_property(prefix)
.with_description("emailPrefix must be 1 to 64 characters from a-z, 0-9 and _."),
CreateRefusal::DomainNotAllowed => SetError::forbidden()
.with_property(domain)
.with_description("The account can't have masked addresses on this domain."),
CreateRefusal::OverQuota => {
SetError::new(jmap_proto::error::set::SetErrorType::OverQuota)
.with_description("The account's maxMaskedAddresses limit is reached.")
}
CreateRefusal::RateLimited => {
SetError::new(jmap_proto::error::set::SetErrorType::RateLimit)
.with_description("Too many masked addresses created in the last hour.")
}
}
}
}
/// `x:MaskedEmail/set`: a mask about to be created or changed (ME-12 to
/// ME-17). On an update, the server-set and create-only fields keep their
/// stored values.
pub async fn validate(
set: &RegistrySetResponse<'_>,
mask: &mut MaskedEmail,
old: Option<&MaskedEmail>,
unpatched: VecMap<Property, JmapValue<'_>>,
) -> ValidationResult {
match old {
None => {
let text = |property| {
unpatched
.get(&property)
.and_then(|value: &JmapValue<'_>| value.as_str())
.map(|value| value.to_string())
};
let prefix = text(Property::EmailPrefix);
let domain = text(Property::EmailDomain);
Ok(prepare_create(
set.server,
set.account_id,
mask,
prefix.as_deref(),
domain.as_deref(),
)
.await?
.map(|_| ObjectResponse::default())
.map_err(|refusal| {
refusal.into_set_error(Property::EmailPrefix, Property::EmailDomain)
}))
}
Some(old) => {
if mask.expires_at != old.expires_at {
return Ok(Err(SetError::invalid_properties()
.with_property(Property::ExpiresAt)
.with_description(
"expiresAt can only be set when the mask is created.",
)));
}
mask.email = old.email.clone();
mask.account_id = old.account_id;
mask.created_at = old.created_at;
Ok(Ok(ObjectResponse::default()))
}
}
}
/// After `x:MaskedEmail/set` creates a mask: it starts `enabled`, or
/// `deleted` if created with `enabled: false` (ME-7a).
pub async fn created(server: &Server, id: Id, mask: &MaskedEmail) -> trc::Result<()> {
ops::created(
&server.core.storage.data,
server.registry(),
id,
mask,
State::from_upstream(mask.enabled),
)
.await
}
/// After `x:MaskedEmail/set` changes a mask: `enabled` sets the state
/// (ME-2), and anything else leaves it as it was.
pub async fn updated(
server: &Server,
id: Id,
old: &MaskedEmail,
new: &MaskedEmail,
) -> trc::Result<()> {
let data = &server.core.storage.data;
if let Some(mask) = ops::load(data, server.registry(), id).await? {
let state = if old.enabled != new.enabled {
State::from_upstream_write(new.enabled)
} else {
mask.state
};
ops::updated(data, server.registry(), &mask, state).await?;
}
Ok(())
}
/// After `x:MaskedEmail/set` destroys a mask: its address is tombstoned
/// (ME-3).
pub async fn destroyed(server: &Server, id: Id, mask: &MaskedEmail) -> trc::Result<()> {
ops::destroyed(&server.core.storage.data, server.registry(), id, mask).await
}
/// `x:MaskedEmail/get`: `enabled` reports whether mail is accepted, so an
/// expired or deleted mask reads false (ME-6a). `false` when the mask is gone
/// (a pending one past its deadline, ME-8).
pub async fn read(server: &Server, id: Id, mask: &mut MaskedEmail) -> trc::Result<bool> {
match ops::load(&server.core.storage.data, server.registry(), id).await? {
Some(loaded) => {
mask.enabled = loaded.state.as_upstream_enabled(loaded.expired);
Ok(true)
}
None => Ok(false),
}
}
/// `x:MaskedEmail/query`, which also filters on `enabled`, `forDomain` and
/// text in the address and description (a fork addition).
pub 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 enabled = None;
let mut for_domain = None;
let mut text = None;
req.request
.extract_filters(|property, op, value| match (property, op, value) {
(Property::Enabled, RegistryFilterOp::Equal, serde_json::Value::Bool(v)) => {
enabled = Some(v);
true
}
(Property::ForDomain, RegistryFilterOp::Equal, serde_json::Value::String(v)) => {
for_domain = Some(v);
true
}
(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 ids = ops::of_account(
&req.server.core.storage.data,
req.server.registry(),
account_id,
)
.await?
.into_iter()
.filter(|mask: &Mask| {
enabled.is_none_or(|e| mask.state.as_upstream_enabled(mask.expired) == e)
&& for_domain
.as_deref()
.is_none_or(|d| mask.object.for_domain.as_deref() == Some(d))
&& text.as_deref().is_none_or(|t| {
mask.object.email.to_lowercase().contains(t)
|| mask
.object
.description
.as_deref()
.is_some_and(|d| d.to_lowercase().contains(t))
})
})
.map(|mask| mask.id)
.collect::<Vec<_>>();
ids.sort_unstable();
let mut response = QueryResponseBuilder::new(
ids.len(),
req.server.core.jmap.query_max_results,
JmapState::Initial,
&req.request,
);
for id in ids {
if !response.add_id(id) {
break;
}
}
Ok(response)
}
+10
View File
@@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! JMAP glue for INBUXA's rebuilt features. The features' rules live in
//! `crates/features`; this module only speaks JMAP for them.
pub mod masked_email;
+1
View File
@@ -33,6 +33,7 @@ pub mod contact;
pub mod email; pub mod email;
pub mod file; pub mod file;
pub mod identity; pub mod identity;
pub mod inbuxa; // inbuxa: the fork's own features
pub mod mailbox; pub mod mailbox;
pub mod participant_identity; pub mod participant_identity;
pub mod principal; pub mod principal;
+12 -1
View File
@@ -196,6 +196,7 @@ impl RegistryGet for Server {
| ObjectType::Domain => { | ObjectType::Domain => {
let is_singleton = (get.object_flags & OBJ_SINGLETON) != 0; let is_singleton = (get.object_flags & OBJ_SINGLETON) != 0;
let ids_requested = get.ids.is_some();
let ids = if let Some(ids) = get.ids.take() { let ids = if let Some(ids) = get.ids.take() {
ids ids
} else if object_type == ObjectType::Tenant } else if object_type == ObjectType::Tenant
@@ -217,7 +218,7 @@ impl RegistryGet for Server {
get.response.list.reserve(ids.len()); get.response.list.reserve(ids.len());
for id in ids { for id in ids {
let object = if let Some(object) = self let mut object = if let Some(object) = self
.registry() .registry()
.get(ObjectId::new(object_type, id)) .get(ObjectId::new(object_type, id))
.await .await
@@ -245,6 +246,16 @@ impl RegistryGet for Server {
continue; continue;
}; };
// inbuxa: ME-6a, ME-8
if let ObjectInner::MaskedEmail(mask) = &mut object.inner
&& !crate::inbuxa::masked_email::read(self, id, mask).await?
{
if ids_requested {
get.not_found(id);
}
continue;
}
let mut extra_properties: VecMap<Property, _> = VecMap::new(); let mut extra_properties: VecMap<Property, _> = VecMap::new();
match &object.inner { match &object.inner {
ObjectInner::DkimSignature(obj) ObjectInner::DkimSignature(obj)
+1 -2
View File
@@ -20,8 +20,7 @@ impl EnterpriseRegistry for Server {
fn assert_enterprise_object(&self, object_type: ObjectType) -> trc::Result<()> { fn assert_enterprise_object(&self, object_type: ObjectType) -> trc::Result<()> {
if !matches!( if !matches!(
object_type, object_type,
ObjectType::MaskedEmail ObjectType::ArchivedItem
| ObjectType::ArchivedItem
| ObjectType::Metric | ObjectType::Metric
| ObjectType::Trace | ObjectType::Trace
) { ) {
+10
View File
@@ -131,6 +131,16 @@ impl RegistryQuery for Server {
.await .await
.and_then(|response| response.build()), .and_then(|response| response.build()),
// inbuxa: filters on enabled, forDomain and text (masked email)
ObjectType::MaskedEmail => crate::inbuxa::masked_email::query(RegistryQueryResponse {
server: self,
access_token,
object_type,
request,
})
.await
.and_then(|response| response.build()),
ObjectType::Action => Err(trc::JmapEvent::InvalidArguments ObjectType::Action => Err(trc::JmapEvent::InvalidArguments
.into_err() .into_err()
.details("Actions cannot be queried")), .details("Actions cannot be queried")),
+31
View File
@@ -500,6 +500,23 @@ impl RegistrySet for Server {
) )
.await? .await?
} }
// inbuxa: ME-12 to ME-17
ObjectInner::MaskedEmail(mask) => {
let old = match &modification {
Modification::Update { object, .. } => match &object.inner {
ObjectInner::MaskedEmail(old) => Some(old),
_ => None,
},
Modification::Create { .. } => None,
};
crate::inbuxa::masked_email::validate(
&set,
mask,
old,
unpatched_properties,
)
.await?
}
ObjectInner::AcmeProvider(provider) if is_create => { ObjectInner::AcmeProvider(provider) if is_create => {
validate_acme_provider(&set, provider, unpatched_properties).await? validate_acme_provider(&set, provider, unpatched_properties).await?
} }
@@ -629,6 +646,12 @@ impl RegistrySet for Server {
{ {
cache_invalidator.process_update(id, &old, &new); cache_invalidator.process_update(id, &old, &new);
} }
// inbuxa: ME-1, ME-2
if let (ObjectInner::MaskedEmail(old), ObjectInner::MaskedEmail(new)) =
(&object.inner, &new_object.inner)
{
crate::inbuxa::masked_email::updated(self, id, old, new).await?;
}
if let ( if let (
ObjectInner::Application(previous), ObjectInner::Application(previous),
ObjectInner::Application(updated), ObjectInner::Application(updated),
@@ -656,6 +679,10 @@ impl RegistrySet for Server {
RegistryWriteResult::Success(id), RegistryWriteResult::Success(id),
) => { ) => {
cache_invalidator.process_create(&new_object); cache_invalidator.process_create(&new_object);
// inbuxa: ME-7a
if let ObjectInner::MaskedEmail(mask) = &new_object.inner {
crate::inbuxa::masked_email::created(self, id, mask).await?;
}
response.object.insert(Property::Id, RegistryValue::Id(id)); response.object.insert(Property::Id, RegistryValue::Id(id));
set.response set.response
.created .created
@@ -746,6 +773,10 @@ impl RegistrySet for Server {
); );
} }
// inbuxa: ME-3
if let ObjectInner::MaskedEmail(mask) = &object.inner {
crate::inbuxa::masked_email::destroyed(self, id, mask).await?;
}
cache_invalidator.process_delete(id, &object); cache_invalidator.process_delete(id, &object);
set.response.destroyed.push(id); set.response.destroyed.push(id);
} }