Shared mailboxes: a second kind of account lock #148
No files matched your search
@@ -446,9 +446,10 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// MA-D0a: a message sent from an address that isn't the sender's own:
|
/// 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
|
/// a group's or a shared mailbox's. The message itself only says
|
||||||
/// the audit log is where the person who sent it is named. A delegate's
|
/// `From:` that address, so the audit log is where the person who sent
|
||||||
/// send is AL-9's record, not this one.
|
/// it is named. A locked account's delegate's send is AL-9's record, not
|
||||||
|
/// this one.
|
||||||
pub async fn audit_send_as(
|
pub async fn audit_send_as(
|
||||||
&self,
|
&self,
|
||||||
token: &AccessToken,
|
token: &AccessToken,
|
||||||
@@ -459,7 +460,11 @@ impl Server {
|
|||||||
let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else {
|
let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if as_account_id == token.account_id() || token.delegation(as_account_id).is_some() {
|
if as_account_id == token.account_id()
|
||||||
|
|| token
|
||||||
|
.delegation(as_account_id)
|
||||||
|
.is_some_and(|delegation| delegation.kind.is_lock())
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let actor = self.audit_actor(token).await;
|
let actor = self.audit_actor(token).await;
|
||||||
|
|||||||
@@ -46,19 +46,22 @@ impl Server {
|
|||||||
// inbuxa: AL-2, AL-5: whether this account is locked, and which
|
// inbuxa: AL-2, AL-5: whether this account is locked, and which
|
||||||
// locked accounts are handed to it. The token is their cache: every
|
// locked accounts are handed to it. The token is their cache: every
|
||||||
// change to a lock invalidates the tokens it touches.
|
// change to a lock invalidates the tokens it touches.
|
||||||
let locked = inbuxa_features::lock::get(self.store(), account_id)
|
let lock_kind = inbuxa_features::lock::get(self.store(), account_id)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?
|
.caused_by(trc::location!())?
|
||||||
.is_some();
|
.map(|lock| lock.kind);
|
||||||
|
let locked = lock_kind.is_some();
|
||||||
|
let shared_mailbox = lock_kind == Some(inbuxa_features::lock::Kind::SharedMailbox);
|
||||||
let now_secs = now();
|
let now_secs = now();
|
||||||
let delegations: Box<[super::Delegation]> =
|
let delegations: Box<[super::Delegation]> =
|
||||||
inbuxa_features::lock::delegated_to(self.store(), account_id)
|
inbuxa_features::lock::delegated_to(self.store(), account_id)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?
|
.caused_by(trc::location!())?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(_, delegate)| delegate.is_current(now_secs))
|
.filter(|(_, delegate, _)| delegate.is_current(now_secs))
|
||||||
.map(|(locked_id, delegate)| super::Delegation {
|
.map(|(locked_id, delegate, kind)| super::Delegation {
|
||||||
account_id: locked_id,
|
account_id: locked_id,
|
||||||
|
kind,
|
||||||
access: delegate.access,
|
access: delegate.access,
|
||||||
send_as: delegate.send_as,
|
send_as: delegate.send_as,
|
||||||
until: delegate.until,
|
until: delegate.until,
|
||||||
@@ -247,6 +250,7 @@ impl Server {
|
|||||||
.map(ConcurrencyLimiter::new),
|
.map(ConcurrencyLimiter::new),
|
||||||
obj_size: 0,
|
obj_size: 0,
|
||||||
locked,
|
locked,
|
||||||
|
shared_mailbox,
|
||||||
delegations: delegations.clone(),
|
delegations: delegations.clone(),
|
||||||
revision,
|
revision,
|
||||||
revision_account,
|
revision_account,
|
||||||
@@ -300,6 +304,7 @@ impl Server {
|
|||||||
.map(ConcurrencyLimiter::new),
|
.map(ConcurrencyLimiter::new),
|
||||||
obj_size: 0,
|
obj_size: 0,
|
||||||
locked,
|
locked,
|
||||||
|
shared_mailbox,
|
||||||
delegations: delegations.clone(),
|
delegations: delegations.clone(),
|
||||||
revision,
|
revision,
|
||||||
revision_account,
|
revision_account,
|
||||||
@@ -658,6 +663,7 @@ impl AccessToken {
|
|||||||
credential_version: old_inner.credential_version,
|
credential_version: old_inner.credential_version,
|
||||||
obj_size: old_inner.obj_size,
|
obj_size: old_inner.obj_size,
|
||||||
locked: old_inner.locked,
|
locked: old_inner.locked,
|
||||||
|
shared_mailbox: old_inner.shared_mailbox,
|
||||||
delegations: old_inner.delegations.clone(),
|
delegations: old_inner.delegations.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -848,6 +854,18 @@ impl AccessToken {
|
|||||||
self.inner.locked
|
self.inner.locked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-S: the account is a shared mailbox (a lock of that kind).
|
||||||
|
pub fn is_shared_mailbox(&self) -> bool {
|
||||||
|
self.inner.shared_mailbox
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-S: this account's delegation into `account_id` is to a
|
||||||
|
/// shared mailbox, not a locked account.
|
||||||
|
pub fn delegated_shared_mailbox(&self, account_id: u32) -> bool {
|
||||||
|
self.delegation(account_id)
|
||||||
|
.is_some_and(|d| d.kind == inbuxa_features::lock::Kind::SharedMailbox)
|
||||||
|
}
|
||||||
|
|
||||||
/// inbuxa: AL-5: this account's delegation into a locked account, if it
|
/// inbuxa: AL-5: this account's delegation into a locked account, if it
|
||||||
/// has one that hasn't ended.
|
/// has one that hasn't ended.
|
||||||
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
|
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
|
||||||
@@ -928,6 +946,7 @@ impl AccessToken {
|
|||||||
credential_version: Default::default(),
|
credential_version: Default::default(),
|
||||||
obj_size: Default::default(),
|
obj_size: Default::default(),
|
||||||
locked: false,
|
locked: false,
|
||||||
|
shared_mailbox: false,
|
||||||
delegations: Default::default(),
|
delegations: Default::default(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -988,6 +1007,7 @@ impl AccessTokenInner {
|
|||||||
credential_version: Default::default(),
|
credential_version: Default::default(),
|
||||||
obj_size: Default::default(),
|
obj_size: Default::default(),
|
||||||
locked: false,
|
locked: false,
|
||||||
|
shared_mailbox: false,
|
||||||
delegations: Default::default(),
|
delegations: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ pub struct AccessTokenInner {
|
|||||||
pub(crate) obj_size: u64,
|
pub(crate) obj_size: u64,
|
||||||
// inbuxa: AL-2: the account is locked; it may not authenticate
|
// inbuxa: AL-2: the account is locked; it may not authenticate
|
||||||
pub(crate) locked: bool,
|
pub(crate) locked: bool,
|
||||||
|
// inbuxa: MA-S: the lock is a shared mailbox
|
||||||
|
pub(crate) shared_mailbox: bool,
|
||||||
// inbuxa: AL-5: locked accounts handed to this one
|
// inbuxa: AL-5: locked accounts handed to this one
|
||||||
pub(crate) delegations: Box<[Delegation]>,
|
pub(crate) delegations: Box<[Delegation]>,
|
||||||
}
|
}
|
||||||
@@ -165,6 +167,8 @@ pub struct Delegation {
|
|||||||
pub send_as: bool,
|
pub send_as: bool,
|
||||||
/// Seconds since the epoch.
|
/// Seconds since the epoch.
|
||||||
pub until: Option<u64>,
|
pub until: Option<u64>,
|
||||||
|
/// MA-S: a locked account, or a shared mailbox.
|
||||||
|
pub kind: inbuxa_features::lock::Kind,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Hash, Clone)]
|
#[derive(Debug, Default, Hash, Clone)]
|
||||||
|
|||||||
@@ -290,7 +290,11 @@ impl SieveScriptIngest for Server {
|
|||||||
// inbuxa: AL-4: a locked account answers no sender, so a
|
// inbuxa: AL-4: a locked account answers no sender, so a
|
||||||
// rejection is kept instead; sieve has already cleared
|
// rejection is kept instead; sieve has already cleared
|
||||||
// the implicit keep, so it is filed here
|
// the implicit keep, so it is filed here
|
||||||
Event::Reject { .. } if access_token.is_locked() => {
|
// A shared mailbox (MA-S) is a role address and answers
|
||||||
|
// as one: its Sieve script runs as written
|
||||||
|
Event::Reject { .. }
|
||||||
|
if access_token.is_locked() && !access_token.is_shared_mailbox() =>
|
||||||
|
{
|
||||||
if let Some(message) = messages.get_mut(0)
|
if let Some(message) = messages.get_mut(0)
|
||||||
&& !message.file_into.contains(&INBOX_ID)
|
&& !message.file_into.contains(&INBOX_ID)
|
||||||
{
|
{
|
||||||
@@ -403,7 +407,11 @@ impl SieveScriptIngest for Server {
|
|||||||
// inbuxa: AL-4: a locked account sends nothing on its
|
// inbuxa: AL-4: a locked account sends nothing on its
|
||||||
// own: no redirect, vacation reply or notification. An
|
// own: no redirect, vacation reply or notification. An
|
||||||
// unsent redirect leaves the message to be kept.
|
// unsent redirect leaves the message to be kept.
|
||||||
Event::SendMessage { .. } if access_token.is_locked() => {
|
// A shared mailbox's acknowledgements and redirects go
|
||||||
|
// out (MA-S).
|
||||||
|
Event::SendMessage { .. }
|
||||||
|
if access_token.is_locked() && !access_token.is_shared_mailbox() =>
|
||||||
|
{
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Sieve(SieveEvent::ActionReject),
|
Sieve(SieveEvent::ActionReject),
|
||||||
Details = "Account is locked: nothing is sent",
|
Details = "Account is locked: nothing is sent",
|
||||||
|
|||||||
@@ -66,6 +66,53 @@ const KIND_DELEGATE: u8 = b'd';
|
|||||||
/// Most delegates one lock may have (AL-5).
|
/// Most delegates one lock may have (AL-5).
|
||||||
pub const MAX_DELEGATES: usize = 10;
|
pub const MAX_DELEGATES: usize = 10;
|
||||||
|
|
||||||
|
/// Most people one shared mailbox may have (MA-S): a help desk is bigger
|
||||||
|
/// than the handful a departed colleague's mail is handed to.
|
||||||
|
pub const MAX_SHARED_MAILBOX_DELEGATES: usize = 100;
|
||||||
|
|
||||||
|
/// What a lock is for (multi-account spec, MA-S).
|
||||||
|
///
|
||||||
|
/// Both kinds keep receiving mail, can't be signed in to, and are opened by
|
||||||
|
/// delegates through real grants. A shared mailbox is a role address such
|
||||||
|
/// as support@: it needs no reason, holds more people, runs its own Sieve
|
||||||
|
/// replies (an automatic acknowledgement), records only what is sent as it,
|
||||||
|
/// and may only send as its own addresses.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Kind {
|
||||||
|
#[default]
|
||||||
|
Lock,
|
||||||
|
SharedMailbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kind {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Kind::Lock => "lock",
|
||||||
|
Kind::SharedMailbox => "sharedMailbox",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"lock" => Some(Kind::Lock),
|
||||||
|
"sharedMailbox" => Some(Kind::SharedMailbox),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_lock(&self) -> bool {
|
||||||
|
matches!(self, Kind::Lock)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max_delegates(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Kind::Lock => MAX_DELEGATES,
|
||||||
|
Kind::SharedMailbox => MAX_SHARED_MAILBOX_DELEGATES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What a delegate may do in the locked account (AL-6).
|
/// What a delegate may do in the locked account (AL-6).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -189,6 +236,9 @@ pub struct Replaced {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Lock {
|
pub struct Lock {
|
||||||
pub account_id: u32,
|
pub account_id: u32,
|
||||||
|
/// Absent on locks written before shared mailboxes existed: a lock.
|
||||||
|
#[serde(default, skip_serializing_if = "Kind::is_lock")]
|
||||||
|
pub kind: Kind,
|
||||||
pub reason: String,
|
pub reason: String,
|
||||||
/// Seconds since the epoch.
|
/// Seconds since the epoch.
|
||||||
pub locked_at: u64,
|
pub locked_at: u64,
|
||||||
@@ -401,8 +451,9 @@ pub async fn all(data: &Store) -> trc::Result<Vec<Lock>> {
|
|||||||
Ok(locks)
|
Ok(locks)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The accounts delegated to `delegate`, with its delegation in each.
|
/// The accounts delegated to `delegate`, with its delegation in each and
|
||||||
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate)>> {
|
/// the kind of lock it is in.
|
||||||
|
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate, Kind)>> {
|
||||||
let mut locked = Vec::new();
|
let mut locked = Vec::new();
|
||||||
data.iterate(
|
data.iterate(
|
||||||
IterateParams::new(
|
IterateParams::new(
|
||||||
@@ -425,7 +476,7 @@ pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32,
|
|||||||
if let Some(lock) = get(data, account_id).await?
|
if let Some(lock) = get(data, account_id).await?
|
||||||
&& let Some(delegation) = lock.delegate(delegate)
|
&& let Some(delegation) = lock.delegate(delegate)
|
||||||
{
|
{
|
||||||
delegations.push((account_id, delegation.clone()));
|
delegations.push((account_id, delegation.clone(), lock.kind));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(delegations)
|
Ok(delegations)
|
||||||
@@ -472,6 +523,22 @@ pub async fn remove(data: &Store, lock: &Lock) -> trc::Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kind_reads_back_and_defaults_to_lock() {
|
||||||
|
// MA-S: a lock stored before shared mailboxes existed has no kind
|
||||||
|
let stored = r#"{"accountId":1,"reason":"r","lockedAt":0,"lockedBy":"admin","delegates":[]}"#;
|
||||||
|
let lock: Lock = serde_json::from_str(stored).unwrap();
|
||||||
|
assert_eq!(lock.kind, Kind::Lock);
|
||||||
|
assert!(!serde_json::to_string(&lock).unwrap().contains("kind"), "a lock is written as before");
|
||||||
|
|
||||||
|
let shared = Lock { kind: Kind::SharedMailbox, ..lock };
|
||||||
|
let written = serde_json::to_string(&shared).unwrap();
|
||||||
|
assert!(written.contains(r#""kind":"sharedMailbox""#), "{written}");
|
||||||
|
assert_eq!(serde_json::from_str::<Lock>(&written).unwrap().kind, Kind::SharedMailbox);
|
||||||
|
assert_eq!(Kind::parse("sharedMailbox"), Some(Kind::SharedMailbox));
|
||||||
|
assert_eq!(Kind::SharedMailbox.max_delegates(), MAX_SHARED_MAILBOX_DELEGATES);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keys_read_back() {
|
fn keys_read_back() {
|
||||||
let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else {
|
let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else {
|
||||||
@@ -509,6 +576,7 @@ mod tests {
|
|||||||
fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock {
|
fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock {
|
||||||
Lock {
|
Lock {
|
||||||
account_id: 1,
|
account_id: 1,
|
||||||
|
kind: Kind::Lock,
|
||||||
reason: "r".into(),
|
reason: "r".into(),
|
||||||
locked_at: 0,
|
locked_at: 0,
|
||||||
locked_by: "admin".into(),
|
locked_by: "admin".into(),
|
||||||
@@ -623,6 +691,7 @@ mod tests {
|
|||||||
fn expired_delegations_grant_nothing() {
|
fn expired_delegations_grant_nothing() {
|
||||||
let lock = Lock {
|
let lock = Lock {
|
||||||
account_id: 1,
|
account_id: 1,
|
||||||
|
kind: Kind::Lock,
|
||||||
reason: "Left the company".into(),
|
reason: "Left the company".into(),
|
||||||
locked_at: 100,
|
locked_at: 100,
|
||||||
locked_by: "admin".into(),
|
locked_by: "admin".into(),
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ pub enum AccountLockProperty {
|
|||||||
/// The locked account (on create; afterwards the same as `id`).
|
/// The locked account (on create; afterwards the same as `id`).
|
||||||
AccountId,
|
AccountId,
|
||||||
Name,
|
Name,
|
||||||
|
/// MA-S: `lock` (the default) or `sharedMailbox`; set on create only.
|
||||||
|
Kind,
|
||||||
Reason,
|
Reason,
|
||||||
LockedAt,
|
LockedAt,
|
||||||
LockedBy,
|
LockedBy,
|
||||||
@@ -53,6 +55,7 @@ impl Property for AccountLockProperty {
|
|||||||
AccountLockProperty::Id => "id",
|
AccountLockProperty::Id => "id",
|
||||||
AccountLockProperty::AccountId => "accountId",
|
AccountLockProperty::AccountId => "accountId",
|
||||||
AccountLockProperty::Name => "name",
|
AccountLockProperty::Name => "name",
|
||||||
|
AccountLockProperty::Kind => "kind",
|
||||||
AccountLockProperty::Reason => "reason",
|
AccountLockProperty::Reason => "reason",
|
||||||
AccountLockProperty::LockedAt => "lockedAt",
|
AccountLockProperty::LockedAt => "lockedAt",
|
||||||
AccountLockProperty::LockedBy => "lockedBy",
|
AccountLockProperty::LockedBy => "lockedBy",
|
||||||
@@ -68,6 +71,7 @@ impl AccountLockProperty {
|
|||||||
b"id" => AccountLockProperty::Id,
|
b"id" => AccountLockProperty::Id,
|
||||||
b"accountId" => AccountLockProperty::AccountId,
|
b"accountId" => AccountLockProperty::AccountId,
|
||||||
b"name" => AccountLockProperty::Name,
|
b"name" => AccountLockProperty::Name,
|
||||||
|
b"kind" => AccountLockProperty::Kind,
|
||||||
b"reason" => AccountLockProperty::Reason,
|
b"reason" => AccountLockProperty::Reason,
|
||||||
b"lockedAt" => AccountLockProperty::LockedAt,
|
b"lockedAt" => AccountLockProperty::LockedAt,
|
||||||
b"lockedBy" => AccountLockProperty::LockedBy,
|
b"lockedBy" => AccountLockProperty::LockedBy,
|
||||||
|
|||||||
@@ -148,8 +148,12 @@ pub struct InbuxaDelegatedCapabilities {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct DelegationInfo {
|
pub struct DelegationInfo {
|
||||||
/// Always true: only locked accounts are delegated.
|
/// Always true: only locked accounts are delegated. A shared mailbox is
|
||||||
|
/// a lock too, so a front end that knows no `kind` still treats it as
|
||||||
|
/// one it may only reach as a delegate.
|
||||||
pub locked: bool,
|
pub locked: bool,
|
||||||
|
/// MA-S: `lock` or `sharedMailbox`.
|
||||||
|
pub kind: &'static str,
|
||||||
/// `read`, `organize` or `full`.
|
/// `read`, `organize` or `full`.
|
||||||
pub access: &'static str,
|
pub access: &'static str,
|
||||||
#[serde(rename(serialize = "sendAs"))]
|
#[serde(rename(serialize = "sendAs"))]
|
||||||
|
|||||||
@@ -204,15 +204,20 @@ impl RequestHandler for Server {
|
|||||||
// inbuxa: AL-9: a delegate's access, and what it
|
// inbuxa: AL-9: a delegate's access, and what it
|
||||||
// changes, are recorded; anyone else here impersonated
|
// changes, are recorded; anyone else here impersonated
|
||||||
if let Some(delegation) = access_token.delegation(account_id) {
|
if let Some(delegation) = access_token.delegation(account_id) {
|
||||||
let access = delegation.access.as_str();
|
// MA-S: in a shared mailbox only what is sent as it
|
||||||
self.audit_delegate(
|
// is recorded (audit_send_as); every read and flag
|
||||||
access_token,
|
// on a busy desk would bury the log
|
||||||
account_id,
|
if delegation.kind.is_lock() {
|
||||||
access,
|
let access = delegation.access.as_str();
|
||||||
is_write.then_some(call_name.as_str()),
|
self.audit_delegate(
|
||||||
result.as_ref().err(),
|
access_token,
|
||||||
)
|
account_id,
|
||||||
.await;
|
access,
|
||||||
|
is_write.then_some(call_name.as_str()),
|
||||||
|
result.as_ref().err(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
if makes_containers
|
if makes_containers
|
||||||
&& result.is_ok()
|
&& result.is_ok()
|
||||||
&& let Err(err) =
|
&& let Err(err) =
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ impl SessionHandler for Server {
|
|||||||
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
|
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
|
||||||
delegation: DelegationInfo {
|
delegation: DelegationInfo {
|
||||||
locked: true,
|
locked: true,
|
||||||
|
kind: delegation.kind.as_str(),
|
||||||
access: delegation.access.as_str(),
|
access: delegation.access.as_str(),
|
||||||
send_as: delegation.send_as,
|
send_as: delegation.send_as,
|
||||||
until: delegation.until.map(|until| {
|
until: delegation.until.map(|until| {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use common::{
|
|||||||
};
|
};
|
||||||
use email::inbuxa_lock::apply_grants;
|
use email::inbuxa_lock::apply_grants;
|
||||||
use groupware::inbuxa_lock::invalidate;
|
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::{
|
use jmap_proto::{
|
||||||
error::set::SetError,
|
error::set::SetError,
|
||||||
method::{
|
method::{
|
||||||
@@ -39,6 +39,7 @@ const ALL: &[P] = &[
|
|||||||
P::Id,
|
P::Id,
|
||||||
P::AccountId,
|
P::AccountId,
|
||||||
P::Name,
|
P::Name,
|
||||||
|
P::Kind,
|
||||||
P::Reason,
|
P::Reason,
|
||||||
P::LockedAt,
|
P::LockedAt,
|
||||||
P::LockedBy,
|
P::LockedBy,
|
||||||
@@ -75,6 +76,7 @@ async fn parse_delegates(
|
|||||||
server: &Server,
|
server: &Server,
|
||||||
access_token: &AccessToken,
|
access_token: &AccessToken,
|
||||||
locked_id: u32,
|
locked_id: u32,
|
||||||
|
kind: Kind,
|
||||||
value: LValue,
|
value: LValue,
|
||||||
) -> Result<Vec<Delegate>, SetError<P>> {
|
) -> Result<Vec<Delegate>, SetError<P>> {
|
||||||
let invalid = |why: String| {
|
let invalid = |why: String| {
|
||||||
@@ -86,8 +88,10 @@ async fn parse_delegates(
|
|||||||
let Some(items) = json.as_array() else {
|
let Some(items) = json.as_array() else {
|
||||||
return Err(invalid("delegates must be a list.".into()));
|
return Err(invalid("delegates must be a list.".into()));
|
||||||
};
|
};
|
||||||
if items.len() > MAX_DELEGATES {
|
// MA-S: a shared mailbox holds more people than a lock hands over
|
||||||
return Err(invalid(format!("At most {MAX_DELEGATES} delegates.")));
|
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 locked_tenant = server.account(locked_id).await.ok().and_then(|a| a.id_tenant);
|
||||||
let mut delegates: Vec<Delegate> = Vec::with_capacity(items.len());
|
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 {
|
let value = match property {
|
||||||
P::Id | P::AccountId => Value::Element(AccountLockValue::Id(Id::from(lock.account_id))),
|
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::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::Reason => Value::Str(lock.reason.clone().into()),
|
||||||
P::LockedAt => date(lock.locked_at),
|
P::LockedAt => date(lock.locked_at),
|
||||||
P::LockedBy => Value::Str(lock.locked_by.clone().into()),
|
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() {
|
for (client_id, value) in request.unwrap_create() {
|
||||||
let mut account_id = None;
|
let mut account_id = None;
|
||||||
|
let mut kind = Kind::Lock;
|
||||||
let mut reason = None;
|
let mut reason = None;
|
||||||
let mut delegates_value = None;
|
let mut delegates_value = None;
|
||||||
let mut invalid = None;
|
let mut invalid = None;
|
||||||
@@ -291,6 +297,17 @@ pub async fn set(
|
|||||||
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => {
|
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => {
|
||||||
account_id = Some(id.document_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::Reason), Value::Str(r)) => reason = reason_of(Some(&r)),
|
||||||
(Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()),
|
(Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()),
|
||||||
_ => {
|
_ => {
|
||||||
@@ -310,9 +327,14 @@ pub async fn set(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(reason) = reason.or_else(|| reason_of(arguments.reason.as_deref())) else {
|
// MA-S: a shared mailbox needs no reason; a lock always does
|
||||||
response.not_created.append(client_id, reason_required());
|
let reason = match reason.or_else(|| reason_of(arguments.reason.as_deref())) {
|
||||||
continue;
|
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 {
|
if let Err(error) = assert_reach(server, access_token, account_id).await {
|
||||||
response.not_created.append(client_id, error);
|
response.not_created.append(client_id, error);
|
||||||
@@ -321,12 +343,13 @@ pub async fn set(
|
|||||||
if lock::get(data, account_id).await?.is_some() {
|
if lock::get(data, account_id).await?.is_some() {
|
||||||
response.not_created.append(
|
response.not_created.append(
|
||||||
client_id,
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let delegates = match delegates_value {
|
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,
|
Ok(delegates) => delegates,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
response.not_created.append(client_id, error);
|
response.not_created.append(client_id, error);
|
||||||
@@ -337,6 +360,7 @@ pub async fn set(
|
|||||||
};
|
};
|
||||||
let mut created = Lock {
|
let mut created = Lock {
|
||||||
account_id,
|
account_id,
|
||||||
|
kind,
|
||||||
reason,
|
reason,
|
||||||
locked_at: now(),
|
locked_at: now(),
|
||||||
locked_by: actor.name.clone(),
|
locked_by: actor.name.clone(),
|
||||||
@@ -370,7 +394,7 @@ pub async fn set(
|
|||||||
response.not_updated.append(id, SetError::not_found());
|
response.not_updated.append(id, SetError::not_found());
|
||||||
continue;
|
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());
|
response.not_updated.append(id, reason_required());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -379,7 +403,9 @@ pub async fn set(
|
|||||||
for (key, value) in value.into_expanded_object() {
|
for (key, value) in value.into_expanded_object() {
|
||||||
match (&key, value) {
|
match (&key, value) {
|
||||||
(Key::Property(P::Delegates), 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,
|
Ok(delegates) => updated.delegates = delegates,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
invalid = Some(error);
|
invalid = Some(error);
|
||||||
@@ -389,6 +415,7 @@ pub async fn set(
|
|||||||
}
|
}
|
||||||
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
|
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
|
||||||
Some(r) => updated.reason = r,
|
Some(r) => updated.reason = r,
|
||||||
|
None if !current.kind.is_lock() => updated.reason = String::new(),
|
||||||
None => {
|
None => {
|
||||||
invalid = Some(reason_required());
|
invalid = Some(reason_required());
|
||||||
break;
|
break;
|
||||||
@@ -420,7 +447,7 @@ pub async fn set(
|
|||||||
response.not_destroyed.append(id, SetError::not_found());
|
response.not_destroyed.append(id, SetError::not_found());
|
||||||
continue;
|
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());
|
response.not_destroyed.append(id, reason_required());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ pub trait EmailSubmissionSet: Sync + Send {
|
|||||||
fn send_message(
|
fn send_message(
|
||||||
&self,
|
&self,
|
||||||
account_id: u32,
|
account_id: u32,
|
||||||
|
own_addresses_only: bool,
|
||||||
response: &SetResponse<email_submission::EmailSubmission>,
|
response: &SetResponse<email_submission::EmailSubmission>,
|
||||||
instance: &Arc<ServerInstance>,
|
instance: &Arc<ServerInstance>,
|
||||||
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
|
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
|
||||||
@@ -83,7 +84,14 @@ impl EmailSubmissionSet for Server {
|
|||||||
let mut batch = BatchBuilder::new();
|
let mut batch = BatchBuilder::new();
|
||||||
for (id, object) in request.unwrap_create() {
|
for (id, object) in request.unwrap_create() {
|
||||||
match self
|
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?
|
.await?
|
||||||
{
|
{
|
||||||
Ok(submission) => {
|
Ok(submission) => {
|
||||||
@@ -400,6 +408,7 @@ impl EmailSubmissionSet for Server {
|
|||||||
async fn send_message(
|
async fn send_message(
|
||||||
&self,
|
&self,
|
||||||
account_id: u32,
|
account_id: u32,
|
||||||
|
own_addresses_only: bool,
|
||||||
response: &SetResponse<email_submission::EmailSubmission>,
|
response: &SetResponse<email_submission::EmailSubmission>,
|
||||||
instance: &Arc<ServerInstance>,
|
instance: &Arc<ServerInstance>,
|
||||||
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
|
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
|
||||||
@@ -629,6 +638,46 @@ impl EmailSubmissionSet for Server {
|
|||||||
.unarchive::<MessageMetadata>()
|
.unarchive::<MessageMetadata>()
|
||||||
.caused_by(trc::location!())?;
|
.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
|
// Add recipients to envelope if missing
|
||||||
let mut bcc_header = None;
|
let mut bcc_header = None;
|
||||||
if rcpt_to.is_empty() {
|
if rcpt_to.is_empty() {
|
||||||
|
|||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
D8e0s1e4Umau4gRh5MEW24KtsawKGPNvS-6LWrIFbtQ
|
OjbTKzNuSVcQRNR2Mb1UVvGnXui9r05aIdRASwyOSmo
|
||||||
@@ -447,6 +447,156 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
query["ids"].as_array().is_some_and(|ids| ids.len() >= 2),
|
query["ids"].as_array().is_some_and(|ids| ids.len() >= 2),
|
||||||
"AL-9: the delegate's access and changes weren't recorded: {query}"
|
"AL-9: the delegate's access and changes weren't recorded: {query}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
shared_mailbox(admin, &mut smtp_rx, &mut lmtp).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MA-S (specs/multi-account.md): a shared mailbox is a lock of its own
|
||||||
|
/// kind. No reason is needed, more people fit, its Sieve replies go out,
|
||||||
|
/// only what is sent as it is recorded, and it sends only as itself.
|
||||||
|
async fn shared_mailbox(
|
||||||
|
admin: &Account,
|
||||||
|
smtp_rx: &mut tokio::sync::mpsc::Receiver<crate::jmap::mail::submission::MockMessage>,
|
||||||
|
lmtp: &mut SmtpConnection,
|
||||||
|
) {
|
||||||
|
println!("Running shared mailbox tests...");
|
||||||
|
let support = admin
|
||||||
|
.create_user_account("[email protected]", "support-secret-3317", "Support", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let agent = admin
|
||||||
|
.create_user_account("[email protected]", "agent-secret-5520", "Agent", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let support_id = support.id_string().to_string();
|
||||||
|
|
||||||
|
// An automatic acknowledgement, set up while it could still sign in
|
||||||
|
support
|
||||||
|
.jmap_client()
|
||||||
|
.await
|
||||||
|
.vacation_response_enable("Received", "We'll get back to you.".into(), None::<String>)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// More people than a lock may have
|
||||||
|
let mut delegates = vec![json!({"accountId": agent.id_string(), "access": "organize", "sendAs": true})];
|
||||||
|
for n in 0..11 {
|
||||||
|
let name: &'static str = Box::leak(format!("desk{n}@example.com").into_boxed_str());
|
||||||
|
let desk = admin.create_user_account(name, "desk-secret-7781", "Desk", &[], vec![]).await;
|
||||||
|
delegates.push(json!({"accountId": desk.id_string(), "access": "read"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// No reason needed
|
||||||
|
let response = admin
|
||||||
|
.lock_set(json!({"create": {"s": {"accountId": support_id, "kind": "sharedMailbox",
|
||||||
|
"delegates": delegates}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(response["created"]["s"]["id"], support_id.as_str(), "MA-S1: {response}");
|
||||||
|
let (_, got) = admin
|
||||||
|
.call("inbuxa:AccountLock/get", json!({"accountId": admin.id_string(), "ids": [support_id]}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(got["list"][0]["kind"], "sharedMailbox", "MA-S1: {got}");
|
||||||
|
|
||||||
|
// Nobody signs in to it
|
||||||
|
assert_ne!(support.session_status().await, 200, "MA-S1: a shared mailbox signed in");
|
||||||
|
|
||||||
|
// It says what it is to the people in it
|
||||||
|
let session = agent.jmap_session_object().await.0;
|
||||||
|
let delegation = &session["accounts"][support_id.as_str()]["accountCapabilities"]["urn:inbuxa:jmap"]["delegation"];
|
||||||
|
assert_eq!(delegation["kind"], "sharedMailbox", "MA-S: {session}");
|
||||||
|
assert_eq!(delegation["locked"], true, "MA-S: front ends that know no kind still see a lock");
|
||||||
|
|
||||||
|
// Its Sieve replies go out, where a lock's are held back
|
||||||
|
lmtp.ingest(
|
||||||
|
"[email protected]",
|
||||||
|
&["[email protected]"],
|
||||||
|
// Addressed to it: a vacation reply answers only mail sent to it
|
||||||
|
"From: [email protected]\r\nTo: [email protected]\r\nSubject: My order\r\n\r\nHello.\r\n",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_message_delivery(
|
||||||
|
smtp_rx,
|
||||||
|
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Received"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// The agent answers as support@: sent, and recorded as the agent
|
||||||
|
let (_, mailboxes) = agent
|
||||||
|
.call("Mailbox/get", json!({"accountId": support_id, "ids": null, "properties": ["role"]}))
|
||||||
|
.await;
|
||||||
|
let drafts = mailboxes["list"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|m| m["role"] == "drafts")
|
||||||
|
.unwrap_or_else(|| panic!("no Drafts: {mailboxes}"))["id"]
|
||||||
|
.clone();
|
||||||
|
let (_, identities) = agent
|
||||||
|
.call("Identity/get", json!({"accountId": support_id, "ids": null}))
|
||||||
|
.await;
|
||||||
|
let identity = identities["list"][0]["id"].clone();
|
||||||
|
let send = |reply_to: Option<&str>, subject: &str| {
|
||||||
|
let mut email = json!({
|
||||||
|
"mailboxIds": {drafts.as_str().unwrap(): true},
|
||||||
|
"from": [{"email": "[email protected]"}],
|
||||||
|
"to": [{"email": "[email protected]"}],
|
||||||
|
"subject": subject,
|
||||||
|
"bodyValues": {"t": {"value": "Thanks for writing."}},
|
||||||
|
"textBody": [{"partId": "t", "type": "text/plain"}]
|
||||||
|
});
|
||||||
|
if let Some(reply_to) = reply_to {
|
||||||
|
email["replyTo"] = json!([{"email": reply_to}]);
|
||||||
|
}
|
||||||
|
json!([
|
||||||
|
["Email/set", {"accountId": support_id, "create": {"m": email}}, "e"],
|
||||||
|
["EmailSubmission/set", {"accountId": support_id,
|
||||||
|
"create": {"s": {"identityId": identity, "emailId": "#m"}}}, "s"]
|
||||||
|
])
|
||||||
|
};
|
||||||
|
let response = agent.jmap_request(USING, send(None, "Re: My order")).await.0;
|
||||||
|
assert!(
|
||||||
|
response.pointer("/methodResponses/1/1/created/s").is_some(),
|
||||||
|
"MA-S3: the answer didn't go out: {response}"
|
||||||
|
);
|
||||||
|
assert_message_delivery(
|
||||||
|
smtp_rx,
|
||||||
|
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Re: My order"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// MA-S3: a reply can't be steered to the agent's own address
|
||||||
|
let response = agent
|
||||||
|
.jmap_request(USING, send(Some("[email protected]"), "Write to me directly"))
|
||||||
|
.await
|
||||||
|
.0;
|
||||||
|
assert_eq!(
|
||||||
|
response.pointer("/methodResponses/1/1/notCreated/s/type"),
|
||||||
|
Some(&json!("forbiddenFrom")),
|
||||||
|
"MA-S3: {response}"
|
||||||
|
);
|
||||||
|
expect_nothing(smtp_rx).await;
|
||||||
|
|
||||||
|
// MA-D0a: the send names the agent; AL-9's per-change records don't
|
||||||
|
// apply in a shared mailbox
|
||||||
|
let (_, query) = admin
|
||||||
|
.call(
|
||||||
|
"inbuxa:AuditEvent/query",
|
||||||
|
json!({"accountId": admin.id_string(),
|
||||||
|
"filter": {"actorId": agent.id_string(), "accountId": support_id}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, records) = admin
|
||||||
|
.call("inbuxa:AuditEvent/get", json!({"accountId": admin.id_string(), "ids": query["ids"]}))
|
||||||
|
.await;
|
||||||
|
let kinds = records["list"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r["target"]["kind"].as_str().unwrap_or_default().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(kinds, ["EmailSubmission"], "MA-D0a: {records}");
|
||||||
|
|
||||||
|
// Ending it needs no reason either
|
||||||
|
let response = admin.lock_set(json!({"destroy": [support_id]})).await;
|
||||||
|
assert_eq!(response["destroyed"][0], support_id.as_str(), "MA-S: {response}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs these tests alone: `cargo test -p tests account_lock_tests -- --ignored`.
|
/// Runs these tests alone: `cargo test -p tests account_lock_tests -- --ignored`.
|
||||||
|
|||||||
Reference in new issue
Block a user