An EmailSubmission create's response carries inbuxa:held (dlp-and-mail-flow-rules spec, §2.6, §4): true when the message is held for review, false otherwise, so the webmail can say so at once. A sender can't read the review queue, and a held message's sendAt is its real send time, not the century-off release, so this is how the sender learns. mail_rules_tests checks both values.
1120 lines
34 KiB
Rust
1120 lines
34 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
//! `inbuxa:MailRule` (dlp-and-mail-flow-rules spec, §2.2, §2.8): rules are
|
|
//! stored, listed in the order they run, checked when written, kept apart
|
|
//! by kind for permissions, and every change is audited.
|
|
|
|
use crate::utils::{
|
|
account::Account,
|
|
server::{TestServer, TestServerBuilder},
|
|
smtp::SmtpConnection,
|
|
};
|
|
use registry::schema::{
|
|
prelude::{ObjectType, Property},
|
|
structs::{CustomRoles, Expression, MtaStageAuth, Role, UserRoles},
|
|
};
|
|
use registry::types::map::Map;
|
|
use serde_json::{Value, json};
|
|
|
|
const USING: &[&str] = &[
|
|
"urn:ietf:params:jmap:core",
|
|
"urn:ietf:params:jmap:mail",
|
|
"urn:ietf:params:jmap:submission",
|
|
"urn:inbuxa:jmap",
|
|
"urn:inbuxa:jmap:registry",
|
|
];
|
|
|
|
async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) {
|
|
if arguments.get("accountId").is_none() {
|
|
arguments["accountId"] = account.id_string().into();
|
|
}
|
|
let response = account
|
|
.jmap_request(USING, json!([[method, arguments, "0"]]))
|
|
.await;
|
|
let call = response
|
|
.0
|
|
.pointer("/methodResponses/0")
|
|
.cloned()
|
|
.unwrap_or_else(|| panic!("{method}: {}", response.0));
|
|
(
|
|
call[0].as_str().unwrap_or_default().to_string(),
|
|
call[1].clone(),
|
|
)
|
|
}
|
|
|
|
fn dlp_rule() -> Value {
|
|
json!({
|
|
"name": "Cards leaving",
|
|
"kind": "dlp",
|
|
"direction": "outgoing",
|
|
"priority": 10,
|
|
"conditions": [
|
|
{"type": "recipientOutside"},
|
|
{"type": "detected", "detectors": [{"id": "payment-card", "atLeast": 5}]}
|
|
],
|
|
"actions": [{"type": "hold", "notice": "Held for review", "notifySender": true}]
|
|
})
|
|
}
|
|
|
|
fn transport_rule() -> Value {
|
|
json!({
|
|
"name": "Disclaimer",
|
|
"kind": "transport",
|
|
"direction": "outgoing",
|
|
"priority": 1,
|
|
"conditions": [{"type": "recipientOutside"}],
|
|
"actions": [{"type": "addDisclaimer", "text": "Sent by Example Co.", "position": "bottom"}]
|
|
})
|
|
}
|
|
|
|
async fn names(account: &Account) -> Vec<String> {
|
|
let (name, response) = call(account, "inbuxa:MailRule/get", json!({"ids": null})).await;
|
|
assert_eq!(name, "inbuxa:MailRule/get", "{response}");
|
|
response["list"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|r| r["name"].as_str().unwrap().to_string())
|
|
.collect()
|
|
}
|
|
|
|
pub async fn test(test: &mut TestServer) {
|
|
println!("Running mail rule tests...");
|
|
let admin = test.account("[email protected]");
|
|
|
|
// Created, then listed in the order they run
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:MailRule/set",
|
|
json!({"create": {"d": dlp_rule(), "t": transport_rule()}}),
|
|
)
|
|
.await;
|
|
let dlp_id = response["created"]["d"]["id"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("DLP rule created: {response}"))
|
|
.to_string();
|
|
let transport_id = response["created"]["t"]["id"].as_str().unwrap().to_string();
|
|
assert_eq!(names(&admin).await, vec!["Disclaimer", "Cards leaving"]);
|
|
|
|
let (_, response) = call(&admin, "inbuxa:MailRule/get", json!({"ids": [dlp_id]})).await;
|
|
let rule = &response["list"][0];
|
|
assert_eq!(
|
|
rule["conditions"][1]["detectors"][0]["atLeast"], 5,
|
|
"{rule}"
|
|
);
|
|
assert_eq!(rule["actions"][0]["notifySender"], true);
|
|
assert_eq!(rule["createdBy"], "[email protected]");
|
|
assert!(
|
|
rule["createdAt"].as_str().is_some_and(|d| d.ends_with('Z')),
|
|
"{rule}"
|
|
);
|
|
|
|
// Checked when written
|
|
let mut inbound = dlp_rule();
|
|
inbound["direction"] = "incoming".into();
|
|
let mut unknown = dlp_rule();
|
|
unknown["conditions"][1]["detectors"][0]["id"] = "no-such-detector".into();
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:MailRule/set",
|
|
json!({"create": {"a": inbound, "b": unknown, "c": {"name": "x", "kind": "dlp"}}}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response["notCreated"]["a"]["properties"][0], "direction",
|
|
"{response}"
|
|
);
|
|
assert!(
|
|
response["notCreated"]["b"]["description"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("no-such-detector"),
|
|
"{response}"
|
|
);
|
|
assert!(response["notCreated"].get("c").is_some(), "{response}");
|
|
|
|
// Changed in place; what the server sets can't be sent
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:MailRule/set",
|
|
json!({"update": {
|
|
transport_id.as_str(): {"name": "Footer", "priority": 50},
|
|
dlp_id.as_str(): {"createdBy": "someone else"}
|
|
}}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
response["updated"].get(transport_id.as_str()).is_some(),
|
|
"{response}"
|
|
);
|
|
assert_eq!(
|
|
response["notUpdated"][dlp_id.as_str()]["properties"][0],
|
|
"createdBy",
|
|
"{response}"
|
|
);
|
|
assert_eq!(names(&admin).await, vec!["Cards leaving", "Footer"]);
|
|
|
|
// A compliance officer sees DLP rules, not mail flow rules, and changes
|
|
// neither (settled answer 4)
|
|
let mut officer_role = None;
|
|
for id in admin
|
|
.registry_query_ids(
|
|
ObjectType::Role,
|
|
Vec::<(&str, &str)>::new(),
|
|
Vec::<&str>::new(),
|
|
)
|
|
.await
|
|
{
|
|
let role = admin.registry_get::<Role>(id).await;
|
|
if role.description == "Compliance Officer" && role.member_tenant_id.is_none() {
|
|
officer_role = Some(id);
|
|
}
|
|
}
|
|
let officer = admin
|
|
.create_user_account(
|
|
"[email protected]",
|
|
"officer-secret-7731",
|
|
"Officer",
|
|
&[],
|
|
vec![],
|
|
)
|
|
.await;
|
|
admin
|
|
.registry_update_object(
|
|
ObjectType::Account,
|
|
officer.id(),
|
|
json!({Property::Roles: UserRoles::Custom(CustomRoles {
|
|
role_ids: Map::new(vec![officer_role.expect("the officer role")]),
|
|
})}),
|
|
)
|
|
.await;
|
|
assert_eq!(names(&officer).await, vec!["Cards leaving"]);
|
|
let (name, response) = call(
|
|
&officer,
|
|
"inbuxa:MailRule/set",
|
|
json!({"create": {"d": dlp_rule()}}),
|
|
)
|
|
.await;
|
|
assert_eq!(name, "error", "the officer created a DLP rule: {response}");
|
|
|
|
// Deleted
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:MailRule/set",
|
|
json!({"destroy": [transport_id]}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response["destroyed"][0],
|
|
transport_id.as_str(),
|
|
"{response}"
|
|
);
|
|
assert_eq!(names(&admin).await, vec!["Cards leaving"]);
|
|
|
|
// Every change is in the audit log
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:AuditEvent/query",
|
|
json!({"filter": {"targetKind": "inbuxa:MailRule"}, "calculateTotal": true}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
response["total"].as_u64().unwrap_or(0) >= 4,
|
|
"creates, update and destroy audited: {response}"
|
|
);
|
|
}
|
|
|
|
/// A draft from `sender`, submitted; the submission call's response.
|
|
async fn submit(
|
|
sender: &Account,
|
|
identity: &str,
|
|
mailbox: &str,
|
|
to: &[&str],
|
|
subject: &str,
|
|
body: &str,
|
|
dlp_override: Option<&str>,
|
|
) -> Value {
|
|
let (_, response) = call(
|
|
sender,
|
|
"Email/set",
|
|
json!({"create": {"e": {
|
|
"mailboxIds": {mailbox: true},
|
|
"from": [{"email": sender.name()}],
|
|
"to": to.iter().map(|a| json!({"email": a})).collect::<Vec<_>>(),
|
|
"subject": subject,
|
|
"bodyValues": {"b": {"value": body}},
|
|
"textBody": [{"partId": "b", "type": "text/plain"}]
|
|
}}}),
|
|
)
|
|
.await;
|
|
let email = response["created"]["e"]["id"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("draft: {response}"))
|
|
.to_string();
|
|
let mut create = json!({"emailId": email, "identityId": identity});
|
|
if let Some(reason) = dlp_override {
|
|
create["inbuxa:dlpOverride"] = json!({"reason": reason});
|
|
}
|
|
call(
|
|
sender,
|
|
"EmailSubmission/set",
|
|
json!({"create": {"s": create}}),
|
|
)
|
|
.await
|
|
.1
|
|
}
|
|
|
|
/// DLP at DATA (§2.4–§2.7): a warning answered with a reason, a block no
|
|
/// reason answers, the subject tag, and records that name the rules and
|
|
/// counts but never what was found.
|
|
pub async fn dlp(test: &mut TestServer) {
|
|
println!("Running DLP sending tests...");
|
|
let admin = test.account("[email protected]");
|
|
let sender = admin
|
|
.create_user_account(
|
|
"[email protected]",
|
|
"dlp-sender-secret-5501",
|
|
"DLP sender",
|
|
&[],
|
|
vec![],
|
|
)
|
|
.await;
|
|
let (_, response) = call(
|
|
&sender,
|
|
"Identity/set",
|
|
json!({"create": {"i": {"name": "Sender", "email": "[email protected]"}}}),
|
|
)
|
|
.await;
|
|
let identity = response["created"]["i"]["id"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("{response}"))
|
|
.to_string();
|
|
let (_, response) = call(
|
|
&sender,
|
|
"Mailbox/set",
|
|
json!({"create": {"m": {"name": "DLP drafts"}}}),
|
|
)
|
|
.await;
|
|
let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string();
|
|
let outside = ["[email protected]"];
|
|
let card = "Card 4242 4242 4242 4242, expires 12/31";
|
|
|
|
// No rules: sent
|
|
let response = submit(
|
|
&sender, &identity, &mailbox, &outside, "Numbers", card, None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
response["created"].get("s").is_some(),
|
|
"no rules: {response}"
|
|
);
|
|
assert_eq!(response["created"]["s"]["inbuxa:held"], false, "{response}");
|
|
|
|
// A warning: refused with the rule and its notice, then sent with a reason
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:MailRule/set",
|
|
json!({"create": {"w": {
|
|
"name": "Cards leaving", "kind": "dlp", "direction": "outgoing",
|
|
"conditions": [{"type": "recipientOutside"},
|
|
{"type": "detected", "detectors": [{"id": "payment-card"}]}],
|
|
"actions": [{"type": "warn", "notice": "This looks like a card number."}]
|
|
}}}),
|
|
)
|
|
.await;
|
|
let warn_rule = response["created"]["w"]["id"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("{response}"))
|
|
.to_string();
|
|
let response = submit(
|
|
&sender, &identity, &mailbox, &outside, "Numbers", card, None,
|
|
)
|
|
.await;
|
|
let refused = &response["notCreated"]["s"];
|
|
assert_eq!(refused["type"], "inbuxa:dlpWarning", "{response}");
|
|
assert_eq!(refused["rules"][0]["name"], "Cards leaving");
|
|
assert_eq!(refused["description"], "This looks like a card number.");
|
|
// Inside the server: no warning
|
|
let response = submit(
|
|
&sender,
|
|
&identity,
|
|
&mailbox,
|
|
&["[email protected]"],
|
|
"Numbers",
|
|
card,
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
response["created"].get("s").is_some(),
|
|
"local recipient: {response}"
|
|
);
|
|
let response = submit(
|
|
&sender,
|
|
&identity,
|
|
&mailbox,
|
|
&outside,
|
|
"Numbers",
|
|
card,
|
|
Some("The client asked for it"),
|
|
)
|
|
.await;
|
|
assert!(
|
|
response["created"].get("s").is_some(),
|
|
"overridden: {response}"
|
|
);
|
|
|
|
// A block: no reason gets past it
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:MailRule/set",
|
|
json!({"create": {"b": {
|
|
"name": "Keys", "kind": "dlp", "direction": "outgoing",
|
|
"conditions": [{"type": "detected", "detectors": [{"id": "private-key"}]}],
|
|
"actions": [{"type": "block", "notice": "Private keys don't leave by mail."}]
|
|
}}}),
|
|
)
|
|
.await;
|
|
assert!(response["created"].get("b").is_some(), "{response}");
|
|
let key = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQ\n-----END OPENSSH PRIVATE KEY-----";
|
|
let response = submit(
|
|
&sender,
|
|
&identity,
|
|
&mailbox,
|
|
&outside,
|
|
"Key",
|
|
key,
|
|
Some("Please"),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response["notCreated"]["s"]["type"], "inbuxa:dlpBlocked",
|
|
"{response}"
|
|
);
|
|
|
|
// The subject tag overrides, and doesn't go out: the sender's own copy
|
|
// arrives without it
|
|
let response = submit(
|
|
&sender,
|
|
&identity,
|
|
&mailbox,
|
|
&["[email protected]", "[email protected]"],
|
|
"[override: Agreed with finance] Tagged numbers",
|
|
card,
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(
|
|
response["created"].get("s").is_some(),
|
|
"tag override: {response}"
|
|
);
|
|
let mut delivered = Vec::new();
|
|
for _ in 0..50 {
|
|
let (_, response) = call(
|
|
&sender,
|
|
"Email/query",
|
|
json!({"filter": {"text": "Tagged"}}),
|
|
)
|
|
.await;
|
|
let ids = response["ids"].clone();
|
|
let (_, response) = call(
|
|
&sender,
|
|
"Email/get",
|
|
json!({"ids": ids, "properties": ["subject", "mailboxIds"]}),
|
|
)
|
|
.await;
|
|
delivered = response["list"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|e| !e["mailboxIds"].as_object().unwrap().contains_key(&mailbox))
|
|
.map(|e| e["subject"].as_str().unwrap_or_default().to_string())
|
|
// The bounce for the unreachable outside address quotes it
|
|
.filter(|subject| !subject.starts_with("Failed to deliver"))
|
|
.collect();
|
|
if !delivered.is_empty() {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
}
|
|
assert_eq!(
|
|
delivered,
|
|
vec!["Tagged numbers".to_string()],
|
|
"delivered subject"
|
|
);
|
|
|
|
// Recorded: sender, what happened, rules and counts, the reason; never
|
|
// the number itself
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:AuditEvent/query",
|
|
json!({"filter": {"targetKind": "message"}}),
|
|
)
|
|
.await;
|
|
let ids = response["ids"].clone();
|
|
let (_, response) = call(&admin, "inbuxa:AuditEvent/get", json!({"ids": ids})).await;
|
|
let events = response["list"].as_array().unwrap();
|
|
let details: Vec<&str> = events
|
|
.iter()
|
|
.filter_map(|e| e["details"].as_str())
|
|
.collect();
|
|
assert!(
|
|
details
|
|
.iter()
|
|
.any(|d| d
|
|
.starts_with("DLP warned, to elsewhere.org: \"Cards leaving\" (payment-card 1)")),
|
|
"{details:?}"
|
|
);
|
|
assert!(
|
|
details.iter().any(|d| d.starts_with("DLP blocked")),
|
|
"{details:?}"
|
|
);
|
|
assert!(
|
|
events.iter().any(
|
|
|e| e["reason"] == "The client asked for it" && e["outcome"]["status"] == "success"
|
|
),
|
|
"{response}"
|
|
);
|
|
assert!(
|
|
events.iter().any(|e| e["reason"] == "Agreed with finance"),
|
|
"{response}"
|
|
);
|
|
assert!(
|
|
events
|
|
.iter()
|
|
.all(|e| e["actor"]["name"] == "[email protected]"),
|
|
"{response}"
|
|
);
|
|
let all = response.to_string();
|
|
assert!(
|
|
!all.contains("4242") && !all.contains("b3BlbnNz"),
|
|
"matched text in the audit log"
|
|
);
|
|
|
|
// Rules off again for the tests that follow
|
|
call(
|
|
&admin,
|
|
"inbuxa:MailRule/set",
|
|
json!({"destroy": [warn_rule]}),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// The subjects, bodies and a header of what `account` received whose
|
|
/// subject contains `text`, polling until something arrives.
|
|
async fn received(
|
|
account: &Account,
|
|
text: &str,
|
|
header: &str,
|
|
drafts: Option<&str>,
|
|
) -> Vec<(String, String, String)> {
|
|
for _ in 0..50 {
|
|
// Not the sender's own draft
|
|
let filter = match drafts {
|
|
Some(drafts) => json!({"subject": text, "inMailboxOtherThan": [drafts]}),
|
|
None => json!({"subject": text}),
|
|
};
|
|
let (_, response) = call(account, "Email/query", json!({"filter": filter})).await;
|
|
let ids = response["ids"].clone();
|
|
let (_, response) = call(
|
|
account,
|
|
"Email/get",
|
|
json!({"ids": ids, "properties": ["subject", "bodyValues", "textBody", format!("header:{header}:asText")],
|
|
"fetchTextBodyValues": true}),
|
|
)
|
|
.await;
|
|
let list: Vec<(String, String, String)> = response["list"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|e| {
|
|
!e["subject"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.starts_with("Failed to deliver")
|
|
})
|
|
.map(|e| {
|
|
let part = e["textBody"][0]["partId"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
(
|
|
e["subject"].as_str().unwrap_or_default().to_string(),
|
|
e["bodyValues"][part.as_str()]["value"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
e[format!("header:{header}:asText").as_str()]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
)
|
|
})
|
|
.collect();
|
|
if !list.is_empty() {
|
|
return list;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
}
|
|
Vec::new()
|
|
}
|
|
|
|
/// Transport rules (§2.4): the actions that change a message, where it
|
|
/// goes, or refuse it, on outgoing and incoming mail.
|
|
pub async fn transport(test: &mut TestServer) {
|
|
println!("Running mail flow action tests...");
|
|
let admin = test.account("[email protected]");
|
|
let sender = admin
|
|
.create_user_account(
|
|
"[email protected]",
|
|
"flow-sender-secret-6602",
|
|
"Flow sender",
|
|
&[],
|
|
vec![],
|
|
)
|
|
.await;
|
|
let other = admin
|
|
.create_user_account(
|
|
"[email protected]",
|
|
"flow-other-secret-6603",
|
|
"Flow other",
|
|
&[],
|
|
vec![],
|
|
)
|
|
.await;
|
|
let (_, response) = call(
|
|
&sender,
|
|
"Identity/set",
|
|
json!({"create": {"i": {"name": "Sender", "email": "[email protected]"}}}),
|
|
)
|
|
.await;
|
|
let identity = response["created"]["i"]["id"].as_str().unwrap().to_string();
|
|
let (_, response) = call(
|
|
&sender,
|
|
"Mailbox/set",
|
|
json!({"create": {"m": {"name": "Flow drafts"}}}),
|
|
)
|
|
.await;
|
|
let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string();
|
|
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:MailRule/set",
|
|
json!({"create": {
|
|
"d": {"name": "Footer and tag", "kind": "transport", "direction": "outgoing",
|
|
"conditions": [{"type": "senderAddress", "addresses": ["[email protected]"]}],
|
|
"actions": [
|
|
{"type": "addDisclaimer", "text": "Sent by Example Co.", "position": "bottom"},
|
|
{"type": "addHeader", "name": "X-Flow", "value": "checked"},
|
|
{"type": "prefixSubject", "text": "[Example]"}
|
|
]},
|
|
"r": {"name": "Redirect projects", "kind": "transport", "direction": "outgoing",
|
|
"conditions": [{"type": "words", "words": ["project falcon"]}],
|
|
"actions": [{"type": "redirect", "addresses": ["[email protected]"]}]},
|
|
"x": {"name": "No invoices", "kind": "transport", "direction": "outgoing",
|
|
"conditions": [{"type": "words", "words": ["invoice-scam"]}],
|
|
"actions": [{"type": "refuse", "text": "This kind of message isn't sent from here."}]},
|
|
"i": {"name": "Outside banner", "kind": "transport", "direction": "incoming",
|
|
"conditions": [], "actions": [{"type": "prefixSubject", "text": "[External]"}]}
|
|
}}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response["created"].as_object().map(|c| c.len()),
|
|
Some(4),
|
|
"{response}"
|
|
);
|
|
|
|
// Disclaimer, header and subject prefix on the sender's own copy
|
|
let response = submit(
|
|
&sender,
|
|
&identity,
|
|
&mailbox,
|
|
&["[email protected]"],
|
|
"Lunch",
|
|
"See you at noon.",
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(response["created"].get("s").is_some(), "{response}");
|
|
let got = received(&sender, "Lunch", "X-Flow", Some(&mailbox)).await;
|
|
assert_eq!(got.len(), 1, "{got:?}");
|
|
let (subject, body, header) = &got[0];
|
|
assert_eq!(subject, "[Example] Lunch");
|
|
assert!(
|
|
body.starts_with("See you at noon.") && body.trim_end().ends_with("Sent by Example Co."),
|
|
"{body:?}"
|
|
);
|
|
assert_eq!(header.trim(), "checked");
|
|
|
|
// Redirected: the other user gets it, the named recipient doesn't
|
|
let response = submit(
|
|
&sender,
|
|
&identity,
|
|
&mailbox,
|
|
&["[email protected]"],
|
|
"Falcon",
|
|
"About Project Falcon.",
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(response["created"].get("s").is_some(), "{response}");
|
|
assert_eq!(
|
|
received(&other, "Falcon", "X-Flow", None).await.len(),
|
|
1,
|
|
"redirected"
|
|
);
|
|
assert!(
|
|
received(&sender, "Falcon", "X-Flow", Some(&mailbox))
|
|
.await
|
|
.is_empty(),
|
|
"the named recipient didn't get it"
|
|
);
|
|
|
|
// Refused
|
|
let response = submit(
|
|
&sender,
|
|
&identity,
|
|
&mailbox,
|
|
&["[email protected]"],
|
|
"Pay",
|
|
"invoice-scam inside",
|
|
None,
|
|
)
|
|
.await;
|
|
let refused = &response["notCreated"]["s"];
|
|
assert_eq!(refused["type"], "forbiddenToSend", "{response}");
|
|
assert!(
|
|
refused["description"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("isn't sent from here"),
|
|
"{response}"
|
|
);
|
|
|
|
// Incoming mail: the banner rule, and none of the outgoing ones
|
|
// LMTP delivery without signing in, as mail from outside arrives
|
|
admin
|
|
.registry_create_object(MtaStageAuth {
|
|
require: Expression {
|
|
else_: "false".to_string(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
let mut lmtp = SmtpConnection::connect().await;
|
|
lmtp.ingest(
|
|
"[email protected]",
|
|
&["[email protected]"],
|
|
"From: [email protected]\r\nTo: [email protected]\r\nSubject: Hello from outside\r\n\r\nHi.\r\n",
|
|
)
|
|
.await;
|
|
let got = received(&other, "Hello from outside", "X-Flow", None).await;
|
|
assert_eq!(
|
|
got.first().map(|g| g.0.as_str()),
|
|
Some("[External] Hello from outside"),
|
|
"{got:?}"
|
|
);
|
|
assert!(
|
|
got[0].2.is_empty(),
|
|
"outgoing rules left incoming mail alone"
|
|
);
|
|
|
|
// The redirect and the refusal are recorded; the footer isn't
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:AuditEvent/query",
|
|
json!({"filter": {"targetKind": "message", "text": "Mail flow"}}),
|
|
)
|
|
.await;
|
|
let ids = response["ids"].clone();
|
|
let (_, response) = call(&admin, "inbuxa:AuditEvent/get", json!({"ids": ids})).await;
|
|
let details: Vec<String> = response["list"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter_map(|e| e["details"].as_str().map(str::to_string))
|
|
.collect();
|
|
assert!(
|
|
details.iter().any(|d| d.starts_with(
|
|
"Mail flow rule \"Redirect projects\" redirected to [email protected]"
|
|
)),
|
|
"{details:?}"
|
|
);
|
|
assert!(
|
|
details
|
|
.iter()
|
|
.any(|d| d.starts_with("Mail flow rule \"No invoices\" refused")),
|
|
"{details:?}"
|
|
);
|
|
assert!(
|
|
!details.iter().any(|d| d.contains("Footer and tag")),
|
|
"{details:?}"
|
|
);
|
|
|
|
// Off again
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:MailRule/get",
|
|
json!({"ids": null, "properties": ["id", "kind"]}),
|
|
)
|
|
.await;
|
|
let transport: Vec<Value> = response["list"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|r| r["kind"] == "transport")
|
|
.map(|r| r["id"].clone())
|
|
.collect();
|
|
call(&admin, "inbuxa:MailRule/set", json!({"destroy": transport})).await;
|
|
}
|
|
|
|
/// Hold for review (§2.6): held mail waits, shows in the review queue,
|
|
/// can't be sent around the review, and a reviewer releases or rejects it.
|
|
pub async fn hold(test: &mut TestServer) {
|
|
println!("Running held mail tests...");
|
|
let admin = test.account("[email protected]");
|
|
let sender = admin
|
|
.create_user_account(
|
|
"[email protected]",
|
|
"hold-sender-secret-7703",
|
|
"Hold sender",
|
|
&[],
|
|
vec![],
|
|
)
|
|
.await;
|
|
let (_, response) = call(
|
|
&sender,
|
|
"Identity/set",
|
|
json!({"create": {"i": {"name": "Sender", "email": "[email protected]"}}}),
|
|
)
|
|
.await;
|
|
let identity = response["created"]["i"]["id"].as_str().unwrap().to_string();
|
|
let (_, response) = call(
|
|
&sender,
|
|
"Mailbox/set",
|
|
json!({"create": {"m": {"name": "Hold drafts"}}}),
|
|
)
|
|
.await;
|
|
let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string();
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:MailRule/set",
|
|
json!({"create": {"h": {
|
|
"name": "Hold cards", "kind": "dlp", "direction": "outgoing",
|
|
"conditions": [{"type": "words", "words": ["hold-me"]},
|
|
{"type": "detected", "detectors": [{"id": "payment-card"}]}],
|
|
"actions": [{"type": "hold", "notice": "Card numbers are reviewed first.", "notifySender": true}]
|
|
}}}),
|
|
)
|
|
.await;
|
|
let rule = response["created"]["h"]["id"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("{response}"))
|
|
.to_string();
|
|
let body = "hold-me: card 4242 4242 4242 4242";
|
|
|
|
// Accepted, held, listed
|
|
let response = submit(
|
|
&sender,
|
|
&identity,
|
|
&mailbox,
|
|
&["[email protected]"],
|
|
"Held one",
|
|
body,
|
|
None,
|
|
)
|
|
.await;
|
|
let submission = response["created"]["s"]["id"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("held, not refused: {response}"))
|
|
.to_string();
|
|
assert_eq!(response["created"]["s"]["inbuxa:held"], true, "{response}");
|
|
let (_, response) = call(&admin, "inbuxa:HeldMessage/get", json!({"ids": null})).await;
|
|
let list = response["list"]
|
|
.as_array()
|
|
.unwrap_or_else(|| panic!("{response}"));
|
|
assert_eq!(list.len(), 1, "{response}");
|
|
let first = list[0]["id"].as_str().unwrap().to_string();
|
|
assert_eq!(list[0]["sender"], "[email protected]");
|
|
assert_eq!(list[0]["subject"], "Held one");
|
|
assert_eq!(list[0]["rules"][0]["name"], "Hold cards");
|
|
assert_eq!(
|
|
list[0]["counts"],
|
|
json!([{"detector": "words", "count": 1}, {"detector": "payment-card", "count": 1}])
|
|
);
|
|
assert!(
|
|
list[0].get("preview").is_none(),
|
|
"no preview unless asked for"
|
|
);
|
|
|
|
// The sender is told, and it isn't delivered
|
|
let notice = received(
|
|
&sender,
|
|
"Held for review: Held one",
|
|
"X-Flow",
|
|
Some(&mailbox),
|
|
)
|
|
.await;
|
|
assert!(
|
|
notice[0].1.contains("Card numbers are reviewed first."),
|
|
"{notice:?}"
|
|
);
|
|
assert!(
|
|
received_now(&sender, "Held one", &mailbox)
|
|
.await
|
|
.iter()
|
|
.all(|s| s.starts_with("Held for review"))
|
|
);
|
|
|
|
// Not around the review: not from the queue, not by unsending
|
|
let (_, response) = call(
|
|
&admin,
|
|
"x:QueuedMessage/set",
|
|
json!({"update": {first.as_str(): {"nextRetry": "2026-01-01T00:00:00Z"}}}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
response["notUpdated"][first.as_str()]["description"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("held for review"),
|
|
"{response}"
|
|
);
|
|
let (_, response) = call(&admin, "x:QueuedMessage/set", json!({"destroy": [first]})).await;
|
|
assert!(
|
|
response["notDestroyed"].get(first.as_str()).is_some(),
|
|
"{response}"
|
|
);
|
|
let (_, response) = call(
|
|
&sender,
|
|
"EmailSubmission/set",
|
|
json!({"update": {submission.as_str(): {"undoStatus": "canceled"}}}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response["notUpdated"][submission.as_str()]["type"],
|
|
"cannotUnsend",
|
|
"{response}"
|
|
);
|
|
|
|
// Reading it is recorded
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:HeldMessage/get",
|
|
json!({"ids": [first], "properties": ["id", "preview"]}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
response["list"][0]["preview"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("4242 4242"),
|
|
"{response}"
|
|
);
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:AuditEvent/query",
|
|
json!({"filter": {"targetKind": "inbuxa:HeldMessage", "action": "blobAccess"}}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response["ids"].as_array().map(|i| i.len()),
|
|
Some(1),
|
|
"{response}"
|
|
);
|
|
|
|
// Rejected: a reason is required; the sender gets the note
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:HeldMessage/set",
|
|
json!({"update": {first.as_str(): {"decision": "reject"}}}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
response["notUpdated"].get(first.as_str()).is_some(),
|
|
"no reason: {response}"
|
|
);
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:HeldMessage/set",
|
|
json!({"reason": "Card data may not leave by mail", "update": {first.as_str(): {"decision": "reject", "note": "Use the payments portal."}}}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
response["updated"].get(first.as_str()).is_some(),
|
|
"{response}"
|
|
);
|
|
let notice = received(&sender, "Not sent: Held one", "X-Flow", Some(&mailbox)).await;
|
|
assert!(
|
|
notice[0].1.contains("Use the payments portal."),
|
|
"{notice:?}"
|
|
);
|
|
let (_, response) = call(&admin, "x:QueuedMessage/get", json!({"ids": [first]})).await;
|
|
assert_eq!(
|
|
response["notFound"][0],
|
|
first.as_str(),
|
|
"gone from the queue: {response}"
|
|
);
|
|
|
|
// Released: delivered
|
|
let response = submit(
|
|
&sender,
|
|
&identity,
|
|
&mailbox,
|
|
&["[email protected]"],
|
|
"Held two",
|
|
body,
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(response["created"].get("s").is_some(), "{response}");
|
|
let (_, response) = call(&admin, "inbuxa:HeldMessage/get", json!({"ids": null})).await;
|
|
let second = response["list"][0]["id"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("{response}"))
|
|
.to_string();
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:HeldMessage/set",
|
|
json!({"reason": "Finance approved", "update": {second.as_str(): {"decision": "release"}}}),
|
|
)
|
|
.await;
|
|
assert!(
|
|
response["updated"].get(second.as_str()).is_some(),
|
|
"{response}"
|
|
);
|
|
let mut delivered = Vec::new();
|
|
for _ in 0..50 {
|
|
delivered = received_now(&sender, "Held two", &mailbox).await;
|
|
if delivered.iter().any(|s| s == "Held two") {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
}
|
|
assert!(delivered.iter().any(|s| s == "Held two"), "{delivered:?}");
|
|
let (_, response) = call(&admin, "inbuxa:HeldMessage/get", json!({"ids": null})).await;
|
|
assert_eq!(
|
|
response["list"].as_array().map(|l| l.len()),
|
|
Some(0),
|
|
"{response}"
|
|
);
|
|
|
|
// Both decisions are in the audit log, with their reasons
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:AuditEvent/query",
|
|
json!({"filter": {"targetKind": "inbuxa:HeldMessage", "action": "update"}}),
|
|
)
|
|
.await;
|
|
let ids = response["ids"].clone();
|
|
let (_, response) = call(&admin, "inbuxa:AuditEvent/get", json!({"ids": ids})).await;
|
|
let reasons: Vec<&str> = response["list"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter_map(|e| e["reason"].as_str())
|
|
.collect();
|
|
assert!(
|
|
reasons.contains(&"Card data may not leave by mail")
|
|
&& reasons.contains(&"Finance approved"),
|
|
"{response}"
|
|
);
|
|
|
|
// Unreviewed: returned by the daily clean-up once its time is up
|
|
let response = submit(
|
|
&sender,
|
|
&identity,
|
|
&mailbox,
|
|
&["[email protected]"],
|
|
"Held three",
|
|
body,
|
|
None,
|
|
)
|
|
.await;
|
|
assert!(response["created"].get("s").is_some(), "{response}");
|
|
let store = test.server.store();
|
|
let mut record = inbuxa_features::mailflow::held::all(store)
|
|
.await
|
|
.unwrap()
|
|
.pop()
|
|
.expect("held");
|
|
record.expires_at = 0;
|
|
inbuxa_features::mailflow::held::create(store, &record)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(smtp::queue::held::expire(&test.server).await.unwrap(), 1);
|
|
assert!(
|
|
inbuxa_features::mailflow::held::all(store)
|
|
.await
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
let notice = received(&sender, "Not sent: Held three", "X-Flow", Some(&mailbox)).await;
|
|
assert!(
|
|
notice[0].1.contains("Nobody reviewed it within 7 days"),
|
|
"{notice:?}"
|
|
);
|
|
let (_, response) = call(
|
|
&admin,
|
|
"inbuxa:AuditEvent/query",
|
|
json!({"filter": {"targetKind": "inbuxa:HeldMessage", "action": "destroy"}}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
response["ids"].as_array().map(|i| i.len()),
|
|
Some(1),
|
|
"expiry recorded: {response}"
|
|
);
|
|
|
|
call(&admin, "inbuxa:MailRule/set", json!({"destroy": [rule]})).await;
|
|
}
|
|
|
|
/// Subjects in `account` matching `text` right now, not in `drafts`.
|
|
async fn received_now(account: &Account, text: &str, drafts: &str) -> Vec<String> {
|
|
let (_, response) = call(
|
|
account,
|
|
"Email/query",
|
|
json!({"filter": {"subject": text, "inMailboxOtherThan": [drafts]}}),
|
|
)
|
|
.await;
|
|
let ids = response["ids"].clone();
|
|
let (_, response) = call(
|
|
account,
|
|
"Email/get",
|
|
json!({"ids": ids, "properties": ["subject"]}),
|
|
)
|
|
.await;
|
|
response["list"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|e| e["subject"].as_str().unwrap_or_default().to_string())
|
|
.collect()
|
|
}
|
|
|
|
#[ignore]
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
pub async fn mail_rules_tests() {
|
|
let mut test = TestServerBuilder::new("mail_rules_tests")
|
|
.await
|
|
.with_default_listeners()
|
|
.await
|
|
.build()
|
|
.await;
|
|
let admin = test.create_admin_account("[email protected]").await;
|
|
test.insert_account(admin);
|
|
self::test(&mut test).await;
|
|
self::dlp(&mut test).await;
|
|
self::transport(&mut test).await;
|
|
self::hold(&mut test).await;
|
|
if test.is_reset() {
|
|
test.temp_dir.delete();
|
|
}
|
|
}
|