Files
inbuxa-server/crates/features/src/masked_email/address.rs
T
jcoffey-dev aaca8fe537 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.
2026-09-18 16:17:47 -07:00

127 lines
4.2 KiB
Rust

/*
* 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));
}
}