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

+14 -9
View File
@@ -204,15 +204,20 @@ impl RequestHandler for Server {
// inbuxa: AL-9: a delegate's access, and what it
// changes, are recorded; anyone else here impersonated
if let Some(delegation) = access_token.delegation(account_id) {
let access = delegation.access.as_str();
self.audit_delegate(
access_token,
account_id,
access,
is_write.then_some(call_name.as_str()),
result.as_ref().err(),
)
.await;
// MA-S: in a shared mailbox only what is sent as it
// is recorded (audit_send_as); every read and flag
// on a busy desk would bury the log
if delegation.kind.is_lock() {
let access = delegation.access.as_str();
self.audit_delegate(
access_token,
account_id,
access,
is_write.then_some(call_name.as_str()),
result.as_ref().err(),
)
.await;
}
if makes_containers
&& result.is_ok()
&& let Err(err) =
+1
View File
@@ -148,6 +148,7 @@ impl SessionHandler for Server {
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
delegation: DelegationInfo {
locked: true,
kind: delegation.kind.as_str(),
access: delegation.access.as_str(),
send_as: delegation.send_as,
until: delegation.until.map(|until| {
+38 -11
View File
@@ -15,7 +15,7 @@ use common::{
};
use email::inbuxa_lock::apply_grants;
use groupware::inbuxa_lock::invalidate;
use inbuxa_features::lock::{self, Access, Delegate, Lock, MAX_DELEGATES};
use inbuxa_features::lock::{self, Access, Delegate, Kind, Lock};
use jmap_proto::{
error::set::SetError,
method::{
@@ -39,6 +39,7 @@ const ALL: &[P] = &[
P::Id,
P::AccountId,
P::Name,
P::Kind,
P::Reason,
P::LockedAt,
P::LockedBy,
@@ -75,6 +76,7 @@ async fn parse_delegates(
server: &Server,
access_token: &AccessToken,
locked_id: u32,
kind: Kind,
value: LValue,
) -> Result<Vec<Delegate>, SetError<P>> {
let invalid = |why: String| {
@@ -86,8 +88,10 @@ async fn parse_delegates(
let Some(items) = json.as_array() else {
return Err(invalid("delegates must be a list.".into()));
};
if items.len() > MAX_DELEGATES {
return Err(invalid(format!("At most {MAX_DELEGATES} delegates.")));
// MA-S: a shared mailbox holds more people than a lock hands over
let max = kind.max_delegates();
if items.len() > max {
return Err(invalid(format!("At most {max} delegates.")));
}
let locked_tenant = server.account(locked_id).await.ok().and_then(|a| a.id_tenant);
let mut delegates: Vec<Delegate> = Vec::with_capacity(items.len());
@@ -173,6 +177,7 @@ async fn to_value(server: &Server, lock: &Lock, properties: &[P]) -> LValue {
let value = match property {
P::Id | P::AccountId => Value::Element(AccountLockValue::Id(Id::from(lock.account_id))),
P::Name => Value::Str(server.audit_account_name(lock.account_id).await.into()),
P::Kind => Value::Str(Cow::Borrowed(lock.kind.as_str())),
P::Reason => Value::Str(lock.reason.clone().into()),
P::LockedAt => date(lock.locked_at),
P::LockedBy => Value::Str(lock.locked_by.clone().into()),
@@ -283,6 +288,7 @@ pub async fn set(
for (client_id, value) in request.unwrap_create() {
let mut account_id = None;
let mut kind = Kind::Lock;
let mut reason = None;
let mut delegates_value = None;
let mut invalid = None;
@@ -291,6 +297,17 @@ pub async fn set(
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => {
account_id = Some(id.document_id())
}
(Key::Property(P::Kind), Value::Str(k)) => match Kind::parse(&k) {
Some(k) => kind = k,
None => {
invalid = Some(
SetError::invalid_properties()
.with_property(P::Kind)
.with_description("kind must be lock or sharedMailbox."),
);
break;
}
},
(Key::Property(P::Reason), Value::Str(r)) => reason = reason_of(Some(&r)),
(Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()),
_ => {
@@ -310,9 +327,14 @@ pub async fn set(
);
continue;
};
let Some(reason) = reason.or_else(|| reason_of(arguments.reason.as_deref())) else {
response.not_created.append(client_id, reason_required());
continue;
// MA-S: a shared mailbox needs no reason; a lock always does
let reason = match reason.or_else(|| reason_of(arguments.reason.as_deref())) {
Some(reason) => reason,
None if kind == Kind::SharedMailbox => String::new(),
None => {
response.not_created.append(client_id, reason_required());
continue;
}
};
if let Err(error) = assert_reach(server, access_token, account_id).await {
response.not_created.append(client_id, error);
@@ -321,12 +343,13 @@ pub async fn set(
if lock::get(data, account_id).await?.is_some() {
response.not_created.append(
client_id,
SetError::already_exists().with_description("That account is already locked."),
SetError::already_exists()
.with_description("That account is already locked or a shared mailbox."),
);
continue;
}
let delegates = match delegates_value {
Some(value) => match parse_delegates(server, access_token, account_id, value).await {
Some(value) => match parse_delegates(server, access_token, account_id, kind, value).await {
Ok(delegates) => delegates,
Err(error) => {
response.not_created.append(client_id, error);
@@ -337,6 +360,7 @@ pub async fn set(
};
let mut created = Lock {
account_id,
kind,
reason,
locked_at: now(),
locked_by: actor.name.clone(),
@@ -370,7 +394,7 @@ pub async fn set(
response.not_updated.append(id, SetError::not_found());
continue;
};
if reason_of(arguments.reason.as_deref()).is_none() {
if current.kind.is_lock() && reason_of(arguments.reason.as_deref()).is_none() {
response.not_updated.append(id, reason_required());
continue;
}
@@ -379,7 +403,9 @@ pub async fn set(
for (key, value) in value.into_expanded_object() {
match (&key, value) {
(Key::Property(P::Delegates), value) => {
match parse_delegates(server, access_token, account_id, value.into_owned()).await {
match parse_delegates(server, access_token, account_id, current.kind, value.into_owned())
.await
{
Ok(delegates) => updated.delegates = delegates,
Err(error) => {
invalid = Some(error);
@@ -389,6 +415,7 @@ pub async fn set(
}
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
Some(r) => updated.reason = r,
None if !current.kind.is_lock() => updated.reason = String::new(),
None => {
invalid = Some(reason_required());
break;
@@ -420,7 +447,7 @@ pub async fn set(
response.not_destroyed.append(id, SetError::not_found());
continue;
};
if reason_of(arguments.reason.as_deref()).is_none() {
if current.kind.is_lock() && reason_of(arguments.reason.as_deref()).is_none() {
response.not_destroyed.append(id, reason_required());
continue;
}
+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() {