Masked email acceptance tests 1 to 11, with ME-9, ME-10, ME-11 and /changes (ME-1 to ME-19)

tests/src/system/masked_email.rs runs from system_tests and alone as
masked_email_tests. Fastmail's MaskedEmail/set updates from the stored
object, so the registry's revision check holds.
This commit is contained in:
2026-09-18 16:37:12 -07:00
parent d04aafd3d7
commit f07c00fffb
5 changed files with 644 additions and 8 deletions
+1
View File
@@ -45,6 +45,7 @@ pop3 = { path = "../crates/pop3", features = ["test_mode"] }
smtp = { path = "../crates/smtp", features = ["test_mode"] }
common = { path = "../crates/common", features = ["test_mode"] }
registry = { path = "../crates/registry" }
inbuxa-features = { path = "../crates/features" }
email = { path = "../crates/email", features = ["test_mode"] }
spam-filter = { path = "../crates/spam-filter", features = ["test_mode"] }
trc = { path = "../crates/trc", features = [] }
+628
View File
@@ -0,0 +1,628 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Masked email acceptance tests 1 to 11, from
//! `docs/spec/features/masked-email.md`, with the additions the spec makes
//! (ME-9, ME-10, ME-11, `/changes`). Each check names the test number or the
//! requirement it covers. Test 12 (compat) needs a copy of INBUXA's data.
use crate::utils::{
account::Account,
jmap::{JmapResponse, JmapUtils},
server::{TestServer, TestServerBuilder},
smtp::SmtpConnection,
};
use email::mailbox::{INBOX_ID, TRASH_ID};
use inbuxa_features::masked_email::data;
use jmap_proto::error::set::SetErrorType;
use registry::schema::{
enums::StorageQuota,
prelude::{ObjectType, Property},
structs::{Expression, Imap, MaskedEmail, MtaStageAuth, MtaStageRcpt, UserRoles},
};
use serde_json::{Value, json};
use store::write::BatchBuilder;
use types::id::Id;
use utils::map::vec_map::VecMap;
const FASTMAIL: &str = "https://www.fastmail.com/dev/maskedemail";
const SECRET_ALICE: &str = "alice keeps her masks close";
const SECRET_BOB: &str = "bob would like to see them";
const SECRET_RATE: &str = "rate tester makes many masks";
const SECRET_T: &str = "tenant people on the mask test";
pub async fn test(test: &mut TestServer) {
println!("Running masked email tests...");
let admin = test.account("[email protected]");
admin.find_or_create_domain("example.org").await;
admin.find_or_create_domain("mask-alias.example.org").await;
admin.find_or_create_domain("not-linked.example.org").await;
let alice = admin
.create_user_account(
"[email protected]",
SECRET_ALICE,
"Alice",
&["[email protected]"],
vec![],
)
.await;
let bob = admin
.create_user_account("[email protected]", SECRET_BOB, "Bob", &[], vec![])
.await;
let mut lmtp = SmtpConnection::connect().await;
// Refused recipients answer at once, so the refusals below are quick
admin
.registry_update_setting(
MtaStageRcpt {
wait_on_fail: Expression {
else_: "1ms".into(),
..Default::default()
},
..Default::default()
},
&[Property::WaitOnFail],
)
.await;
admin.reload_settings().await;
// Acceptance test 1: a Fastmail create with no arguments is pending, on
// the account's primary domain, and reads enabled in x: (ME-7a)
let pending = alice.fastmail_create(json!({})).await;
let pending_email = pending["email"].as_str().unwrap().to_string();
let pending_id = pending.object_id();
assert!(pending_email.ends_with("@example.org"), "test 1: {pending}");
assert!(
!pending_email.split('@').next().unwrap().contains('.'),
"ME-13: never upstream's shape"
);
assert_eq!(pending["state"], "pending", "test 1");
assert!(alice.x_mask(pending_id).await.enabled, "test 1: x: enabled");
// Through x: it starts enabled in both
let x_id = alice.x_create(json!({})).await;
assert_eq!(
alice.fastmail_state(x_id).await,
"enabled",
"test 1: x: create"
);
// Acceptance test 2: mail to a pending mask is delivered, the mask turns
// enabled and lastMessageAt is set (ME-7). It names the mask (ME-9).
let inbox_before = alice.count_in(INBOX_ID).await;
lmtp.ingest(
"[email protected]",
&[&pending_email],
&message("to a pending mask"),
)
.await;
alice.wait_for_count(INBOX_ID, inbox_before + 1).await;
let mask = alice.fastmail_get(pending_id).await;
assert_eq!(mask["state"], "enabled", "test 2");
assert!(mask["lastMessageAt"].is_string(), "test 2: {mask}");
assert_eq!(
alice.latest_header("X-Masked-Email").await.trim(),
pending_email,
"ME-9"
);
// ME-10: sub-addressing on a mask
let (local, domain) = pending_email.split_once('@').unwrap();
lmtp.ingest(
"[email protected]",
&[&format!("{local}+news@{domain}")],
&message("sub-addressed"),
)
.await;
alice.wait_for_count(INBOX_ID, inbox_before + 2).await;
// Acceptance test 3: a pending mask with no mail for 24 hours is removed
// and tombstoned (ME-8). Its deadline is moved into the past here.
let stale = alice.fastmail_create(json!({})).await;
let stale_id = stale.object_id();
let stale_email = stale["email"].as_str().unwrap().to_string();
test.expire_pending(stale_id).await;
assert!(
alice.fastmail_get_raw(stale_id).await["list"]
.as_array()
.unwrap()
.is_empty(),
"test 3"
);
assert_eq!(
test.tombstone(&stale_email).await,
Some(false),
"test 3: tombstoned"
);
// Acceptance test 4: disabled mail is accepted and lands in Trash (ME-5)
alice
.fastmail_update(pending_id, json!({"state": "disabled"}))
.await;
let trash_before = alice.count_in(TRASH_ID).await;
lmtp.ingest(
"[email protected]",
&[&pending_email],
&message("to a disabled mask"),
)
.await;
alice.wait_for_count(TRASH_ID, trash_before + 1).await;
assert!(
alice.x_mask(pending_id).await.enabled,
"disabled reads enabled in x:"
);
// Acceptance test 5: deleted, through either API, refuses mail at RCPT TO
// (ME-6), and both APIs agree (ME-2)
alice
.fastmail_update(pending_id, json!({"state": "deleted"}))
.await;
assert!(!alice.x_mask(pending_id).await.enabled, "test 5");
refused(&pending_email).await;
alice.x_update(pending_id, json!({"enabled": true})).await;
assert_eq!(alice.fastmail_state(pending_id).await, "enabled", "test 5");
alice.x_update(pending_id, json!({"enabled": false})).await;
assert_eq!(alice.fastmail_state(pending_id).await, "deleted", "test 5");
refused(&pending_email).await;
// ME-1: pending can't come back
alice
.fastmail_update_err(pending_id, json!({"state": "pending"}))
.await;
// Acceptance test 6: a destroyed mask is refused as unknown, and its
// address stays reserved (ME-3, ME-13)
alice.fastmail_destroy(pending_id).await;
refused(&pending_email).await;
assert_eq!(test.tombstone(&pending_email).await, Some(false), "test 6");
// Acceptance test 7: prefixes
let shop = alice
.x_get_email(alice.x_create(json!({"emailPrefix": "shop"})).await)
.await;
assert!(shop.starts_with("shop_"), "test 7: {shop}");
alice
.x_create_err(json!({"emailPrefix": "Shop!"}))
.await
.assert_type(SetErrorType::InvalidProperties);
// Acceptance test 8: domains (ME-12)
let aliased = alice
.x_get_email(
alice
.x_create(json!({"emailDomain": "mask-alias.example.org"}))
.await,
)
.await;
assert!(
aliased.ends_with("@mask-alias.example.org"),
"ME-12: {aliased}"
);
alice
.x_create_err(json!({"emailDomain": "not-linked.example.org"}))
.await
.assert_type(SetErrorType::Forbidden)
.assert_properties(&["emailDomain"]);
// ME-11: an owner sends as its mask; nobody else can
let identity = alice
.jmap_method_call(
"Identity/set",
json!({"accountId": alice.id_string(), "create": {"i": {"email": shop, "name": "Shop"}}}),
)
.await;
assert!(
identity
.0
.pointer("/methodResponses/0/1/created/i")
.is_some(),
"ME-11: {identity:?}"
);
let identity = bob
.jmap_method_call(
"Identity/set",
json!({"accountId": bob.id_string(), "create": {"i": {"email": shop, "name": "Shop"}}}),
)
.await;
assert!(
identity
.0
.pointer("/methodResponses/0/1/notCreated/i")
.is_some(),
"ME-11"
);
// Acceptance test 11: who reaches whose masks (ME-18, ME-19)
assert_eq!(
bob.x_list_for(&alice)
.await
.method_response()
.text_field("type"),
"forbidden",
"test 11: another user"
);
assert!(
admin.x_list_for(&alice).await.method_response()["list"]
.as_array()
.is_some_and(|list| !list.is_empty()),
"test 11: server admin"
);
let t_id = admin
.registry_create_object(registry::schema::structs::Tenant {
name: "Mask tenant".to_string(),
..Default::default()
})
.await;
let t_domain = admin
.registry_create_object(registry::schema::structs::Domain {
name: "mask-tenant.example.org".to_string(),
is_enabled: true,
member_tenant_id: Some(t_id),
..Default::default()
})
.await;
test.wait_for_tasks_skip_failures().await;
let t_admin = admin
.create_user_account(
"[email protected]",
SECRET_T,
"T admin",
&[],
vec![],
)
.await;
admin
.registry_update_object(
ObjectType::Account,
t_admin.id(),
json!({ Property::Roles: UserRoles::Admin }),
)
.await;
let t_user = admin
.create_user_account(
"[email protected]",
SECRET_T,
"T user",
&[],
vec![],
)
.await;
t_user.x_create(json!({})).await;
assert!(
t_admin.x_list_for(&t_user).await.method_response()["list"]
.as_array()
.is_some_and(|list| list.len() == 1),
"test 11: tenant admin, own tenant"
);
assert_eq!(
t_admin
.x_list_for(&alice)
.await
.method_response()
.text_field("type"),
"forbidden",
"test 11: tenant admin, other accounts"
);
// x:MaskedEmail/changes (a fork addition)
let since = alice.x_state().await;
let new_id = alice.x_create(json!({})).await;
let changes = alice
.jmap_method_call(
"x:MaskedEmail/changes",
json!({"accountId": alice.id_string(), "sinceState": since}),
)
.await;
assert_eq!(
changes.method_response()["created"],
json!([new_id.to_string()]),
"/changes: {changes:?}"
);
// Acceptance test 9: maxMaskedAddresses (ME-14)
admin
.registry_update_object(
ObjectType::Account,
bob.id(),
json!({ Property::Quotas: VecMap::from_iter([(StorageQuota::MaxMaskedAddresses, 2u64)]) }),
)
.await;
bob.x_create(json!({})).await;
bob.x_create(json!({})).await;
bob.x_create_err(json!({}))
.await
.assert_type(SetErrorType::OverQuota);
admin
.registry_update_object(
ObjectType::Account,
bob.id(),
json!({ Property::Quotas: VecMap::from_iter([(StorageQuota::MaxMaskedAddresses, 0u64)]) }),
)
.await;
bob.x_create_err(json!({}))
.await
.assert_type(SetErrorType::OverQuota);
// Acceptance test 10: the 51st create in an hour is rate-limited (ME-15)
let rate = admin
.create_user_account("[email protected]", SECRET_RATE, "Rate", &[], vec![])
.await;
// Above the server's default limit, so only the rate can stop it
admin
.registry_update_object(
ObjectType::Account,
rate.id(),
json!({ Property::Quotas: VecMap::from_iter([(StorageQuota::MaxMaskedAddresses, 1000u64)]) }),
)
.await;
for n in 0..50 {
let response = rate.fastmail_set(json!({"create": {"n": {}}})).await;
assert!(
response
.0
.pointer("/methodResponses/0/1/created/n")
.is_some(),
"create {n}: {response:?}"
);
}
let response = rate.fastmail_set(json!({"create": {"n": {}}})).await;
assert_eq!(
response
.0
.pointer("/methodResponses/0/1/notCreated/n/type")
.and_then(|v| v.as_str()),
Some("rateLimit"),
"test 10: {response:?}"
);
// Clean up
admin
.registry_update_setting(MtaStageRcpt::default(), &[Property::WaitOnFail])
.await;
admin.reload_settings().await;
for account in [alice, bob, rate, t_user, t_admin] {
admin.destroy_account(account).await;
}
test.wait_for_tasks().await;
admin.registry_destroy(ObjectType::Domain, [t_domain]).await;
admin.registry_destroy(ObjectType::Tenant, [t_id]).await;
}
/// Runs the masked email tests alone:
/// `cargo test -p tests masked_email_tests -- --ignored`.
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn masked_email_tests() {
let mut test = TestServerBuilder::new("masked_email_tests")
.await
.with_default_listeners()
.await
.with_object(Imap {
allow_plain_text_auth: true,
..Default::default()
})
.await
.with_object(MtaStageAuth {
require: Expression {
else_: "false".to_string(),
..Default::default()
},
..Default::default()
})
.await
.build()
.await;
let admin = test.create_admin_account("[email protected]").await;
test.insert_account(admin);
self::test(&mut test).await;
if test.is_reset() {
test.temp_dir.delete();
}
}
fn message(subject: &str) -> String {
format!(
"From: [email protected]\r\nTo: [email protected]\r\nSubject: {subject}\r\n\r\nHello.\r\n"
)
}
impl TestServer {
async fn expire_pending(&self, id: Id) {
let data_store = &self.server.core.storage.data;
let mut record = data::record(data_store, id).await.unwrap().unwrap();
record.pending_until = Some(1);
let mut batch = BatchBuilder::new();
data::set_record(&mut batch, id, &record).unwrap();
data_store.write(batch.build_all()).await.unwrap();
}
/// `Some(live)` when the address is in the fork's index.
async fn tombstone(&self, address: &str) -> Option<bool> {
data::address(&self.server.core.storage.data, address)
.await
.unwrap()
.map(|entry| entry.live)
}
}
/// ME-6: refused at RCPT TO with upstream's reply, on a fresh connection.
async fn refused(address: &str) {
let mut lmtp = SmtpConnection::connect().await;
lmtp.mail_from("[email protected]", 2).await;
let reply = lmtp.rcpt_to(address, 5).await;
assert!(
reply.iter().any(|line| line.starts_with("550 5.1.2")),
"ME-6: {reply:?}"
);
}
impl Account {
async fn fastmail_set(&self, args: Value) -> JmapResponse {
let mut args = args;
args["accountId"] = json!(self.id_string());
self.jmap_request(
&["urn:ietf:params:jmap:core", FASTMAIL],
json!([["MaskedEmail/set", args, "0"]]),
)
.await
}
async fn fastmail_create(&self, object: Value) -> Value {
let response = self.fastmail_set(json!({"create": {"n": object}})).await;
response
.0
.pointer("/methodResponses/0/1/created/n")
.unwrap_or_else(|| panic!("not created: {response:?}"))
.clone()
}
async fn fastmail_update(&self, id: Id, object: Value) {
let response = self
.fastmail_set(json!({"update": {id.to_string(): object}}))
.await;
assert!(
response
.0
.pointer(&format!("/methodResponses/0/1/updated/{id}"))
.is_some(),
"not updated: {response:?}"
);
}
async fn fastmail_update_err(&self, id: Id, object: Value) {
let response = self
.fastmail_set(json!({"update": {id.to_string(): object}}))
.await;
assert!(
response
.0
.pointer(&format!("/methodResponses/0/1/notUpdated/{id}"))
.is_some(),
"updated: {response:?}"
);
}
async fn fastmail_destroy(&self, id: Id) {
let response = self
.fastmail_set(json!({"destroy": [id.to_string()]}))
.await;
assert_eq!(
response.method_response()["destroyed"],
json!([id.to_string()]),
"{response:?}"
);
}
async fn fastmail_get_raw(&self, id: Id) -> Value {
self.jmap_request(
&["urn:ietf:params:jmap:core", FASTMAIL],
json!([["MaskedEmail/get", {"accountId": self.id_string(), "ids": [id.to_string()]}, "0"]]),
)
.await
.method_response()
.clone()
}
async fn fastmail_get(&self, id: Id) -> Value {
let response = self.fastmail_get_raw(id).await;
response["list"][0].clone()
}
async fn fastmail_state(&self, id: Id) -> String {
self.fastmail_get(id).await["state"]
.as_str()
.unwrap_or_default()
.to_string()
}
async fn x_create(&self, object: Value) -> Id {
self.registry_create_many(ObjectType::MaskedEmail, [object])
.await
.created_id(0)
}
async fn x_create_err(&self, object: Value) -> crate::utils::jmap::JmapSetError {
self.registry_create_many(ObjectType::MaskedEmail, [object])
.await
.not_created(0)
.to_set_error()
}
async fn x_update(&self, id: Id, object: Value) {
self.registry_update_object(ObjectType::MaskedEmail, id, object)
.await;
}
async fn x_mask(&self, id: Id) -> MaskedEmail {
self.registry_get::<MaskedEmail>(id).await
}
async fn x_get_email(&self, id: Id) -> String {
self.x_mask(id).await.email
}
async fn x_list_for(&self, owner: &Account) -> JmapResponse {
self.jmap_method_call(
"x:MaskedEmail/get",
json!({"accountId": owner.id_string(), "ids": null}),
)
.await
}
async fn x_state(&self) -> String {
self.x_list_for(self).await.method_response()["state"]
.as_str()
.unwrap_or_default()
.to_string()
}
async fn count_in(&self, mailbox_id: u32) -> usize {
self.jmap_method_call(
"Email/query",
json!({
"accountId": self.id_string(),
"filter": {"inMailbox": Id::from(mailbox_id).to_string()}
}),
)
.await
.method_response()["ids"]
.as_array()
.map(|ids| ids.len())
.unwrap_or(0)
}
async fn wait_for_count(&self, mailbox_id: u32, count: usize) {
for _ in 0..40 {
if self.count_in(mailbox_id).await >= count {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
panic!(
"mailbox {mailbox_id} of {} never reached {count} messages",
self.name()
);
}
async fn latest_header(&self, header: &str) -> String {
let response = self
.jmap_method_calls(json!([
["Email/query", {
"accountId": self.id_string(),
"sort": [{"property": "receivedAt", "isAscending": false}],
"limit": 1
}, "q"],
["Email/get", {
"accountId": self.id_string(),
"#ids": {"resultOf": "q", "name": "Email/query", "path": "/ids"},
"properties": [format!("header:{header}:asText")]
}, "g"]
]))
.await;
response.0["methodResponses"][1][1]["list"][0][format!("header:{header}:asText")]
.as_str()
.unwrap_or_default()
.to_string()
}
}
+2
View File
@@ -10,6 +10,7 @@ pub mod authorization;
pub mod crypto;
pub mod delivery;
pub mod directory;
pub mod masked_email;
pub mod oidc;
pub mod purge;
pub mod quota;
@@ -62,6 +63,7 @@ pub async fn system_tests() {
oidc::test(&mut test).await;
authorization::test(&mut test).await;
tenant::test(&mut test).await;
masked_email::test(&mut test).await;
security::test(&mut test).await;
quota::test(&mut test).await;
purge::test(&mut test).await;