Merge branch 'main' into fix/group-collections-no-onward-share
This commit is contained in:
commit
461f5fab3c
34 files changed
+1460
-39
No files matched your search
@@ -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": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user