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:
@@ -367,7 +367,8 @@ impl RequestHandler for Server {
|
||||
}
|
||||
GetRequestMethod::Registry(mut req) => {
|
||||
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(
|
||||
method_name.obj.unwrap_registry(),
|
||||
@@ -458,7 +459,8 @@ impl RequestHandler for Server {
|
||||
}
|
||||
QueryRequestMethod::Registry(mut req) => {
|
||||
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(
|
||||
method_name.obj.unwrap_registry(),
|
||||
@@ -574,7 +576,8 @@ impl RequestHandler for Server {
|
||||
}
|
||||
SetRequestMethod::Registry(mut req) => {
|
||||
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(
|
||||
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(
|
||||
account_id: &mut Id,
|
||||
obj: MethodObject,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -33,6 +33,7 @@ pub mod contact;
|
||||
pub mod email;
|
||||
pub mod file;
|
||||
pub mod identity;
|
||||
pub mod inbuxa; // inbuxa: the fork's own features
|
||||
pub mod mailbox;
|
||||
pub mod participant_identity;
|
||||
pub mod principal;
|
||||
|
||||
@@ -196,6 +196,7 @@ impl RegistryGet for Server {
|
||||
| ObjectType::Domain => {
|
||||
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() {
|
||||
ids
|
||||
} else if object_type == ObjectType::Tenant
|
||||
@@ -217,7 +218,7 @@ impl RegistryGet for Server {
|
||||
get.response.list.reserve(ids.len());
|
||||
|
||||
for id in ids {
|
||||
let object = if let Some(object) = self
|
||||
let mut object = if let Some(object) = self
|
||||
.registry()
|
||||
.get(ObjectId::new(object_type, id))
|
||||
.await
|
||||
@@ -245,6 +246,16 @@ impl RegistryGet for Server {
|
||||
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();
|
||||
match &object.inner {
|
||||
ObjectInner::DkimSignature(obj)
|
||||
|
||||
@@ -20,8 +20,7 @@ impl EnterpriseRegistry for Server {
|
||||
fn assert_enterprise_object(&self, object_type: ObjectType) -> trc::Result<()> {
|
||||
if !matches!(
|
||||
object_type,
|
||||
ObjectType::MaskedEmail
|
||||
| ObjectType::ArchivedItem
|
||||
ObjectType::ArchivedItem
|
||||
| ObjectType::Metric
|
||||
| ObjectType::Trace
|
||||
) {
|
||||
|
||||
@@ -131,6 +131,16 @@ impl RegistryQuery for Server {
|
||||
.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,
|
||||
access_token,
|
||||
object_type,
|
||||
request,
|
||||
})
|
||||
.await
|
||||
.and_then(|response| response.build()),
|
||||
|
||||
ObjectType::Action => Err(trc::JmapEvent::InvalidArguments
|
||||
.into_err()
|
||||
.details("Actions cannot be queried")),
|
||||
|
||||
@@ -500,6 +500,23 @@ impl RegistrySet for Server {
|
||||
)
|
||||
.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 => {
|
||||
validate_acme_provider(&set, provider, unpatched_properties).await?
|
||||
}
|
||||
@@ -629,6 +646,12 @@ impl RegistrySet for Server {
|
||||
{
|
||||
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 (
|
||||
ObjectInner::Application(previous),
|
||||
ObjectInner::Application(updated),
|
||||
@@ -656,6 +679,10 @@ impl RegistrySet for Server {
|
||||
RegistryWriteResult::Success(id),
|
||||
) => {
|
||||
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));
|
||||
set.response
|
||||
.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);
|
||||
set.response.destroyed.push(id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user