diff --git a/crates/common/src/manager/backup.rs b/crates/common/src/manager/backup.rs index 6db3c93..e58e516 100644 --- a/crates/common/src/manager/backup.rs +++ b/crates/common/src/manager/backup.rs @@ -321,6 +321,7 @@ impl Family { SUBSPACE_REGISTRY_IDX, SUBSPACE_REGISTRY_PK, SUBSPACE_DIRECTORY, + store::SUBSPACE_INBUXA, // inbuxa: masked email ], Family::Changelog => &[SUBSPACE_LOGS], Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT], diff --git a/crates/features/src/lib.rs b/crates/features/src/lib.rs index b3d56f6..7f3a94e 100644 --- a/crates/features/src/lib.rs +++ b/crates/features/src/lib.rs @@ -18,4 +18,5 @@ //! it. It works on registry objects and the store directly, never on //! `common::Server`. +pub mod masked_email; pub mod tenancy; diff --git a/crates/features/src/masked_email/address.rs b/crates/features/src/masked_email/address.rs new file mode 100644 index 0000000..45dba57 --- /dev/null +++ b/crates/features/src/masked_email/address.rs @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Masked addresses: their format, and making sure none is ever issued twice +//! (ME-13). +//! +//! The local part is `{emailPrefix}_{random}` when a prefix is given, else +//! `{random}`, where `{random}` is 12 characters from `a-z0-9`, about 62 +//! bits. It never contains a `.`, so a fork-issued address can't be mistaken +//! for an upstream one, whose addresses always do. + +use crate::masked_email::data; +use registry::schema::prelude::Property; +use store::{RegistryStore, Store, rand::RngExt as _}; + +/// The characters `{random}` draws from. +const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; +/// The length of `{random}`. +const RANDOM_LEN: usize = 12; +/// The longest prefix a client may ask for. +pub const MAX_PREFIX_LEN: usize = 64; +/// Tries before giving up on finding a free address. At 62 bits a single +/// collision is already unlikely; this only guards against a broken RNG. +const MAX_TRIES: usize = 16; + +/// Whether `prefix` is a valid `emailPrefix`: 1 to 64 characters from +/// `a-z`, `0-9` and `_`. Anything else is refused `invalidProperties` +/// (acceptance test 7). +pub fn is_valid_prefix(prefix: &str) -> bool { + !prefix.is_empty() + && prefix.len() <= MAX_PREFIX_LEN + && prefix + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') +} + +/// A local part in the fork's format, with a fresh random part. +pub fn local_part(prefix: Option<&str>) -> String { + let mut rng = store::rand::rng(); + let random = (0..RANDOM_LEN) + .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char) + .collect::(); + match prefix { + Some(prefix) => format!("{prefix}_{random}"), + None => random, + } +} + +/// The registry's key for an address, as accounts, aliases and lists index +/// theirs. +fn email_key(local_part: &str, domain_id: u32) -> Vec { + let mut key = Vec::with_capacity(local_part.len() + 8); + key.extend_from_slice(local_part.as_bytes()); + key.extend_from_slice(&(domain_id as u64).to_be_bytes()); + key +} + +/// Whether an address is taken by anything the server knows: an account, an +/// alias, a list, a mask, or a mask that was destroyed (a tombstone). +pub async fn is_taken( + data: &Store, + registry: &RegistryStore, + local_part: &str, + domain_id: u32, + domain_name: &str, +) -> trc::Result { + Ok(registry + .primary_key(None, Property::Email, email_key(local_part, domain_id)) + .await? + .is_some() + || data::address(data, &format!("{local_part}@{domain_name}")) + .await? + .is_some()) +} + +/// A new, never-issued address on a domain. +pub async fn generate( + data: &Store, + registry: &RegistryStore, + prefix: Option<&str>, + domain_id: u32, + domain_name: &str, +) -> trc::Result { + for _ in 0..MAX_TRIES { + let local = local_part(prefix); + if !is_taken(data, registry, &local, domain_id, domain_name).await? { + return Ok(format!("{local}@{}", domain_name.to_lowercase())); + } + } + Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("No free masked address found") + .caused_by(trc::location!())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefixes() { + assert!(is_valid_prefix("shop")); + assert!(is_valid_prefix("my_shop_2")); + assert!(!is_valid_prefix("Shop!"), "acceptance test 7"); + assert!(!is_valid_prefix("Shop")); + assert!(!is_valid_prefix("shop.x")); + assert!(!is_valid_prefix("")); + assert!(is_valid_prefix(&"a".repeat(64))); + assert!(!is_valid_prefix(&"a".repeat(65))); + } + + #[test] + fn format() { + let plain = local_part(None); + assert_eq!(plain.len(), RANDOM_LEN); + assert!(plain.bytes().all(|b| ALPHABET.contains(&b))); + let prefixed = local_part(Some("shop")); + assert!(prefixed.starts_with("shop_"), "acceptance test 7"); + assert_eq!(prefixed.len(), "shop_".len() + RANDOM_LEN); + assert!(!prefixed.contains('.'), "never upstream's shape"); + assert_ne!(local_part(None), local_part(None)); + } +} diff --git a/crates/features/src/masked_email/data.rs b/crates/features/src/masked_email/data.rs new file mode 100644 index 0000000..6d42e69 --- /dev/null +++ b/crates/features/src/masked_email/data.rs @@ -0,0 +1,306 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! What the fork keeps beside upstream's `x:MaskedEmail` record, in its own +//! subspace (`store::SUBSPACE_INBUXA`). Every key starts with `M`, so other +//! fork features can share the subspace. After it, one byte names the kind: +//! +//! - `m` + mask id: the mask's `Record` (state, last mail, pending deadline). +//! Missing for masks written before the fork, whose state comes from +//! upstream's `enabled`. +//! - `a` + address: `AddressEntry`, the mask an address belongs to. Kept +//! after the mask is destroyed, as a tombstone, so the address is never +//! issued again (ME-3, ME-13). +//! - `c` + account id + change id: one change to one of the account's masks, +//! for `/changes`. +//! - `i`: present once masks written before the fork are indexed. + +use crate::masked_email::State; +use store::{ + Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass}, +}; +use trc::AddContext; +use types::id::Id; + +const FEATURE: u8 = b'M'; +const KIND_RECORD: u8 = b'm'; +const KIND_ADDRESS: u8 = b'a'; +const KIND_CHANGE: u8 = b'c'; +const KIND_INDEXED: u8 = b'i'; + +fn class(kind: u8, rest: &[u8]) -> ValueClass { + let mut key = Vec::with_capacity(2 + rest.len()); + key.push(FEATURE); + key.push(kind); + key.extend_from_slice(rest); + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +fn key(kind: u8, rest: &[u8]) -> ValueKey { + ValueKey::from(class(kind, rest)) +} + +/// The fork's record of one mask. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Record { + pub state: State, + /// When mail last arrived through the mask, as a Unix timestamp (ME-7). + pub last_message_at: Option, + /// For a pending mask, when it's removed if no mail arrives (ME-8). + pub pending_until: Option, +} + +impl Serialize for Record { + fn serialize(&self) -> trc::Result> { + let mut out = Vec::with_capacity(17); + out.push(self.state as u8); + out.extend_from_slice(&self.last_message_at.unwrap_or(0).to_be_bytes()); + out.extend_from_slice(&self.pending_until.unwrap_or(0).to_be_bytes()); + Ok(out) + } +} + +impl Deserialize for Record { + fn deserialize(bytes: &[u8]) -> trc::Result { + let corrupt = || { + trc::StoreEvent::DataCorruption + .into_err() + .details("Invalid masked email record") + .caused_by(trc::location!()) + }; + if bytes.len() != 17 { + return Err(corrupt()); + } + let state = State::from_u8(bytes[0]).ok_or_else(corrupt)?; + let u64_at = |at: usize| u64::from_be_bytes(bytes[at..at + 8].try_into().unwrap()); + Ok(Record { + state, + last_message_at: Some(u64_at(1)).filter(|v| *v != 0), + pending_until: Some(u64_at(9)).filter(|v| *v != 0), + }) + } +} + +/// The mask an address belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AddressEntry { + pub mask_id: Id, + pub account_id: u32, + /// False once the mask is destroyed: the entry is then a tombstone. + pub live: bool, +} + +impl Serialize for AddressEntry { + fn serialize(&self) -> trc::Result> { + let mut out = Vec::with_capacity(13); + out.extend_from_slice(&self.mask_id.id().to_be_bytes()); + out.extend_from_slice(&self.account_id.to_be_bytes()); + out.push(self.live as u8); + Ok(out) + } +} + +impl Deserialize for AddressEntry { + fn deserialize(bytes: &[u8]) -> trc::Result { + if bytes.len() != 13 { + return Err(trc::StoreEvent::DataCorruption + .into_err() + .details("Invalid masked email address entry") + .caused_by(trc::location!())); + } + Ok(AddressEntry { + mask_id: Id::new(u64::from_be_bytes(bytes[0..8].try_into().unwrap())), + account_id: u32::from_be_bytes(bytes[8..12].try_into().unwrap()), + live: bytes[12] != 0, + }) + } +} + +/// What a change did to a mask, for `/changes`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum Change { + Created = 0, + Updated = 1, + Destroyed = 2, +} + +/// An address as the index keys it: lowercased. +pub fn normalize(address: &str) -> String { + address.trim().to_lowercase() +} + +pub async fn record(data: &Store, mask_id: Id) -> trc::Result> { + data.get_value::(key(KIND_RECORD, &mask_id.id().to_be_bytes())) + .await + .caused_by(trc::location!()) +} + +pub fn set_record(batch: &mut BatchBuilder, mask_id: Id, record: &Record) -> trc::Result<()> { + batch.set( + class(KIND_RECORD, &mask_id.id().to_be_bytes()), + record.serialize()?, + ); + Ok(()) +} + +pub fn clear_record(batch: &mut BatchBuilder, mask_id: Id) { + batch.clear(class(KIND_RECORD, &mask_id.id().to_be_bytes())); +} + +pub async fn address(data: &Store, address: &str) -> trc::Result> { + data.get_value::(key(KIND_ADDRESS, normalize(address).as_bytes())) + .await + .caused_by(trc::location!()) +} + +pub fn set_address( + batch: &mut BatchBuilder, + address: &str, + entry: &AddressEntry, +) -> trc::Result<()> { + batch.set( + class(KIND_ADDRESS, normalize(address).as_bytes()), + entry.serialize()?, + ); + Ok(()) +} + +pub fn log_change( + batch: &mut BatchBuilder, + account_id: u32, + change_id: u64, + mask_id: Id, + change: Change, +) { + let mut rest = Vec::with_capacity(12); + rest.extend_from_slice(&account_id.to_be_bytes()); + rest.extend_from_slice(&change_id.to_be_bytes()); + let mut value = Vec::with_capacity(9); + value.extend_from_slice(&mask_id.id().to_be_bytes()); + value.push(change as u8); + batch.set(class(KIND_CHANGE, &rest), value); +} + +fn change_key(account_id: u32, change_id: u64) -> ValueKey { + let mut rest = Vec::with_capacity(12); + rest.extend_from_slice(&account_id.to_be_bytes()); + rest.extend_from_slice(&change_id.to_be_bytes()); + key(KIND_CHANGE, &rest) +} + +/// The account's changes after `since`, oldest first, and the latest change +/// id (`since` when there are none). +pub async fn changes_since( + data: &Store, + account_id: u32, + since: u64, +) -> trc::Result<(Vec<(Id, Change)>, u64)> { + let mut changes = Vec::new(); + let mut latest = since; + data.iterate( + IterateParams::new( + change_key(account_id, since.saturating_add(1)), + change_key(account_id, u64::MAX), + ) + .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(( + Id::new(u64::from_be_bytes(value[0..8].try_into().unwrap())), + change, + )); + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + Ok((changes, latest)) +} + +/// The account's latest change id, 0 when there's none. +pub async fn latest_change(data: &Store, account_id: u32) -> trc::Result { + let mut latest = 0; + data.iterate( + IterateParams::new(change_key(account_id, 0), change_key(account_id, u64::MAX)) + .descending() + .only_first() + .no_values(), + |key, _| { + if key.len() >= 8 { + latest = u64::from_be_bytes(key[key.len() - 8..].try_into().unwrap()); + } + Ok(false) + }, + ) + .await + .caused_by(trc::location!())?; + Ok(latest) +} + +pub async fn is_indexed(data: &Store) -> trc::Result { + data.key_exists(key(KIND_INDEXED, &[])) + .await + .caused_by(trc::location!()) +} + +pub fn set_indexed(batch: &mut BatchBuilder) { + batch.set(class(KIND_INDEXED, &[]), vec![1]); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_round_trip() { + for record in [ + Record { + state: State::Pending, + last_message_at: None, + pending_until: Some(1_800_000_000), + }, + Record { + state: State::Disabled, + last_message_at: Some(1_700_000_000), + pending_until: None, + }, + ] { + assert_eq!( + Record::deserialize(&record.serialize().unwrap()).unwrap(), + record + ); + } + let entry = AddressEntry { + mask_id: Id::new(123456789), + account_id: 42, + live: false, + }; + assert_eq!( + AddressEntry::deserialize(&entry.serialize().unwrap()).unwrap(), + entry + ); + } + + #[test] + fn addresses_are_lowercased() { + assert_eq!( + normalize(" Shop_ab12@Example.ORG "), + "shop_ab12@example.org" + ); + } +} diff --git a/crates/features/src/masked_email/mod.rs b/crates/features/src/masked_email/mod.rs new file mode 100644 index 0000000..8af86ee --- /dev/null +++ b/crates/features/src/masked_email/mod.rs @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Masked email, built from `docs/spec/features/masked-email.md`. +//! +//! A masked address is a disposable address that delivers to one account. +//! Upstream's `x:MaskedEmail` record stays exactly as upstream writes it. What +//! the fork adds (the one state both APIs share, when mail last arrived, the +//! address index delivery uses, tombstones and the change log) lives beside +//! it in the fork's own subspace (`data`). Requirements are named `ME-n`, +//! after the spec. + +pub mod address; +pub mod data; +pub mod ops; +pub mod policy; +pub mod state; + +pub use state::State; diff --git a/crates/features/src/masked_email/ops.rs b/crates/features/src/masked_email/ops.rs new file mode 100644 index 0000000..c6fd60f --- /dev/null +++ b/crates/features/src/masked_email/ops.rs @@ -0,0 +1,334 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Operations on masks that combine upstream's record with the fork's. + +use crate::masked_email::{ + State, + data::{self, AddressEntry, Change, Record}, +}; +use registry::{ + schema::{prelude::ObjectType, structs::MaskedEmail}, + types::id::ObjectId, +}; +use store::{ + RegistryStore, Store, + registry::{ + RegistryQuery, + write::{RegistryWrite, RegistryWriteResult}, + }, + write::{BatchBuilder, now}, +}; +use trc::AddContext; +use types::id::Id; + +/// How long a pending mask waits for its first message (ME-8). +pub const PENDING_FOR_SECS: u64 = 24 * 60 * 60; + +/// A mask as both APIs see it. +#[derive(Debug, Clone)] +pub struct Mask { + pub id: Id, + pub object: MaskedEmail, + pub state: State, + pub expired: bool, + pub last_message_at: Option, +} + +impl Mask { + /// Whether mail to it is accepted (ME-4, ME-6). + pub fn accepts_mail(&self) -> bool { + self.state.is_live() && !self.expired + } + + /// Whether it counts against `maxMaskedAddresses` (ME-14). + pub fn is_counted(&self) -> bool { + self.accepts_mail() + } +} + +fn is_expired(object: &MaskedEmail, now: u64) -> bool { + object + .expires_at + .is_some_and(|expires| expires.timestamp() <= now as i64) +} + +/// Reads a mask, removing it first if it's pending and past its deadline +/// (ME-8). `None` if it doesn't exist or was just removed. +pub async fn load(data: &Store, registry: &RegistryStore, id: Id) -> trc::Result> { + let Some(object) = registry.object::(id).await? else { + return Ok(None); + }; + let record = data::record(data, id).await?; + let state = record + .map(|r| r.state) + .unwrap_or_else(|| State::from_upstream(object.enabled)); + let now = now(); + + if state == State::Pending + && record + .and_then(|r| r.pending_until) + .is_some_and(|until| until <= now) + { + destroy(data, registry, id, &object).await?; + return Ok(None); + } + + Ok(Some(Mask { + id, + expired: is_expired(&object, now), + state, + last_message_at: record.and_then(|r| r.last_message_at), + object, + })) +} + +/// Every mask an account owns, pending ones past their deadline removed. +pub async fn of_account( + data: &Store, + registry: &RegistryStore, + account_id: u32, +) -> trc::Result> { + let mut masks = Vec::new(); + for id in registry + .query::>(RegistryQuery::new(ObjectType::MaskedEmail).with_account(account_id)) + .await + .caused_by(trc::location!())? + { + if let Some(mask) = load(data, registry, id).await? { + masks.push(mask); + } + } + Ok(masks) +} + +/// How many masks count against the account's limit (ME-14). +pub async fn live_count( + data: &Store, + registry: &RegistryStore, + account_id: u32, +) -> trc::Result { + Ok(of_account(data, registry, account_id) + .await? + .iter() + .filter(|mask| mask.is_counted()) + .count() as u64) +} + +/// The mask an address reaches, if mail to it is accepted (ME-4). Masks +/// written before the fork are indexed the first time this runs. +pub async fn resolve( + data: &Store, + registry: &RegistryStore, + address: &str, +) -> trc::Result> { + ensure_indexed(data, registry).await?; + let Some(entry) = data::address(data, address).await? else { + return Ok(None); + }; + if !entry.live { + return Ok(None); + } + match load(data, registry, entry.mask_id).await? { + Some(mask) if mask.accepts_mail() => Ok(Some(mask)), + Some(_) => Ok(None), + None => { + // Removed without the fork seeing it, e.g. with its account + let mut batch = BatchBuilder::new(); + data::set_address( + &mut batch, + address, + &AddressEntry { + live: false, + ..entry + }, + )?; + data::clear_record(&mut batch, entry.mask_id); + data.write(batch.build_all()).await?; + Ok(None) + } + } +} + +/// Mail arrived through a mask: `lastMessageAt` moves, and a pending mask +/// becomes enabled (ME-7). +pub async fn delivered(data: &Store, registry: &RegistryStore, mask: &Mask) -> trc::Result<()> { + let state = if mask.state == State::Pending { + State::Enabled + } else { + mask.state + }; + let mut batch = BatchBuilder::new(); + data::set_record( + &mut batch, + mask.id, + &Record { + state, + last_message_at: Some(now()), + pending_until: None, + }, + )?; + data::log_change( + &mut batch, + mask.object.account_id.document_id(), + registry.assign_id(), + mask.id, + Change::Updated, + ); + data.write(batch.build_all()).await.map(|_| ()) +} + +/// Records a new mask: its state, its address and the change (ME-7a, ME-8). +pub async fn created( + data: &Store, + registry: &RegistryStore, + id: Id, + object: &MaskedEmail, + state: State, +) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + data::set_record( + &mut batch, + id, + &Record { + state, + last_message_at: None, + pending_until: (state == State::Pending).then(|| now() + PENDING_FOR_SECS), + }, + )?; + data::set_address( + &mut batch, + &object.email, + &AddressEntry { + mask_id: id, + account_id: object.account_id.document_id(), + live: true, + }, + )?; + data::log_change( + &mut batch, + object.account_id.document_id(), + registry.assign_id(), + id, + Change::Created, + ); + data.write(batch.build_all()).await.map(|_| ()) +} + +/// Records a changed mask, and its new state if it has one (ME-1, ME-2). +pub async fn updated( + data: &Store, + registry: &RegistryStore, + mask: &Mask, + state: State, +) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + data::set_record( + &mut batch, + mask.id, + &Record { + state, + last_message_at: mask.last_message_at, + pending_until: if state == State::Pending { + data::record(data, mask.id) + .await? + .and_then(|r| r.pending_until) + } else { + None + }, + }, + )?; + data::log_change( + &mut batch, + mask.object.account_id.document_id(), + registry.assign_id(), + mask.id, + Change::Updated, + ); + data.write(batch.build_all()).await.map(|_| ()) +} + +/// Records a destroyed mask: its address becomes a tombstone (ME-3). +pub async fn destroyed( + data: &Store, + registry: &RegistryStore, + id: Id, + object: &MaskedEmail, +) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + data::clear_record(&mut batch, id); + data::set_address( + &mut batch, + &object.email, + &AddressEntry { + mask_id: id, + account_id: object.account_id.document_id(), + live: false, + }, + )?; + data::log_change( + &mut batch, + object.account_id.document_id(), + registry.assign_id(), + id, + Change::Destroyed, + ); + data.write(batch.build_all()).await.map(|_| ()) +} + +/// Removes a mask and tombstones its address (ME-8). +async fn destroy( + data: &Store, + registry: &RegistryStore, + id: Id, + object: &MaskedEmail, +) -> trc::Result<()> { + match registry + .write(RegistryWrite::delete(ObjectId::new( + ObjectType::MaskedEmail, + id, + ))) + .await? + { + RegistryWriteResult::Success(_) | RegistryWriteResult::NotFound { .. } => { + destroyed(data, registry, id, object).await + } + _ => Ok(()), + } +} + +/// Indexes the addresses of masks written before the fork, once (the key +/// fact for compatibility: delivery finds a mask by its stored address). +pub async fn ensure_indexed(data: &Store, registry: &RegistryStore) -> trc::Result<()> { + if data::is_indexed(data).await? { + return Ok(()); + } + let mut batch = BatchBuilder::new(); + for id in registry + .query::>(RegistryQuery::new(ObjectType::MaskedEmail)) + .await + .caused_by(trc::location!())? + { + if let Some(object) = registry.object::(id).await? + && data::address(data, &object.email).await?.is_none() + { + data::set_address( + &mut batch, + &object.email, + &AddressEntry { + mask_id: id, + account_id: object.account_id.document_id(), + live: true, + }, + )?; + } + if batch.is_large_batch() { + data.write(std::mem::take(&mut batch).build_all()).await?; + } + } + data::set_indexed(&mut batch); + data.write(batch.build_all()).await.map(|_| ()) +} diff --git a/crates/features/src/masked_email/policy.rs b/crates/features/src/masked_email/policy.rs new file mode 100644 index 0000000..68ca02b --- /dev/null +++ b/crates/features/src/masked_email/policy.rs @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Limits on creating masks (ME-14, ME-15). + +use std::sync::OnceLock; + +/// The in-memory store prefix for the create-rate counters. Upstream's +/// prefixes are small numbers counting up from 0; the fork's start at 0xE0. +pub const KV_CREATE_RATE: u8 = 0xE0; + +/// Creates allowed per account per hour when nothing else is configured +/// (ME-15). +pub const DEFAULT_CREATES_PER_HOUR: u64 = 50; + +/// The environment variable that sets the create rate, until the fork has a +/// settings object of its own. 0 means no limit. +pub const CREATE_RATE_VAR: &str = "INBUXA_MASKED_EMAIL_CREATE_RATE"; + +/// Creates allowed per account per hour, `None` for no limit. Read once. +pub fn creates_per_hour() -> Option { + static RATE: OnceLock> = OnceLock::new(); + *RATE.get_or_init(|| parse_rate(std::env::var(CREATE_RATE_VAR).ok().as_deref())) +} + +fn parse_rate(value: Option<&str>) -> Option { + match value.map(str::trim).map(str::parse::) { + None => Some(DEFAULT_CREATES_PER_HOUR), + Some(Ok(0)) => None, + Some(Ok(rate)) => Some(rate), + Some(Err(_)) => { + trc::event!( + Server(trc::ServerEvent::Startup), + Details = concat!( + "INBUXA_MASKED_EMAIL_CREATE_RATE isn't a whole number; ", + "using the default of 50 an hour" + ), + ); + Some(DEFAULT_CREATES_PER_HOUR) + } + } +} + +/// Whether an account may create another mask, given its limit (`u32::MAX` +/// or none for unlimited) and how many live masks it has. 0 turns creation +/// off (ME-14). +pub fn within_limit(limit: u32, live: u64) -> bool { + limit == u32::MAX || live < limit as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rate_setting() { + assert_eq!(parse_rate(None), Some(50)); + assert_eq!(parse_rate(Some("0")), None); + assert_eq!(parse_rate(Some(" 10 ")), Some(10)); + assert_eq!(parse_rate(Some("lots")), Some(50)); + } + + #[test] + fn limits() { + // Acceptance test 9: a limit of 2 refuses the third, 0 refuses any + assert!(within_limit(2, 1)); + assert!(!within_limit(2, 2)); + assert!(!within_limit(0, 0)); + assert!(within_limit(u32::MAX, 1_000_000)); + } +} diff --git a/crates/features/src/masked_email/state.rs b/crates/features/src/masked_email/state.rs new file mode 100644 index 0000000..e481d01 --- /dev/null +++ b/crates/features/src/masked_email/state.rs @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! One state per mask, shown by each API in its own terms (ME-1, ME-2, +//! ME-6a). + +/// A mask's state. The order of the discriminants is stored, so it never +/// changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum State { + /// Created by a password manager, not yet used: delivered, and becomes + /// `Enabled` on the first message (ME-7). Removed after 24 hours + /// without mail (ME-8). + Pending = 0, + /// Delivered normally. + Enabled = 1, + /// Accepted, and filed straight to Trash (ME-5). + Disabled = 2, + /// Refused (ME-6). + Deleted = 3, +} + +impl State { + pub fn from_u8(value: u8) -> Option { + match value { + 0 => Some(State::Pending), + 1 => Some(State::Enabled), + 2 => Some(State::Disabled), + 3 => Some(State::Deleted), + _ => None, + } + } + + /// The state of a mask written before the fork, from upstream's + /// `enabled` flag. + pub fn from_upstream(enabled: bool) -> Self { + if enabled { + State::Enabled + } else { + State::Deleted + } + } + + /// Whether mail to the mask is accepted (ME-4). + pub fn is_live(self) -> bool { + self != State::Deleted + } + + /// The Fastmail API's name for the state. + pub fn as_fastmail(self) -> &'static str { + match self { + State::Pending => "pending", + State::Enabled => "enabled", + State::Disabled => "disabled", + State::Deleted => "deleted", + } + } + + pub fn parse_fastmail(value: &str) -> Option { + match value { + "pending" => Some(State::Pending), + "enabled" => Some(State::Enabled), + "disabled" => Some(State::Disabled), + "deleted" => Some(State::Deleted), + _ => None, + } + } + + /// Upstream's `enabled`: whether mail is accepted. `disabled` masks + /// accept mail (into Trash), so they read `true`. An expired mask reads + /// `false` whatever its state (ME-6a). + pub fn as_upstream_enabled(self, expired: bool) -> bool { + self.is_live() && !expired + } + + /// The state an upstream `enabled` write sets (ME-2). + pub fn from_upstream_write(enabled: bool) -> Self { + State::from_upstream(enabled) + } + + /// Whether a Fastmail write may move a mask from `self` to `to` (ME-1): + /// `pending` can't be set once a mask has left it. + pub fn can_become(self, to: State) -> bool { + to != State::Pending || self == State::Pending + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_apis_read_one_state() { + // The table in "One state, two APIs" + for (state, fastmail, enabled) in [ + (State::Pending, "pending", true), + (State::Enabled, "enabled", true), + (State::Disabled, "disabled", true), + (State::Deleted, "deleted", false), + ] { + assert_eq!(state.as_fastmail(), fastmail); + assert_eq!(State::parse_fastmail(fastmail), Some(state)); + assert_eq!(state.as_upstream_enabled(false), enabled); + assert!(!state.as_upstream_enabled(true), "expired reads false"); + assert_eq!(State::from_u8(state as u8), Some(state)); + } + } + + #[test] + fn upstream_writes() { + // ME-2: enabled false is deleted, enabled true is enabled + assert_eq!(State::from_upstream_write(false), State::Deleted); + assert_eq!(State::from_upstream_write(true), State::Enabled); + } + + #[test] + fn pending_is_one_way() { + // ME-1 + assert!(State::Pending.can_become(State::Pending)); + assert!(State::Pending.can_become(State::Enabled)); + assert!(!State::Enabled.can_become(State::Pending)); + assert!(!State::Deleted.can_become(State::Pending)); + assert!(State::Deleted.can_become(State::Enabled)); + } +} diff --git a/crates/store/src/backend/mysql/main.rs b/crates/store/src/backend/mysql/main.rs index d7ab0d3..398075f 100644 --- a/crates/store/src/backend/mysql/main.rs +++ b/crates/store/src/backend/mysql/main.rs @@ -81,6 +81,7 @@ impl MysqlStore { SUBSPACE_TASK_QUEUE, SUBSPACE_DELETED_ITEMS, SUBSPACE_SPAM_SAMPLES, + crate::SUBSPACE_INBUXA, // inbuxa: masked email SUBSPACE_BLOB_LINK, SUBSPACE_IN_MEMORY_VALUE, SUBSPACE_PROPERTY, diff --git a/crates/store/src/backend/postgres/main.rs b/crates/store/src/backend/postgres/main.rs index c33f978..446b348 100644 --- a/crates/store/src/backend/postgres/main.rs +++ b/crates/store/src/backend/postgres/main.rs @@ -97,6 +97,7 @@ impl PostgresStore { SUBSPACE_TASK_QUEUE, SUBSPACE_DELETED_ITEMS, SUBSPACE_SPAM_SAMPLES, + crate::SUBSPACE_INBUXA, // inbuxa: masked email SUBSPACE_BLOB_LINK, SUBSPACE_IN_MEMORY_VALUE, SUBSPACE_PROPERTY, diff --git a/crates/store/src/backend/rocksdb/main.rs b/crates/store/src/backend/rocksdb/main.rs index cfe726b..e943cee 100644 --- a/crates/store/src/backend/rocksdb/main.rs +++ b/crates/store/src/backend/rocksdb/main.rs @@ -94,6 +94,7 @@ impl RocksDbStore { (SUBSPACE_TELEMETRY_METRIC, CfProfile::Scan), (SUBSPACE_SEARCH_INDEX, CfProfile::Scan), (SUBSPACE_SPAM_SAMPLES, CfProfile::Churn), + (crate::SUBSPACE_INBUXA, CfProfile::PointLookup), // inbuxa: masked email (SUBSPACE_REGISTRY_IDX, CfProfile::Scan), (SUBSPACE_REGISTRY_PK, CfProfile::PointLookup), (SUBSPACE_DIRECTORY, CfProfile::PointLookup), diff --git a/crates/store/src/backend/sqlite/main.rs b/crates/store/src/backend/sqlite/main.rs index 4631e77..1baadda 100644 --- a/crates/store/src/backend/sqlite/main.rs +++ b/crates/store/src/backend/sqlite/main.rs @@ -66,6 +66,7 @@ impl SqliteStore { SUBSPACE_TASK_QUEUE, SUBSPACE_DELETED_ITEMS, SUBSPACE_SPAM_SAMPLES, + crate::SUBSPACE_INBUXA, // inbuxa: masked email SUBSPACE_BLOB_LINK, SUBSPACE_IN_MEMORY_VALUE, SUBSPACE_PROPERTY, diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index ae776ba..606c0fd 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -124,6 +124,9 @@ pub const SUBSPACE_TELEMETRY_METRIC: u8 = b'x'; pub const SUBSPACE_SEARCH_INDEX: u8 = b'z'; pub const SUBSPACE_DELETED_ITEMS: u8 = b'j'; pub const SUBSPACE_SPAM_SAMPLES: u8 = b'w'; +// inbuxa: the fork's own data (masked email ME-*). Not a letter: SQL tables are +// named after the byte, unquoted, and upstream uses every lowercase letter. +pub const SUBSPACE_INBUXA: u8 = b'_'; // TODO: Remove in v1.0 pub const LEGACY_SUBSPACE_BITMAP_TEXT: u8 = b'v'; diff --git a/tests/src/utils/cleanup.rs b/tests/src/utils/cleanup.rs index 723fae3..9ef7c8e 100644 --- a/tests/src/utils/cleanup.rs +++ b/tests/src/utils/cleanup.rs @@ -25,6 +25,7 @@ pub async fn store_destroy(store: &Store) { SUBSPACE_INDEXES, SUBSPACE_DELETED_ITEMS, SUBSPACE_SPAM_SAMPLES, + store::SUBSPACE_INBUXA, // inbuxa: masked email SUBSPACE_BLOB_LINK, SUBSPACE_LOGS, SUBSPACE_IN_MEMORY_COUNTER,