Merge branch 'main' into fix/group-collections-no-onward-share
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 15s
github/ci (branch) GitHub Actions
ci / build (pull_request) Successful in 8m15s

This commit is contained in:
jcoffey-dev committed 2026-10-05 23:08:04 +00:00
commit 461f5fab3c
34 files changed
+1460 -39

No files matched your search

+1 -1
View File
@@ -254,7 +254,7 @@ pub async fn test(test: &TestServer) {
// inbuxa: MT-22, the logo that applies to the account, and
// LP-19, whether the legacy protocols are open to it, and
// ai-explain EX-1, whether Explain can be offered
"urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled", "legacyAllowed": ["imap", "pop3", "manageSieve", "submission"], "aiExplain": false },
"urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled", "legacyAllowed": ["imap", "pop3", "manageSieve", "submission"], "aiExplain": false, "mailSharing": true, "addAccounts": true },
"https://www.fastmail.com/dev/maskedemail": {}
}
}
+150
View File
@@ -447,6 +447,156 @@ pub async fn test(test: &mut TestServer) {
query["ids"].as_array().is_some_and(|ids| ids.len() >= 2),
"AL-9: the delegate's access and changes weren't recorded: {query}"
);
shared_mailbox(admin, &mut smtp_rx, &mut lmtp).await;
}
/// MA-S (specs/multi-account.md): a shared mailbox is a lock of its own
/// kind. No reason is needed, more people fit, its Sieve replies go out,
/// only what is sent as it is recorded, and it sends only as itself.
async fn shared_mailbox(
admin: &Account,
smtp_rx: &mut tokio::sync::mpsc::Receiver<crate::jmap::mail::submission::MockMessage>,
lmtp: &mut SmtpConnection,
) {
println!("Running shared mailbox tests...");
let support = admin
.create_user_account("[email protected]", "support-secret-3317", "Support", &[], vec![])
.await;
let agent = admin
.create_user_account("[email protected]", "agent-secret-5520", "Agent", &[], vec![])
.await;
let support_id = support.id_string().to_string();
// An automatic acknowledgement, set up while it could still sign in
support
.jmap_client()
.await
.vacation_response_enable("Received", "We'll get back to you.".into(), None::<String>)
.await
.unwrap();
// More people than a lock may have
let mut delegates = vec![json!({"accountId": agent.id_string(), "access": "organize", "sendAs": true})];
for n in 0..11 {
let name: &'static str = Box::leak(format!("desk{n}@example.com").into_boxed_str());
let desk = admin.create_user_account(name, "desk-secret-7781", "Desk", &[], vec![]).await;
delegates.push(json!({"accountId": desk.id_string(), "access": "read"}));
}
// No reason needed
let response = admin
.lock_set(json!({"create": {"s": {"accountId": support_id, "kind": "sharedMailbox",
"delegates": delegates}}}))
.await;
assert_eq!(response["created"]["s"]["id"], support_id.as_str(), "MA-S1: {response}");
let (_, got) = admin
.call("inbuxa:AccountLock/get", json!({"accountId": admin.id_string(), "ids": [support_id]}))
.await;
assert_eq!(got["list"][0]["kind"], "sharedMailbox", "MA-S1: {got}");
// Nobody signs in to it
assert_ne!(support.session_status().await, 200, "MA-S1: a shared mailbox signed in");
// It says what it is to the people in it
let session = agent.jmap_session_object().await.0;
let delegation = &session["accounts"][support_id.as_str()]["accountCapabilities"]["urn:inbuxa:jmap"]["delegation"];
assert_eq!(delegation["kind"], "sharedMailbox", "MA-S: {session}");
assert_eq!(delegation["locked"], true, "MA-S: front ends that know no kind still see a lock");
// Its Sieve replies go out, where a lock's are held back
lmtp.ingest(
"[email protected]",
&["[email protected]"],
// Addressed to it: a vacation reply answers only mail sent to it
"From: [email protected]\r\nTo: [email protected]\r\nSubject: My order\r\n\r\nHello.\r\n",
)
.await;
assert_message_delivery(
smtp_rx,
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Received"),
)
.await;
// The agent answers as support@: sent, and recorded as the agent
let (_, mailboxes) = agent
.call("Mailbox/get", json!({"accountId": support_id, "ids": null, "properties": ["role"]}))
.await;
let drafts = mailboxes["list"]
.as_array()
.unwrap()
.iter()
.find(|m| m["role"] == "drafts")
.unwrap_or_else(|| panic!("no Drafts: {mailboxes}"))["id"]
.clone();
let (_, identities) = agent
.call("Identity/get", json!({"accountId": support_id, "ids": null}))
.await;
let identity = identities["list"][0]["id"].clone();
let send = |reply_to: Option<&str>, subject: &str| {
let mut email = json!({
"mailboxIds": {drafts.as_str().unwrap(): true},
"from": [{"email": "[email protected]"}],
"to": [{"email": "[email protected]"}],
"subject": subject,
"bodyValues": {"t": {"value": "Thanks for writing."}},
"textBody": [{"partId": "t", "type": "text/plain"}]
});
if let Some(reply_to) = reply_to {
email["replyTo"] = json!([{"email": reply_to}]);
}
json!([
["Email/set", {"accountId": support_id, "create": {"m": email}}, "e"],
["EmailSubmission/set", {"accountId": support_id,
"create": {"s": {"identityId": identity, "emailId": "#m"}}}, "s"]
])
};
let response = agent.jmap_request(USING, send(None, "Re: My order")).await.0;
assert!(
response.pointer("/methodResponses/1/1/created/s").is_some(),
"MA-S3: the answer didn't go out: {response}"
);
assert_message_delivery(
smtp_rx,
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Re: My order"),
)
.await;
// MA-S3: a reply can't be steered to the agent's own address
let response = agent
.jmap_request(USING, send(Some("[email protected]"), "Write to me directly"))
.await
.0;
assert_eq!(
response.pointer("/methodResponses/1/1/notCreated/s/type"),
Some(&json!("forbiddenFrom")),
"MA-S3: {response}"
);
expect_nothing(smtp_rx).await;
// MA-D0a: the send names the agent; AL-9's per-change records don't
// apply in a shared mailbox
let (_, query) = admin
.call(
"inbuxa:AuditEvent/query",
json!({"accountId": admin.id_string(),
"filter": {"actorId": agent.id_string(), "accountId": support_id}}),
)
.await;
let (_, records) = admin
.call("inbuxa:AuditEvent/get", json!({"accountId": admin.id_string(), "ids": query["ids"]}))
.await;
let kinds = records["list"]
.as_array()
.unwrap()
.iter()
.map(|r| r["target"]["kind"].as_str().unwrap_or_default().to_string())
.collect::<Vec<_>>();
assert_eq!(kinds, ["EmailSubmission"], "MA-D0a: {records}");
// Ending it needs no reason either
let response = admin.lock_set(json!({"destroy": [support_id]})).await;
assert_eq!(response["destroyed"][0], support_id.as_str(), "MA-S: {response}");
}
/// Runs these tests alone: `cargo test -p tests account_lock_tests -- --ignored`.
+1
View File
@@ -12,6 +12,7 @@ pub mod ai;
pub mod ai_calibration;
pub mod ai_explain;
pub mod account_lock; // inbuxa: account lock with delegation
pub mod sharing_policy; // inbuxa: MA-C, who may share mail
pub mod legal_hold; // inbuxa: legal hold
pub mod compliance; // inbuxa: the compliance roles
pub mod mail_rules; // inbuxa: DLP and mail flow rules
+237
View File
@@ -0,0 +1,237 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Who may share mail (multi-account spec, MA-C): the server's switch and a
//! tenant's, which can only be stricter. Off refuses new shares and stops
//! honoring old ones, which come back when it's on again; locks and shared
//! mailboxes are never affected.
use crate::utils::{account::Account, server::TestServerBuilder};
use registry::schema::{
prelude::{ObjectType, Property},
structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles},
};
use serde_json::{Value, json};
const USING: &[&str] = &[
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:inbuxa:jmap",
];
impl Account {
async fn one(&self, method: &str, arguments: Value) -> Value {
let response = self.jmap_request(USING, json!([[method, arguments, "0"]])).await;
response
.0
.pointer("/methodResponses/0")
.cloned()
.unwrap_or_else(|| panic!("{method}: {}", response.0))
}
async fn policy(&self, update: Value) -> Value {
self.one(
"inbuxa:SharingPolicy/set",
json!({"accountId": self.id_string(), "update": update}),
)
.await[1]
.clone()
}
async fn inbox(&self) -> String {
let response = self
.one(
"Mailbox/get",
json!({"accountId": self.id_string(), "ids": null, "properties": ["role"]}),
)
.await;
response[1]["list"]
.as_array()
.unwrap()
.iter()
.find(|m| m["role"] == "inbox")
.unwrap_or_else(|| panic!("no Inbox: {response}"))["id"]
.as_str()
.unwrap()
.to_string()
}
/// Shares the Inbox with `with` (read), or stops with `None` rights.
async fn share_inbox(&self, with: &Account, read: bool) -> Value {
let inbox = self.inbox().await;
let rights = if read { json!({"mayReadItems": true}) } else { Value::Null };
self.one(
"Mailbox/set",
json!({"accountId": self.id_string(),
"update": {inbox: {format!("shareWith/{}", with.id_string()): rights}}}),
)
.await[1]
.clone()
}
async fn sees(&self, other: &Account) -> bool {
self.jmap_session_object().await.0["accounts"]
.get(other.id_string())
.is_some()
}
async fn mail_sharing(&self) -> Value {
self.jmap_session_object().await.0["accounts"][self.id_string()]["accountCapabilities"]
["urn:inbuxa:jmap"]["mailSharing"]
.clone()
}
}
/// Runs these tests alone: `cargo test -p tests sharing_policy_tests -- --ignored`.
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn sharing_policy_tests() {
let mut test = TestServerBuilder::new("sharing_policy_tests")
.await
.with_default_listeners()
.await
.build()
.await;
let admin = test.create_admin_account("[email protected]").await;
println!("Running sharing policy tests...");
let tenant = admin
.registry_create_object(Tenant {
name: "School".to_string(),
..Default::default()
})
.await;
admin
.registry_create_object(Domain {
name: "school.example.org".to_string(),
is_enabled: true,
member_tenant_id: Some(tenant),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
..Default::default()
})
.await;
let ann = admin
.create_user_account("[email protected]", "ann-secret-6610", "Ann", &[], vec![])
.await;
let ben = admin
.create_user_account("[email protected]", "ben-secret-6611", "Ben", &[], vec![])
.await;
let head = admin
.create_user_account("[email protected]", "head-secret-6612", "Head", &[], vec![])
.await;
admin
.registry_update_object(
ObjectType::Account,
head.id(),
json!({Property::Roles: UserRoles::Admin}),
)
.await;
let carl = admin
.create_user_account("[email protected]", "carl-secret-6613", "Carl", &[], vec![])
.await;
// Shares never cross tenants (MT-3), so Carl's neighbour is outside too
let dan = admin
.create_user_account("[email protected]", "dan-secret-6614", "Dan", &[], vec![])
.await;
let tenant_id = tenant.to_string();
// MA-10: on by default
assert_eq!(ann.mail_sharing().await, true, "MA-10");
let response = ann.share_inbox(&ben, true).await;
assert!(response["updated"].is_object(), "MA-10: {response}");
assert!(ben.sees(&ann).await, "MA-10: the share gives nothing");
// The school turns mail sharing off, as its own administrator
let response = head
.policy(json!({tenant_id.as_str(): {"mailSharing": "disabled"}}))
.await;
assert!(response["updated"].is_object(), "MA-13: {response}");
// ...but can't touch the server's
let response = head
.policy(json!({"singleton": {"mailSharing": "disabled"}}))
.await;
assert_eq!(response["notUpdated"]["singleton"]["type"], "forbidden", "MA-13: {response}");
// MA-11: what was shared gives nothing now, and nothing new is shared
assert_eq!(ann.mail_sharing().await, false, "MA-11");
assert!(!ben.sees(&ann).await, "MA-11: an old share still honored");
let response = ann.share_inbox(&head, true).await;
assert_eq!(
response["notUpdated"].as_object().and_then(|o| o.values().next()).map(|e| e["type"].clone()),
Some(json!("forbidden")),
"MA-11: {response}"
);
// MA-12: a shared mailbox isn't anyone's share, and keeps working
let office = admin
.create_user_account("[email protected]", "office-secret-6615", "Office", &[], vec![])
.await;
let response = admin
.one(
"inbuxa:AccountLock/set",
json!({"accountId": admin.id_string(), "create": {"o": {"accountId": office.id_string(),
"kind": "sharedMailbox",
"delegates": [{"accountId": ben.id_string(), "access": "organize"}]}}}),
)
.await;
assert!(response[1]["created"]["o"].is_object(), "MA-12: {response}");
assert!(ben.sees(&office).await, "MA-12: the switch reached a shared mailbox");
// Outside the school nothing changed
let response = carl.share_inbox(&dan, true).await;
assert!(response["updated"].is_object(), "MA-C: another tenant's switch reached Carl: {response}");
assert!(dan.sees(&carl).await, "MA-C");
// A tenant can only be stricter than the server
let response = admin
.policy(json!({"singleton": {"mailSharing": "disabled"}}))
.await;
assert!(response["updated"].is_object(), "MA-C: {response}");
let response = head
.policy(json!({tenant_id.as_str(): {"mailSharing": "enabled"}}))
.await;
assert_eq!(
response["notUpdated"][tenant_id.as_str()]["type"],
"forbidden",
"MA-C: {response}"
);
assert!(!dan.sees(&carl).await, "MA-C: the server's switch didn't reach Carl's share");
// Turned back on, the old shares are honored again
admin
.policy(json!({"singleton": {"mailSharing": null}}))
.await;
head.policy(json!({tenant_id.as_str(): {"mailSharing": null}}))
.await;
assert!(ben.sees(&ann).await, "MA-C: an old share didn't come back");
assert!(dan.sees(&carl).await, "MA-C");
// Ending a share is always allowed, even while sharing is off
head.policy(json!({tenant_id.as_str(): {"mailSharing": "disabled"}}))
.await;
let response = ann.share_inbox(&ben, false).await;
assert!(response["updated"].is_object(), "MA-11: ending a share refused: {response}");
head.policy(json!({tenant_id.as_str(): {"mailSharing": null}}))
.await;
assert!(!ben.sees(&ann).await, "MA-11: the ended share came back");
// MA-14: every change is in the audit log
let query = admin
.one(
"inbuxa:AuditEvent/query",
json!({"accountId": admin.id_string(), "filter": {"targetKind": "inbuxa:SharingPolicy"}}),
)
.await;
assert!(
query[1]["ids"].as_array().is_some_and(|ids| ids.len() >= 5),
"MA-14: {query}"
);
if test.is_reset() {
test.temp_dir.delete();
}
}