Masked email: the fork's subspace and the masked_email module (ME-1, ME-2, ME-3, ME-6a, ME-7, ME-8, ME-13, ME-14, ME-15)
Subspace _ holds the fork's own data, with its own SQL table and RocksDB column family, and is part of backup. The masked_email module keeps each mask's state, last mail and pending deadline beside upstream's record, an index from address to mask with tombstones, and a per-account change log; and generates addresses in the fork's format.
This commit is contained in:
@@ -321,6 +321,7 @@ impl Family {
|
|||||||
SUBSPACE_REGISTRY_IDX,
|
SUBSPACE_REGISTRY_IDX,
|
||||||
SUBSPACE_REGISTRY_PK,
|
SUBSPACE_REGISTRY_PK,
|
||||||
SUBSPACE_DIRECTORY,
|
SUBSPACE_DIRECTORY,
|
||||||
|
store::SUBSPACE_INBUXA, // inbuxa: masked email
|
||||||
],
|
],
|
||||||
Family::Changelog => &[SUBSPACE_LOGS],
|
Family::Changelog => &[SUBSPACE_LOGS],
|
||||||
Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT],
|
Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT],
|
||||||
|
|||||||
@@ -18,4 +18,5 @@
|
|||||||
//! it. It works on registry objects and the store directly, never on
|
//! it. It works on registry objects and the store directly, never on
|
||||||
//! `common::Server`.
|
//! `common::Server`.
|
||||||
|
|
||||||
|
pub mod masked_email;
|
||||||
pub mod tenancy;
|
pub mod tenancy;
|
||||||
|
|||||||
@@ -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::<String>();
|
||||||
|
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<u8> {
|
||||||
|
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<bool> {
|
||||||
|
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<String> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ValueClass> {
|
||||||
|
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<u64>,
|
||||||
|
/// For a pending mask, when it's removed if no mail arrives (ME-8).
|
||||||
|
pub pending_until: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for Record {
|
||||||
|
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||||
|
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<Self> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Self> {
|
||||||
|
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<Option<Record>> {
|
||||||
|
data.get_value::<Record>(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<Option<AddressEntry>> {
|
||||||
|
data.get_value::<AddressEntry>(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<ValueClass> {
|
||||||
|
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<u64> {
|
||||||
|
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<bool> {
|
||||||
|
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(" [email protected] "),
|
||||||
|
"[email protected]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<Mask>> {
|
||||||
|
let Some(object) = registry.object::<MaskedEmail>(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<Vec<Mask>> {
|
||||||
|
let mut masks = Vec::new();
|
||||||
|
for id in registry
|
||||||
|
.query::<Vec<Id>>(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<u64> {
|
||||||
|
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<Option<Mask>> {
|
||||||
|
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::<Vec<Id>>(RegistryQuery::new(ObjectType::MaskedEmail))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
{
|
||||||
|
if let Some(object) = registry.object::<MaskedEmail>(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(|_| ())
|
||||||
|
}
|
||||||
@@ -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<u64> {
|
||||||
|
static RATE: OnceLock<Option<u64>> = OnceLock::new();
|
||||||
|
*RATE.get_or_init(|| parse_rate(std::env::var(CREATE_RATE_VAR).ok().as_deref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_rate(value: Option<&str>) -> Option<u64> {
|
||||||
|
match value.map(str::trim).map(str::parse::<u64>) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,7 @@ impl MysqlStore {
|
|||||||
SUBSPACE_TASK_QUEUE,
|
SUBSPACE_TASK_QUEUE,
|
||||||
SUBSPACE_DELETED_ITEMS,
|
SUBSPACE_DELETED_ITEMS,
|
||||||
SUBSPACE_SPAM_SAMPLES,
|
SUBSPACE_SPAM_SAMPLES,
|
||||||
|
crate::SUBSPACE_INBUXA, // inbuxa: masked email
|
||||||
SUBSPACE_BLOB_LINK,
|
SUBSPACE_BLOB_LINK,
|
||||||
SUBSPACE_IN_MEMORY_VALUE,
|
SUBSPACE_IN_MEMORY_VALUE,
|
||||||
SUBSPACE_PROPERTY,
|
SUBSPACE_PROPERTY,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ impl PostgresStore {
|
|||||||
SUBSPACE_TASK_QUEUE,
|
SUBSPACE_TASK_QUEUE,
|
||||||
SUBSPACE_DELETED_ITEMS,
|
SUBSPACE_DELETED_ITEMS,
|
||||||
SUBSPACE_SPAM_SAMPLES,
|
SUBSPACE_SPAM_SAMPLES,
|
||||||
|
crate::SUBSPACE_INBUXA, // inbuxa: masked email
|
||||||
SUBSPACE_BLOB_LINK,
|
SUBSPACE_BLOB_LINK,
|
||||||
SUBSPACE_IN_MEMORY_VALUE,
|
SUBSPACE_IN_MEMORY_VALUE,
|
||||||
SUBSPACE_PROPERTY,
|
SUBSPACE_PROPERTY,
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ impl RocksDbStore {
|
|||||||
(SUBSPACE_TELEMETRY_METRIC, CfProfile::Scan),
|
(SUBSPACE_TELEMETRY_METRIC, CfProfile::Scan),
|
||||||
(SUBSPACE_SEARCH_INDEX, CfProfile::Scan),
|
(SUBSPACE_SEARCH_INDEX, CfProfile::Scan),
|
||||||
(SUBSPACE_SPAM_SAMPLES, CfProfile::Churn),
|
(SUBSPACE_SPAM_SAMPLES, CfProfile::Churn),
|
||||||
|
(crate::SUBSPACE_INBUXA, CfProfile::PointLookup), // inbuxa: masked email
|
||||||
(SUBSPACE_REGISTRY_IDX, CfProfile::Scan),
|
(SUBSPACE_REGISTRY_IDX, CfProfile::Scan),
|
||||||
(SUBSPACE_REGISTRY_PK, CfProfile::PointLookup),
|
(SUBSPACE_REGISTRY_PK, CfProfile::PointLookup),
|
||||||
(SUBSPACE_DIRECTORY, CfProfile::PointLookup),
|
(SUBSPACE_DIRECTORY, CfProfile::PointLookup),
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ impl SqliteStore {
|
|||||||
SUBSPACE_TASK_QUEUE,
|
SUBSPACE_TASK_QUEUE,
|
||||||
SUBSPACE_DELETED_ITEMS,
|
SUBSPACE_DELETED_ITEMS,
|
||||||
SUBSPACE_SPAM_SAMPLES,
|
SUBSPACE_SPAM_SAMPLES,
|
||||||
|
crate::SUBSPACE_INBUXA, // inbuxa: masked email
|
||||||
SUBSPACE_BLOB_LINK,
|
SUBSPACE_BLOB_LINK,
|
||||||
SUBSPACE_IN_MEMORY_VALUE,
|
SUBSPACE_IN_MEMORY_VALUE,
|
||||||
SUBSPACE_PROPERTY,
|
SUBSPACE_PROPERTY,
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ pub const SUBSPACE_TELEMETRY_METRIC: u8 = b'x';
|
|||||||
pub const SUBSPACE_SEARCH_INDEX: u8 = b'z';
|
pub const SUBSPACE_SEARCH_INDEX: u8 = b'z';
|
||||||
pub const SUBSPACE_DELETED_ITEMS: u8 = b'j';
|
pub const SUBSPACE_DELETED_ITEMS: u8 = b'j';
|
||||||
pub const SUBSPACE_SPAM_SAMPLES: u8 = b'w';
|
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
|
// TODO: Remove in v1.0
|
||||||
pub const LEGACY_SUBSPACE_BITMAP_TEXT: u8 = b'v';
|
pub const LEGACY_SUBSPACE_BITMAP_TEXT: u8 = b'v';
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub async fn store_destroy(store: &Store) {
|
|||||||
SUBSPACE_INDEXES,
|
SUBSPACE_INDEXES,
|
||||||
SUBSPACE_DELETED_ITEMS,
|
SUBSPACE_DELETED_ITEMS,
|
||||||
SUBSPACE_SPAM_SAMPLES,
|
SUBSPACE_SPAM_SAMPLES,
|
||||||
|
store::SUBSPACE_INBUXA, // inbuxa: masked email
|
||||||
SUBSPACE_BLOB_LINK,
|
SUBSPACE_BLOB_LINK,
|
||||||
SUBSPACE_LOGS,
|
SUBSPACE_LOGS,
|
||||||
SUBSPACE_IN_MEMORY_COUNTER,
|
SUBSPACE_IN_MEMORY_COUNTER,
|
||||||
|
|||||||
Reference in New Issue
Block a user