Audit mail sent from an address that isn't the sender's own
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 48s
ci / build (pull_request) Successful in 8m50s
github/ci (branch) GitHub Actions

A group's members can send as the group, and the message says only
From: the group, so nothing recorded which person sent it. Every
submission whose envelope sender belongs to another account now writes
an audit record: the person as actor, an EmailSubmission target named
by the address and owned by that account, and "Sent as <address>",
with ", from <account>" when it went out through the sender's own
account rather than the group's.

A delegate's send is left to AL-9's record, and a send from the
sender's own address writes nothing. No Sender: header is added: the
audit log is where the real sender is named. email_submission_set now
takes the access token, from its one caller.

The audit suite has a group member send once as the group (one
record, with the address, account and details) and once as themselves
(none) (specs/multi-account.md, MA-D0a, G2).
This commit is contained in:
jcoffey-dev committed 2026-10-05 14:07:25 -07:00
1 parent d7bebd454d
commit 76c170db9d
4 files changed
+165 -1

No files matched your search

+52
View File
@@ -445,6 +445,58 @@ impl Server {
} }
} }
/// MA-D0a: a message sent from an address that isn't the sender's own:
/// a group's, today. The message itself only says `From:` the group, so
/// the audit log is where the person who sent it is named. A delegate's
/// send is AL-9's record, not this one.
pub async fn audit_send_as(
&self,
token: &AccessToken,
submission_account_id: u32,
submission_id: u32,
address: &str,
) {
let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else {
return;
};
if as_account_id == token.account_id() || token.delegation(as_account_id).is_some() {
return;
}
let actor = self.audit_actor(token).await;
let tenant_id = self
.account(as_account_id)
.await
.ok()
.and_then(|account| account.id_tenant);
let details = if submission_account_id == as_account_id {
format!("Sent as {address}")
} else {
format!(
"Sent as {address}, from {}",
self.audit_account_name(submission_account_id).await
)
};
self.audit_note(Record {
at: ms(),
actor,
via: token.origin().cloned(),
remote_ip: None,
action: Action::Create,
target: Target {
kind: "EmailSubmission".into(),
id: Some(Id::from(submission_id).to_string()),
name: Some(address.to_string()),
account_id: Some(as_account_id),
tenant_id,
},
changes: vec![],
details: Some(details),
reason: None,
outcome: Outcome::success(),
})
.await;
}
/// AU-7: removes entries past the retention period. /// AU-7: removes entries past the retention period.
pub async fn audit_purge(&self) -> trc::Result<usize> { pub async fn audit_purge(&self) -> trc::Result<usize> {
let settings = log::settings(self.store()).await?; let settings = log::settings(self.store()).await?;
+1 -1
View File
@@ -783,7 +783,7 @@ impl RequestHandler for Server {
// inbuxa: AL-8: a delegate may send as a locked account // inbuxa: AL-8: a delegate may send as a locked account
access_token.assert_can_send(req.account_id)?; access_token.assert_can_send(req.account_id)?;
self.email_submission_set(*req, &session.instance, next_call) self.email_submission_set(*req, access_token, &session.instance, next_call)
.await? .await?
.into() .into()
} }
+12
View File
@@ -8,6 +8,7 @@
use common::{ use common::{
Server, Server,
auth::AccessToken,
config::smtp::queue::QueueName, config::smtp::queue::QueueName,
network::{ServerInstance, stream::NullIo}, network::{ServerInstance, stream::NullIo},
storage::index::ObjectIndexBuilder, storage::index::ObjectIndexBuilder,
@@ -49,6 +50,7 @@ pub trait EmailSubmissionSet: Sync + Send {
fn email_submission_set<'x>( fn email_submission_set<'x>(
&self, &self,
request: SetRequest<'x, email_submission::EmailSubmission>, request: SetRequest<'x, email_submission::EmailSubmission>,
access_token: &AccessToken,
instance: &Arc<ServerInstance>, instance: &Arc<ServerInstance>,
next_call: &mut Option<Call<RequestMethod<'x>>>, next_call: &mut Option<Call<RequestMethod<'x>>>,
) -> impl Future<Output = trc::Result<SetResponse<email_submission::EmailSubmission>>> + Send; ) -> impl Future<Output = trc::Result<SetResponse<email_submission::EmailSubmission>>> + Send;
@@ -68,6 +70,7 @@ impl EmailSubmissionSet for Server {
async fn email_submission_set<'x>( async fn email_submission_set<'x>(
&self, &self,
mut request: SetRequest<'x, email_submission::EmailSubmission>, mut request: SetRequest<'x, email_submission::EmailSubmission>,
access_token: &AccessToken,
instance: &Arc<ServerInstance>, instance: &Arc<ServerInstance>,
next_call: &mut Option<Call<RequestMethod<'x>>>, next_call: &mut Option<Call<RequestMethod<'x>>>,
) -> trc::Result<SetResponse<email_submission::EmailSubmission>> { ) -> trc::Result<SetResponse<email_submission::EmailSubmission>> {
@@ -110,6 +113,15 @@ impl EmailSubmissionSet for Server {
.assign_document_ids(account_id, Collection::EmailSubmission, 1) .assign_document_ids(account_id, Collection::EmailSubmission, 1)
.await .await
.caused_by(trc::location!())?; .caused_by(trc::location!())?;
// inbuxa: MA-D0a: who sent it, when it went out as someone else
self.audit_send_as(
access_token,
account_id,
document_id,
&submission.envelope.mail_from.email,
)
.await;
batch batch
.with_account_id(account_id) .with_account_id(account_id)
.with_collection(Collection::EmailSubmission) .with_collection(Collection::EmailSubmission)
+100
View File
@@ -540,10 +540,110 @@ pub async fn test(test: &mut TestServer) {
"AU-7: continued" "AU-7: continued"
); );
// MA-D0a: a group member sending as the group is named in the log; the
// same person sending as themselves isn't recorded
let group = admin
.create_group_account("[email protected]", "Help desk", &[])
.await;
let agent = admin
.create_user_account(
"[email protected]",
"agent-secret-for-send-as",
"Agent",
&[],
vec![],
)
.await;
admin
.registry_update_object(
ObjectType::Account,
agent.id(),
json!({"memberGroupIds": {group.id_string(): true}}),
)
.await;
agent.send_as("[email protected]").await;
agent.send_as("[email protected]").await;
let sends = admin
.audit(json!({
"targetKind": "EmailSubmission",
"actorId": agent.id_string(),
}))
.await;
assert_eq!(sends.len(), 1, "MA-D0a: {sends:?}");
assert_eq!(sends[0]["target"]["name"], "[email protected]");
assert_eq!(
sends[0]["target"]["accountId"],
group.id_string(),
"MA-D0a: {}",
sends[0]
);
assert_eq!(
sends[0]["details"],
"Sent as [email protected], from [email protected]"
);
// Clean up what later suites could trip over // Clean up what later suites could trip over
admin.registry_destroy_all(ObjectType::BlockedIp).await; admin.registry_destroy_all(ObjectType::BlockedIp).await;
} }
impl Account {
/// Sends one message to itself from its own account, as `from`.
async fn send_as(&self, from: &str) {
const USING: &[&str] = &[
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission",
];
let account_id = self.id_string();
let response = self
.jmap_request(
USING,
json!([
["Identity/get", {"accountId": account_id}, "i"],
["Mailbox/get", {"accountId": account_id, "properties": ["role"]}, "m"]
]),
)
.await;
let identity = response
.0
.pointer("/methodResponses/0/1/list")
.and_then(Value::as_array)
.and_then(|list| list.iter().find(|identity| identity["email"] == from))
.unwrap_or_else(|| panic!("no identity for {from}: {}", response.0))["id"]
.clone();
let drafts = response
.0
.pointer("/methodResponses/1/1/list")
.and_then(Value::as_array)
.and_then(|list| list.iter().find(|mailbox| mailbox["role"] == "drafts"))
.unwrap_or_else(|| panic!("no drafts: {}", response.0))["id"]
.clone();
let response = self
.jmap_request(
USING,
json!([
["Email/set", {"accountId": account_id, "create": {"m": {
"mailboxIds": {drafts.as_str().unwrap(): true},
"from": [{"email": from}],
"to": [{"email": self.name()}],
"subject": format!("Sent as {from}"),
"bodyValues": {"t": {"value": "MA-D0a"}},
"textBody": [{"partId": "t", "type": "text/plain"}]
}}}, "e"],
["EmailSubmission/set", {"accountId": account_id, "create": {"s": {
"identityId": identity, "emailId": "#m"
}}}, "s"]
]),
)
.await;
assert!(
response.0.pointer("/methodResponses/1/1/created/s").is_some(),
"send as {from}: {}",
response.0
);
}
}
/// Runs these tests alone: `cargo test -p tests audit_log_tests -- --ignored`. /// Runs these tests alone: `cargo test -p tests audit_log_tests -- --ignored`.
#[ignore] #[ignore]
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]