Merge branch 'main' into fix/group-collections-no-onward-share
This commit is contained in:
commit
461f5fab3c
34 files changed
+1460
-39
No files matched your search
@@ -130,6 +130,10 @@ impl JmapAuthorization for AccessToken {
|
||||
// sign-in on the tenant's domains, so it takes the domain's
|
||||
// permissions, which a tenant administrator already holds.
|
||||
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
|
||||
// inbuxa: MA-C, who may share mail: a tenant administrator
|
||||
// manages their tenant's, so the domain's permissions; the
|
||||
// server's own also needs sysSharingUpdate (see the method)
|
||||
GetRequestMethod::SharingPolicy(_) => Permission::SysDomainGet,
|
||||
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
|
||||
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
||||
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
||||
@@ -370,6 +374,14 @@ impl JmapAuthorization for AccessToken {
|
||||
Permission::SysDomainUpdate,
|
||||
Permission::SysDomainUpdate,
|
||||
),
|
||||
// inbuxa: MA-C, who may share mail, with the domain's
|
||||
SetRequestMethod::SharingPolicy(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysDomainUpdate,
|
||||
Permission::SysDomainUpdate,
|
||||
Permission::SysDomainUpdate,
|
||||
),
|
||||
SetRequestMethod::VacationResponse(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
@@ -500,7 +512,8 @@ impl JmapAuthorization for AccessToken {
|
||||
| MethodObject::JournalExport
|
||||
| MethodObject::JournalVerification
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| MethodObject::SharingPolicy => Permission::JmapEmailChanges,
|
||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||
MethodObject::Registry(object_type) => object_type.get_permission(),
|
||||
},
|
||||
|
||||
@@ -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) =
|
||||
@@ -312,6 +317,9 @@ impl RequestHandler for Server {
|
||||
SetResponseMethod::TenantProtocolPolicy(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::SharingPolicy(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::AddressBook(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
@@ -569,6 +577,13 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: inbuxa:SharingPolicy/get (MA-C, who may share mail)
|
||||
GetRequestMethod::SharingPolicy(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::sharing_policy::get(self, access_token, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
GetRequestMethod::Principal(req) => {
|
||||
self.principal_get(*req, access_token).await?.into()
|
||||
}
|
||||
@@ -1127,6 +1142,23 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: inbuxa:SharingPolicy/set (MA-C, who may share mail)
|
||||
SetRequestMethod::SharingPolicy(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
// inbuxa: AU-1.2, AU-3
|
||||
crate::inbuxa::audit::recorded(
|
||||
self,
|
||||
access_token,
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
None,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::sharing_policy::set(self, access_token, req)),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::AddressBook(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
||||
|
||||
@@ -80,6 +80,13 @@ impl SessionHandler for Server {
|
||||
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
||||
&& access_token.tenant_id().is_none()
|
||||
&& self.ai_explain_model(&self.ai_limits().await).await.is_some();
|
||||
// inbuxa: MA-C: what the sharing switches leave this principal
|
||||
let sharing = inbuxa_features::security::sharing_policy::effective_for(
|
||||
self.store(),
|
||||
access_token.tenant_id(),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
account.account_capabilities.append(
|
||||
Capability::Inbuxa,
|
||||
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
||||
@@ -87,6 +94,8 @@ impl SessionHandler for Server {
|
||||
legacy_protocols,
|
||||
legacy_allowed,
|
||||
ai_explain,
|
||||
mail_sharing: sharing.mail_sharing,
|
||||
add_accounts: sharing.add_accounts,
|
||||
}),
|
||||
);
|
||||
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
|
||||
@@ -148,6 +157,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| {
|
||||
|
||||
@@ -439,6 +439,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::HeldMessage
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| MethodObject::SharingPolicy
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ pub mod webhook_test;
|
||||
pub mod explanation;
|
||||
pub mod protocol_policy;
|
||||
pub mod tenant_protocol_policy;
|
||||
pub mod sharing_policy;
|
||||
pub mod deleted_account;
|
||||
pub mod fastmail;
|
||||
pub mod masked_email;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:SharingPolicy/get` and `/set`: whether people may share their own
|
||||
//! mail and add other accounts to the webmail (multi-account spec, MA-C).
|
||||
//!
|
||||
//! The server's policy has the singleton id; each tenant's has the tenant's
|
||||
//! id. At server level `/get` with no ids answers with the server's and every
|
||||
//! tenant's; inside a tenant, with the server's (to read) and its own tenant's
|
||||
//! (MT-1). Only a server administrator holding `sysSharingUpdate` changes the
|
||||
//! server's; a tenant's administrator changes their tenant's, and can only be
|
||||
//! stricter than the server.
|
||||
//!
|
||||
//! A change rebuilds every access token, here and on every node: what a share
|
||||
//! still gives is worked out when a token is built.
|
||||
|
||||
use common::{Server, auth::AccessToken, ipc::BroadcastEvent};
|
||||
use inbuxa_features::{
|
||||
security::sharing_policy::{self, SharingPolicy as Policy, looser_than_server},
|
||||
tenancy::quota::all_tenants,
|
||||
};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::inbuxa_sharing_policy::{SharingPolicy, SharingPolicyProperty as P, SharingPolicyValue},
|
||||
request::IntoValid,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use registry::schema::enums::Permission;
|
||||
use types::id::Id;
|
||||
|
||||
type PValue = Value<'static, P, SharingPolicyValue>;
|
||||
|
||||
const ALL: &[P] = &[P::Id, P::TenantId, P::MailSharing, P::AddAccounts, P::ChangedAt, P::ChangedBy];
|
||||
|
||||
fn switch_str(on: Option<bool>) -> &'static str {
|
||||
if on.unwrap_or(true) { "enabled" } else { "disabled" }
|
||||
}
|
||||
|
||||
fn to_value(tenant_id: Option<u32>, policy: &Policy, properties: &[P]) -> PValue {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(SharingPolicyValue::Id(
|
||||
tenant_id.map_or_else(Id::singleton, Id::from),
|
||||
)),
|
||||
P::TenantId => tenant_id
|
||||
.map(|t| Value::Element(SharingPolicyValue::Id(Id::from(t))))
|
||||
.unwrap_or(Value::Null),
|
||||
P::MailSharing => Value::Str(switch_str(policy.mail_sharing).into()),
|
||||
P::AddAccounts => Value::Str(switch_str(policy.add_accounts).into()),
|
||||
P::ChangedAt => policy
|
||||
.changed_at
|
||||
.map(|at| Value::Number(at.into()))
|
||||
.unwrap_or(Value::Null),
|
||||
P::ChangedBy => policy
|
||||
.changed_by
|
||||
.as_ref()
|
||||
.map(|by| Value::Str(by.clone().into()))
|
||||
.unwrap_or(Value::Null),
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// The tenants this principal may reach: its own inside a tenant (MT-1),
|
||||
/// every tenant at server level.
|
||||
async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<Vec<u32>> {
|
||||
match access_token.tenant_id() {
|
||||
Some(tenant_id) => Ok(vec![tenant_id]),
|
||||
None => all_tenants(server.registry()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which policy an id names: `None` for the server's.
|
||||
fn target(id: Id) -> Option<u32> {
|
||||
if id.is_singleton() { None } else { Some(id.document_id()) }
|
||||
}
|
||||
|
||||
/// `inbuxa:SharingPolicy/get`.
|
||||
pub async fn get(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: GetRequest<SharingPolicy>,
|
||||
) -> trc::Result<GetResponse<SharingPolicy>> {
|
||||
let properties = request.unwrap_properties(ALL);
|
||||
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::new(),
|
||||
not_found,
|
||||
};
|
||||
let reachable = reachable(server, access_token).await?;
|
||||
let wanted: Vec<Id> = match ids {
|
||||
None => std::iter::once(Id::singleton())
|
||||
.chain(reachable.iter().map(|t| Id::from(*t)))
|
||||
.collect(),
|
||||
Some(ids) => ids,
|
||||
};
|
||||
let data = &server.core.storage.data;
|
||||
for id in wanted {
|
||||
match target(id) {
|
||||
None => {
|
||||
let policy = sharing_policy::get(data, None).await?;
|
||||
response.list.push(to_value(None, &policy, &properties));
|
||||
}
|
||||
Some(tenant_id) if reachable.contains(&tenant_id) => {
|
||||
let policy = sharing_policy::get(data, Some(tenant_id)).await?;
|
||||
response.list.push(to_value(Some(tenant_id), &policy, &properties));
|
||||
}
|
||||
Some(_) => response.push_not_found(id),
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// `inbuxa:SharingPolicy/set`: turns switches. `null` puts one back to its
|
||||
/// default, on (as far as the server allows).
|
||||
pub async fn set(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, SharingPolicy>,
|
||||
) -> trc::Result<SetResponse<SharingPolicy>> {
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
for (client_id, _) in request.unwrap_create() {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
SetError::forbidden().with_description("A sharing policy exists with the server or the tenant."),
|
||||
);
|
||||
}
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("A sharing policy exists with the server or the tenant."),
|
||||
);
|
||||
}
|
||||
|
||||
let reachable = reachable(server, access_token).await?;
|
||||
let data = &server.core.storage.data;
|
||||
let mut changed = false;
|
||||
for (id, value) in request.unwrap_update().into_valid() {
|
||||
let tenant_id = target(id);
|
||||
match tenant_id {
|
||||
None if access_token.tenant_id().is_some()
|
||||
|| !access_token.has_permission(Permission::SysSharingUpdate) =>
|
||||
{
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden()
|
||||
.with_description("Only a server administrator changes the server's sharing policy."),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Some(tenant_id) if !reachable.contains(&tenant_id) => {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let previous = sharing_policy::get(data, tenant_id).await?;
|
||||
let mut policy = previous.clone();
|
||||
let mut error = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let parsed = match value {
|
||||
Value::Null => Ok(None),
|
||||
Value::Str(s) if s == "enabled" => Ok(Some(true)),
|
||||
Value::Str(s) if s == "disabled" => Ok(Some(false)),
|
||||
_ => Err("must be enabled or disabled".to_string()),
|
||||
};
|
||||
let result = match &key {
|
||||
Key::Property(P::MailSharing) => parsed.map(|v| policy.mail_sharing = v),
|
||||
Key::Property(P::AddAccounts) => parsed.map(|v| policy.add_accounts = v),
|
||||
Key::Property(P::Id) => Err("is immutable".to_string()),
|
||||
Key::Property(_) => Err("is set by the server".to_string()),
|
||||
_ => Err("is not a property of inbuxa:SharingPolicy".to_string()),
|
||||
};
|
||||
if let Err(why) = result {
|
||||
error = Some(
|
||||
SetError::invalid_properties()
|
||||
.with_property(key.into_owned())
|
||||
.with_description(why),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(error) = error {
|
||||
response.not_updated.append(id, error);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A tenant can only be stricter than the server
|
||||
if tenant_id.is_some()
|
||||
&& let Some(why) = looser_than_server(&sharing_policy::get(data, None).await?, &policy)
|
||||
{
|
||||
response
|
||||
.not_updated
|
||||
.append(id, SetError::forbidden().with_description(why));
|
||||
continue;
|
||||
}
|
||||
|
||||
if policy.mail_sharing != previous.mail_sharing || policy.add_accounts != previous.add_accounts {
|
||||
policy.changed_at = Some(store::write::now() * 1000);
|
||||
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
|
||||
sharing_policy::set(data, tenant_id, &policy).await?;
|
||||
changed = true;
|
||||
}
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
|
||||
if changed {
|
||||
// Shares are honored, or not, as tokens are built
|
||||
server.invalidate_all_local_caches();
|
||||
server.cluster_broadcast(BroadcastEvent::CacheInvalidateAll).await;
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ use jmap_proto::{
|
||||
types::state::State,
|
||||
};
|
||||
use jmap_tools::{JsonPointerItem, Key, Map, Value};
|
||||
use registry::schema::enums::StorageQuota;
|
||||
use registry::schema::enums::{Permission, StorageQuota};
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
ValueKey,
|
||||
@@ -622,6 +622,27 @@ impl MailboxSet for Server {
|
||||
)));
|
||||
}
|
||||
|
||||
// inbuxa: MA-C: with mail sharing off, nobody here starts or
|
||||
// widens a share (narrowing or ending one is always allowed)
|
||||
let before = current.as_ref().map(|m| m.inner.acls.as_slice()).unwrap_or_default();
|
||||
let widens = changes.acls.iter().any(|grant| {
|
||||
let had = before
|
||||
.iter()
|
||||
.find(|old| old.account_id == grant.account_id)
|
||||
.map_or(0, |old| old.grants.clone().into_inner());
|
||||
grant.grants.clone().into_inner() & !had != 0
|
||||
});
|
||||
if widens
|
||||
&& !ctx.access_token.has_permission(Permission::Impersonate)
|
||||
&& !self.mail_sharing_allowed(ctx.account_id).await?
|
||||
{
|
||||
return Ok(Err(SetError::forbidden()
|
||||
.with_property(MailboxProperty::ShareWith)
|
||||
.with_description(
|
||||
"Your organization has turned off sharing mail folders. A shared mailbox or a group can be set up by an administrator instead.",
|
||||
)));
|
||||
}
|
||||
|
||||
if !changes.acls.is_empty()
|
||||
&& let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await
|
||||
{
|
||||
|
||||
@@ -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