From d04aafd3d7223dae1a5455e7172b3f32a419f8af Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 18 Sep 2026 16:29:47 -0700 Subject: [PATCH] Masked email: Fastmail's Masked Email API, MaskedEmail/get and /set (ME-1, ME-7a, ME-16) Advertised as https://www.fastmail.com/dev/maskedemail in the session and on every account that may hold masks. Masks created through it start pending unless the create sets a state; pending can't be set again once left; state and the other mutable fields map onto the same records the x: API uses. --- .../src/object/fastmail_masked_email.rs | 190 ++++++++++ crates/jmap-proto/src/object/mod.rs | 1 + crates/jmap-proto/src/references/eval.rs | 3 + crates/jmap-proto/src/references/resolve.rs | 4 + crates/jmap-proto/src/request/capability.rs | 5 + crates/jmap-proto/src/request/method.rs | 9 + crates/jmap-proto/src/request/mod.rs | 2 + crates/jmap-proto/src/request/parser.rs | 14 + crates/jmap-proto/src/response/mod.rs | 15 + crates/jmap/src/api/auth.rs | 13 +- crates/jmap/src/api/request.rs | 17 + crates/jmap/src/api/session.rs | 17 +- crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/inbuxa/fastmail.rs | 329 ++++++++++++++++++ crates/jmap/src/inbuxa/mod.rs | 1 + tests/src/jmap/principal/get.rs | 10 +- 16 files changed, 626 insertions(+), 5 deletions(-) create mode 100644 crates/jmap-proto/src/object/fastmail_masked_email.rs create mode 100644 crates/jmap/src/inbuxa/fastmail.rs diff --git a/crates/jmap-proto/src/object/fastmail_masked_email.rs b/crates/jmap-proto/src/object/fastmail_masked_email.rs new file mode 100644 index 0000000..6643990 --- /dev/null +++ b/crates/jmap-proto/src/object/fastmail_masked_email.rs @@ -0,0 +1,190 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Fastmail's published Masked Email API, `MaskedEmail/get` and +//! `MaskedEmail/set` under `https://www.fastmail.com/dev/maskedemail` +//! (masked-email spec, "The two APIs"). The ids are the same as +//! `x:MaskedEmail`'s, so an id is one mask whichever API reads it. + +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 FastmailMaskedEmail; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FastmailMaskedEmailProperty { + Id, + Email, + State, + ForDomain, + Description, + LastMessageAt, + CreatedAt, + CreatedBy, + Url, + EmailPrefix, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FastmailMaskedEmailValue { + Id(Id), + Date(UTCDate), +} + +impl Property for FastmailMaskedEmailProperty { + fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option { + FastmailMaskedEmailProperty::parse(value) + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + FastmailMaskedEmailProperty::Id => "id", + FastmailMaskedEmailProperty::Email => "email", + FastmailMaskedEmailProperty::State => "state", + FastmailMaskedEmailProperty::ForDomain => "forDomain", + FastmailMaskedEmailProperty::Description => "description", + FastmailMaskedEmailProperty::LastMessageAt => "lastMessageAt", + FastmailMaskedEmailProperty::CreatedAt => "createdAt", + FastmailMaskedEmailProperty::CreatedBy => "createdBy", + FastmailMaskedEmailProperty::Url => "url", + FastmailMaskedEmailProperty::EmailPrefix => "emailPrefix", + } + .into() + } +} + +impl FastmailMaskedEmailProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => FastmailMaskedEmailProperty::Id, + b"email" => FastmailMaskedEmailProperty::Email, + b"state" => FastmailMaskedEmailProperty::State, + b"forDomain" => FastmailMaskedEmailProperty::ForDomain, + b"description" => FastmailMaskedEmailProperty::Description, + b"lastMessageAt" => FastmailMaskedEmailProperty::LastMessageAt, + b"createdAt" => FastmailMaskedEmailProperty::CreatedAt, + b"createdBy" => FastmailMaskedEmailProperty::CreatedBy, + b"url" => FastmailMaskedEmailProperty::Url, + b"emailPrefix" => FastmailMaskedEmailProperty::EmailPrefix, + ) + } +} + +impl FromStr for FastmailMaskedEmailProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + FastmailMaskedEmailProperty::parse(s).ok_or(()) + } +} + +impl Element for FastmailMaskedEmailValue { + type Property = FastmailMaskedEmailProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(FastmailMaskedEmailProperty::Id) => { + Id::from_str(value).ok().map(FastmailMaskedEmailValue::Id) + } + Key::Property( + FastmailMaskedEmailProperty::CreatedAt | FastmailMaskedEmailProperty::LastMessageAt, + ) => UTCDate::from_str(value) + .ok() + .map(FastmailMaskedEmailValue::Date), + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + FastmailMaskedEmailValue::Id(id) => id.to_string().into(), + FastmailMaskedEmailValue::Date(date) => date.to_string().into(), + } + } +} + +impl JmapObject for FastmailMaskedEmail { + type Property = FastmailMaskedEmailProperty; + + type Element = FastmailMaskedEmailValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = FastmailMaskedEmailProperty::Id; +} + +impl From for FastmailMaskedEmailValue { + fn from(id: Id) -> Self { + FastmailMaskedEmailValue::Id(id) + } +} + +impl JmapObjectId for FastmailMaskedEmailValue { + fn as_id(&self) -> Option { + match self { + FastmailMaskedEmailValue::Id(id) => Some(*id), + _ => None, + } + } + + fn as_any_id(&self) -> Option { + match self { + FastmailMaskedEmailValue::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 = FastmailMaskedEmailValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for FastmailMaskedEmailProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index 938fe1d..cdc77b4 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -18,6 +18,7 @@ pub mod calendar_event_notification; pub mod contact; pub mod email; pub mod email_submission; +pub mod fastmail_masked_email; // inbuxa: masked email pub mod file_node; pub mod identity; pub mod mailbox; diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index a543559..9db52fb 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -50,6 +50,9 @@ impl Response<'_> { GetResponseMethod::VacationResponse(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::MaskedEmail(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::Principal(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index 9080328..4f6faed 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -41,6 +41,7 @@ impl Response<'_> { GetRequestMethod::PushSubscription(request) => request.resolve_references(self)?, GetRequestMethod::Sieve(request) => request.resolve_references(self)?, GetRequestMethod::VacationResponse(request) => request.resolve_references(self)?, + GetRequestMethod::MaskedEmail(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)?, @@ -75,6 +76,9 @@ impl Response<'_> { SetRequestMethod::VacationResponse(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::MaskedEmail(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::AddressBook(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index 1c31621..b78065f 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -98,6 +98,9 @@ pub enum Capability { // inbuxa: the fork's own capability (contract C-1, multi-tenancy MT-22) #[serde(rename(serialize = "urn:inbuxa:jmap"))] Inbuxa = 1 << 20, + // inbuxa: Fastmail's Masked Email API (masked email) + #[serde(rename(serialize = "https://www.fastmail.com/dev/maskedemail"))] + FastmailMaskedEmail = 1 << 21, } #[derive(Debug, Clone, Copy, Default)] @@ -347,6 +350,7 @@ impl Capability { Capability::WebPushVapid => "urn:ietf:params:jmap:webpush-vapid", Capability::EmailPush => "urn:ietf:params:jmap:emailpush", Capability::Inbuxa => "urn:inbuxa:jmap", + Capability::FastmailMaskedEmail => "https://www.fastmail.com/dev/maskedemail", } } @@ -494,6 +498,7 @@ impl Capability { "urn:ietf:params:jmap:webpush-vapid" => Capability::WebPushVapid, "urn:ietf:params:jmap:emailpush" => Capability::EmailPush, "urn:inbuxa:jmap" => Capability::Inbuxa, + "https://www.fastmail.com/dev/maskedemail" => Capability::FastmailMaskedEmail, ) } } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 45b9d9b..bc1cd7f 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -41,6 +41,8 @@ pub enum MethodObject { ParticipantIdentity, ShareNotification, Registry(ObjectType), + // inbuxa: Fastmail's MaskedEmail + MaskedEmail, } impl MethodObject { @@ -64,6 +66,7 @@ impl MethodObject { MethodObject::AddressBook | MethodObject::ContactCard => Capability::Contacts, MethodObject::FileNode => Capability::FileNode, MethodObject::Registry(_) => Capability::Stalwart, + MethodObject::MaskedEmail => Capability::FastmailMaskedEmail, } } } @@ -235,6 +238,8 @@ impl MethodName { (MethodFunction::Set, MethodObject::ParticipantIdentity) => "ParticipantIdentity/set", (MethodFunction::Echo, MethodObject::Core) => "Core/echo", + (MethodFunction::Get, MethodObject::MaskedEmail) => "MaskedEmail/get", + (MethodFunction::Set, MethodObject::MaskedEmail) => "MaskedEmail/set", (method, MethodObject::Registry(obj)) => { return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str())); } @@ -354,6 +359,9 @@ impl MethodName { "Core/echo" => (MethodObject::Core, MethodFunction::Echo), + "MaskedEmail/get" => (MethodObject::MaskedEmail, MethodFunction::Get), + "MaskedEmail/set" => (MethodObject::MaskedEmail, MethodFunction::Set), + ).or_else(|| { let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?; let obj = ObjectType::parse(obj)?; @@ -401,6 +409,7 @@ impl Display for MethodObject { MethodObject::CalendarEvent => "CalendarEvent", MethodObject::CalendarEventNotification => "CalendarEventNotification", MethodObject::ShareNotification => "ShareNotification", + MethodObject::MaskedEmail => "MaskedEmail", MethodObject::Registry(obj) => { f.write_str("x:")?; return f.write_str(obj.as_str()); diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 42f6224..597112b 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -111,6 +111,7 @@ pub enum GetRequestMethod { ParticipantIdentity(Box>), ShareNotification(Box>), Registry(Box>), + MaskedEmail(Box>), } #[derive(Debug)] @@ -131,6 +132,7 @@ pub enum SetRequestMethod<'x> { CalendarEventNotification(Box>), ParticipantIdentity(Box>), Registry(Box>), + MaskedEmail(Box>), } #[derive(Debug)] diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 19a0dd7..d905ef2 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -146,6 +146,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Get, MethodObject::MaskedEmail) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::MaskedEmail(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), @@ -290,6 +297,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Set, MethodObject::MaskedEmail) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::MaskedEmail(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), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 4c9463a..7160267 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -98,6 +98,7 @@ pub enum GetResponseMethod { ParticipantIdentity(GetResponse), ShareNotification(GetResponse), Registry(GetResponse), + MaskedEmail(GetResponse), } #[derive(Debug, serde::Serialize)] @@ -119,6 +120,7 @@ pub enum SetResponseMethod { CalendarEventNotification(Box>), ParticipantIdentity(Box>), Registry(Box>), + MaskedEmail(Box>), } #[derive(Debug, serde::Serialize)] @@ -265,6 +267,19 @@ impl<'x> From> for ResponseMethod<'x> { } } +// inbuxa: Fastmail's MaskedEmail +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::MaskedEmail(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::MaskedEmail(Box::new(value))) + } +} + impl<'x> From> for ResponseMethod<'x> { fn from(value: GetResponse) -> Self { ResponseMethod::Get(GetResponseMethod::VacationResponse(value)) diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 4969014..6577ba1 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -69,6 +69,8 @@ impl JmapAuthorization for AccessToken { GetRequestMethod::PushSubscription(_) => Permission::JmapPushSubscriptionGet, GetRequestMethod::Sieve(_) => Permission::JmapSieveScriptGet, GetRequestMethod::VacationResponse(_) => Permission::JmapVacationResponseGet, + // inbuxa: Fastmail's MaskedEmail (ME-18) + GetRequestMethod::MaskedEmail(_) => Permission::SysMaskedEmailGet, GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet, GetRequestMethod::Quota(_) => Permission::JmapQuotaGet, GetRequestMethod::Blob(_) => Permission::JmapBlobGet, @@ -141,6 +143,14 @@ impl JmapAuthorization for AccessToken { Permission::JmapSieveScriptUpdate, Permission::JmapSieveScriptDestroy, ), + // inbuxa: Fastmail's MaskedEmail (ME-18) + SetRequestMethod::MaskedEmail(s) => validate_set( + s, + self, + Permission::SysMaskedEmailCreate, + Permission::SysMaskedEmailUpdate, + Permission::SysMaskedEmailDestroy, + ), SetRequestMethod::VacationResponse(s) => validate_set( s, self, @@ -247,7 +257,8 @@ impl JmapAuthorization for AccessToken { | MethodObject::PushSubscription | MethodObject::SearchSnippet | MethodObject::VacationResponse - | MethodObject::SieveScript => Permission::JmapEmailChanges, + | MethodObject::SieveScript + | MethodObject::MaskedEmail => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads MethodObject::Registry(object_type) => object_type.get_permission(), }, diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 68e160d..b117609 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -162,6 +162,9 @@ impl RequestHandler for Server { SetResponseMethod::VacationResponse(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::MaskedEmail(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::AddressBook(set_response) => { set_response.update_created_ids(&mut response); } @@ -296,6 +299,13 @@ impl RequestHandler for Server { self.vacation_response_get(*req).await?.into() } + // inbuxa: Fastmail's MaskedEmail/get + GetRequestMethod::MaskedEmail(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::fastmail::get(self, access_token, *req) + .await? + .into() + } GetRequestMethod::Principal(req) => { self.principal_get(*req, access_token).await?.into() } @@ -516,6 +526,13 @@ impl RequestHandler for Server { self.vacation_response_set(*req, access_token).await?.into() } + // inbuxa: Fastmail's MaskedEmail/set + SetRequestMethod::MaskedEmail(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::fastmail::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)?; diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index 291b113..dc97bb5 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -68,6 +68,20 @@ impl SessionHandler for Server { Capability::Inbuxa, Capabilities::Inbuxa(InbuxaAccountCapabilities { logo }), ); + // inbuxa: Fastmail's Masked Email API, for accounts that may hold masks + if access_token.has_permission(Permission::SysMaskedEmailGet) { + session.capabilities.append( + Capability::FastmailMaskedEmail, + Capabilities::Empty(EmptyCapabilities::default()), + ); + account.account_capabilities.append( + Capability::FastmailMaskedEmail, + Capabilities::Empty(EmptyCapabilities::default()), + ); + session + .primary_accounts + .append(Capability::FastmailMaskedEmail, account_id); + } session.accounts.append(account_id, account); // Add secondary accounts @@ -139,7 +153,8 @@ impl AccountCapabilities for AccessToken { Capability::Core | Capability::PrincipalsOwner | Capability::WebPushVapid - | Capability::Inbuxa => { + | Capability::Inbuxa + | Capability::FastmailMaskedEmail => { return false; } }; diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 821b1e2..454cb66 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -413,6 +413,7 @@ impl IntermediateChangesResponse { | MethodObject::SieveScript | MethodObject::Principal | MethodObject::Quota + | MethodObject::MaskedEmail | MethodObject::Registry(_) => unreachable!(), }) } diff --git a/crates/jmap/src/inbuxa/fastmail.rs b/crates/jmap/src/inbuxa/fastmail.rs new file mode 100644 index 0000000..0aade26 --- /dev/null +++ b/crates/jmap/src/inbuxa/fastmail.rs @@ -0,0 +1,329 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Fastmail's Masked Email API: `MaskedEmail/get` and `MaskedEmail/set` +//! (masked-email spec, "Fastmail's"). Masks created here start `pending` +//! unless the create sets `state` (ME-7a); `createdBy` is set by the server +//! (ME-16). + +use crate::inbuxa::masked_email::{assert_can_manage, prepare_create, state}; +use common::{Server, auth::AccessToken}; +use inbuxa_features::masked_email::{ + State, + ops::{self, Mask}, +}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::fastmail_masked_email::{ + FastmailMaskedEmail, FastmailMaskedEmailProperty as P, FastmailMaskedEmailValue, + }, + request::IntoValid, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Value}; +use registry::{ + schema::{ + prelude::{Object, ObjectType}, + structs::MaskedEmail, + }, + types::id::ObjectId, +}; +use std::borrow::Cow; +use store::registry::write::{RegistryWrite, RegistryWriteResult}; + +type FValue = Value<'static, P, FastmailMaskedEmailValue>; + +const ALL: &[P] = &[ + P::Id, + P::Email, + P::State, + P::ForDomain, + P::Description, + P::LastMessageAt, + P::CreatedAt, + P::CreatedBy, + P::Url, +]; + +fn text(value: &Option) -> FValue { + match value { + Some(value) => Value::Str(Cow::Owned(value.clone())), + None => Value::Null, + } +} + +fn date(timestamp: i64) -> FValue { + Value::Element(FastmailMaskedEmailValue::Date(UTCDate::from_timestamp( + timestamp, + ))) +} + +/// A mask in Fastmail's terms. An expired mask reads `deleted`, since it +/// refuses mail (ME-6a). +fn to_value(mask: &Mask, properties: &[P]) -> FValue { + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(FastmailMaskedEmailValue::Id(mask.id)), + P::Email => Value::Str(Cow::Owned(mask.object.email.clone())), + P::State => Value::Str(Cow::Borrowed(if mask.expired { + State::Deleted.as_fastmail() + } else { + mask.state.as_fastmail() + })), + P::ForDomain => text(&mask.object.for_domain), + P::Description => Value::Str(Cow::Owned( + mask.object.description.clone().unwrap_or_default(), + )), + P::LastMessageAt => match mask.last_message_at { + Some(at) => date(at as i64), + None => Value::Null, + }, + P::CreatedAt => date(mask.object.created_at.timestamp()), + P::CreatedBy => text(&mask.object.created_by), + P::Url => text(&mask.object.url), + P::EmailPrefix => continue, + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// `MaskedEmail/get`, in the user's own JMAP account. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + let account_id = request.account_id.document_id(); + assert_can_manage(server, access_token, account_id).await?; + 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: state(server, account_id).await?.into(), + list: Vec::new(), + not_found, + }; + + let data = &server.core.storage.data; + match ids { + None => { + for mask in ops::of_account(data, server.registry(), account_id).await? { + response.list.push(to_value(&mask, &properties)); + } + } + Some(ids) => { + for id in ids { + match ops::load(data, server.registry(), id).await? { + Some(mask) if mask.object.account_id.document_id() == account_id => { + response.list.push(to_value(&mask, &properties)); + } + _ => response.push_not_found(id), + } + } + } + } + Ok(response) +} + +fn string_of(value: &Value<'_, P, FastmailMaskedEmailValue>) -> Option> { + match value { + Value::Str(s) => Some(Some(s.to_string())), + Value::Null => Some(None), + _ => None, + } +} + +fn invalid(property: P, description: &'static str) -> SetError

{ + SetError::invalid_properties() + .with_property(property) + .with_description(description) +} + +/// Applies the mutable fields of a create or update to a mask, returning +/// the state it asks for, if any. +fn apply( + mask: &mut MaskedEmail, + object: Value<'_, P, FastmailMaskedEmailValue>, + is_create: bool, + prefix: &mut Option, +) -> Result, SetError

> { + let mut state = None; + for (key, value) in object.into_expanded_object() { + let Key::Property(property) = key else { + return Err(SetError::invalid_properties().with_description("Unknown property.")); + }; + match property { + P::State => match value.as_str().and_then(|s| State::parse_fastmail(&s)) { + Some(new) => state = Some(new), + None => return Err(invalid(P::State, "Invalid state.")), + }, + P::ForDomain | P::Description | P::Url => { + let Some(text) = string_of(&value) else { + return Err(invalid(property, "Expected a string.")); + }; + match property { + P::ForDomain => mask.for_domain = text, + P::Description => mask.description = text.filter(|d| !d.is_empty()), + _ => mask.url = text, + } + } + P::EmailPrefix if is_create => match string_of(&value) { + Some(value) => *prefix = value, + None => return Err(invalid(P::EmailPrefix, "Expected a string.")), + }, + // Server-set: ignored on create, as the Fastmail API does + P::Id | P::Email | P::CreatedAt | P::CreatedBy | P::LastMessageAt if is_create => {} + _ => return Err(invalid(property, "This property can't be changed.")), + } + } + Ok(state) +} + +/// `MaskedEmail/set`, in the user's own JMAP account. +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, FastmailMaskedEmail>, +) -> trc::Result> { + let account_id = request.account_id.document_id(); + assert_can_manage(server, access_token, account_id).await?; + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + let data = &server.core.storage.data; + let registry = server.registry(); + + // Creates + for (client_id, object) in request.unwrap_create() { + let mut mask = MaskedEmail { + enabled: true, + ..Default::default() + }; + let mut prefix = None; + let state = match apply(&mut mask, object, true, &mut prefix) { + Ok(state) => state.unwrap_or(State::Pending), + Err(err) => { + response.not_created.append(client_id, err); + continue; + } + }; + if let Err(refusal) = + prepare_create(server, account_id, &mut mask, prefix.as_deref(), None).await? + { + response + .not_created + .append(client_id, refusal.into_set_error(P::EmailPrefix, P::Email)); + continue; + } + mask.enabled = state.is_live(); + mask.created_by = None; + match registry + .write(RegistryWrite::insert(&Object::from(mask.clone()))) + .await? + { + RegistryWriteResult::Success(id) => { + ops::created(data, registry, id, &mask, state).await?; + let created = Mask { + id, + object: mask, + state, + expired: false, + last_message_at: None, + }; + response.created.insert(client_id, to_value(&created, ALL)); + } + err => { + response.not_created.append( + client_id, + SetError::forbidden().with_description(err.to_string()), + ); + } + } + } + + // Updates + for (id, object) in request.unwrap_update().into_valid() { + let Some(mask) = ops::load(data, registry, id) + .await? + .filter(|mask| mask.object.account_id.document_id() == account_id) + else { + response.not_updated.append(id, SetError::not_found()); + continue; + }; + let mut changed = mask.object.clone(); + let requested = match apply(&mut changed, object, false, &mut None) { + Ok(state) => state, + Err(err) => { + response.not_updated.append(id, err); + continue; + } + }; + let new_state = requested.unwrap_or(mask.state); + if !mask.state.can_become(new_state) { + response + .not_updated + .append(id, invalid(P::State, "A mask can't return to pending.")); + continue; + } + changed.enabled = new_state.is_live(); + if changed != mask.object { + let old = Object::from(mask.object.clone()); + match registry + .write(RegistryWrite::update( + id, + &Object::from(changed.clone()), + &old, + )) + .await? + { + RegistryWriteResult::Success(_) => {} + err => { + response + .not_updated + .append(id, SetError::forbidden().with_description(err.to_string())); + continue; + } + } + } + ops::updated(data, registry, &mask, new_state).await?; + response.updated.append(id, None); + } + + // Destroys + for id in request.unwrap_destroy().into_valid() { + let Some(mask) = ops::load(data, registry, id) + .await? + .filter(|mask| mask.object.account_id.document_id() == account_id) + else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + match registry + .write(RegistryWrite::delete(ObjectId::new( + ObjectType::MaskedEmail, + id, + ))) + .await? + { + RegistryWriteResult::Success(_) => { + ops::destroyed(data, registry, id, &mask.object).await?; + response.destroyed.push(id); + } + err => { + response + .not_destroyed + .append(id, SetError::forbidden().with_description(err.to_string())); + } + } + } + + response.new_state = state(server, account_id).await?.into(); + Ok(response) +} diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index ec37693..7869b1f 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -7,4 +7,5 @@ //! JMAP glue for INBUXA's rebuilt features. The features' rules live in //! `crates/features`; this module only speaks JMAP for them. +pub mod fastmail; pub mod masked_email; diff --git a/tests/src/jmap/principal/get.rs b/tests/src/jmap/principal/get.rs index 9deb46b..f8f6665 100644 --- a/tests/src/jmap/principal/get.rs +++ b/tests/src/jmap/principal/get.rs @@ -66,7 +66,9 @@ pub async fn test(test: &TestServer) { "supportsPush": true }, // inbuxa: the fork's own capability (contract C-1, multi-tenancy MT-22) - "urn:inbuxa:jmap": {} + "urn:inbuxa:jmap": {}, + // inbuxa: Fastmail's Masked Email API (masked email) + "https://www.fastmail.com/dev/maskedemail": {} }, "accounts": { john_id: { @@ -248,7 +250,8 @@ pub async fn test(test: &TestServer) { "urn:ietf:params:jmap:mail:share": {}, "urn:stalwart:jmap": {}, // inbuxa: MT-22, the logo that applies to the account - "urn:inbuxa:jmap": { "logo": null } + "urn:inbuxa:jmap": { "logo": null }, + "https://www.fastmail.com/dev/maskedemail": {} } } }, @@ -269,7 +272,8 @@ pub async fn test(test: &TestServer) { "urn:ietf:params:jmap:principals:availability": john_id, "urn:ietf:params:jmap:filenode": john_id, "urn:ietf:params:jmap:mail:share": john_id, - "urn:stalwart:jmap": john_id + "urn:stalwart:jmap": john_id, + "https://www.fastmail.com/dev/maskedemail": john_id }, "username": "jdoe@example.com", "apiUrl": "https://127.0.0.1:8899/jmap/",