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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user