Shared mailboxes: a second kind of account lock
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:
1 parent
5f6548bfdd
commit
9976d52e29
14 files changed
+382
-36
No files matched your search
@@ -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) =
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user