Shared mailboxes: a second kind of account lock
ci / github (pull_request) Skipped
ci / fork-checks (pull_request) Successful in 1m8s
ci / build (pull_request) Successful in 4m30s
github/ci (branch) GitHub Actions

A shared mailbox (support@, legal@) belongs to no one person: nobody
signs in to it, and the people assigned open it beside their own mail
at an access level an administrator chose. An account lock already is
most of that: it keeps receiving mail, refuses every sign-in, and its
delegates reach it through real grants on every container (so IMAP,
DAV and JMAP honor them), never including Share. So a shared mailbox is
a lock of a second kind (multi-account spec, MA-S; John, 2026-10-05).

Lock gains kind: "lock" (the default, so stored locks read as before)
or "sharedMailbox", set on create and fixed after. A shared mailbox:

- needs no reason to make, change or end;
- holds up to 100 people, where a lock holds 10;
- runs its own Sieve replies and redirects, so an automatic
  acknowledgement goes out (a lock answers no one);
- records only what is sent as it (audit_send_as, which now covers it),
  not AL-9's access and per-change records, which would bury the log
  for a busy desk;
- sends only as itself (MA-S3): From and Reply-To must be its own
  addresses, so answers come back to the mailbox and not to whoever
  replied; anything else is forbiddenFrom.

The session marks it delegation: {locked: true, kind: "sharedMailbox"},
so a front end that knows no kind still treats it as a lock. The
console's layout gains Management › Directory › Shared Mailboxes
(CustomComponent/SharedMailboxes).

Tests: the account lock suite now goes on to a shared mailbox: made
without a reason with twelve people, sign-in refused, the session's
kind, its vacation reply delivered, an answer sent as it and recorded
as the agent with no per-change records, and a Reply-To naming the
agent refused; a lock unit test reads a stored lock without a kind.
account_lock_tests, jmap_tests, audit_log_tests and imap_tests pass
(RocksDB).
This commit is contained in:
jcoffey-dev committed 2026-10-05 15:27:52 -07:00
1 parent 5f6548bfdd
commit 9976d52e29
14 files changed
+382 -36

No files matched your search

+50 -1
View File
@@ -58,6 +58,7 @@ pub trait EmailSubmissionSet: Sync + Send {
fn send_message(
&self,
account_id: u32,
own_addresses_only: bool,
response: &SetResponse<email_submission::EmailSubmission>,
instance: &Arc<ServerInstance>,
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
@@ -83,7 +84,14 @@ impl EmailSubmissionSet for Server {
let mut batch = BatchBuilder::new();
for (id, object) in request.unwrap_create() {
match self
.send_message(account_id, &response, instance, object)
.send_message(
account_id,
// inbuxa: MA-S3: a shared mailbox's people send only as it
access_token.delegated_shared_mailbox(account_id),
&response,
instance,
object,
)
.await?
{
Ok(submission) => {
@@ -400,6 +408,7 @@ impl EmailSubmissionSet for Server {
async fn send_message(
&self,
account_id: u32,
own_addresses_only: bool,
response: &SetResponse<email_submission::EmailSubmission>,
instance: &Arc<ServerInstance>,
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
@@ -629,6 +638,46 @@ impl EmailSubmissionSet for Server {
.unarchive::<MessageMetadata>()
.caused_by(trc::location!())?;
// inbuxa: MA-S3: mail that came to a shared mailbox goes out as it,
// so the answer comes back to the mailbox and not to whoever sent
// it: every From and Reply-To address must be the mailbox's own
if own_addresses_only {
let mut named = Vec::new();
for header in metadata.contents[0].parts[0].headers.iter() {
if !matches!(
header.name,
ArchivedMetadataHeaderName::From | ArchivedMetadataHeaderName::ReplyTo
) {
continue;
}
match &header.value {
ArchivedMetadataHeaderValue::AddressList(addr) => {
named.extend(addr.iter().filter_map(|a| a.address.as_ref().map(|v| v.to_string())));
}
ArchivedMetadataHeaderValue::AddressGroup(groups) => {
for group in groups.iter() {
named.extend(
group
.addresses
.iter()
.filter_map(|a| a.address.as_ref().map(|v| v.to_string())),
);
}
}
_ => {}
}
}
for address in named {
if self.account_id_from_email(&address, true).await? != Some(account_id) {
return Ok(Err(SetError::new(SetErrorType::ForbiddenFrom).with_description(
format!(
"A shared mailbox sends only as its own addresses, so replies come back to it; {address} isn't one."
),
)));
}
}
}
// Add recipients to envelope if missing
let mut bcc_header = None;
if rcpt_to.is_empty() {