Shared mailboxes: a second kind of account lock #148

Merged
jcoffey-dev merged 1 commits from feat/shared-mailbox-lock-kind into main 2026-10-05 22:32:54 +00:00
14 changed files with 382 additions and 36 deletions

No files matched your search

+9 -4
View File
@@ -446,9 +446,10 @@ impl Server {
}
/// 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
/// the audit log is where the person who sent it is named. A delegate's
/// send is AL-9's record, not this one.
/// a group's or a shared mailbox's. The message itself only says
/// `From:` that address, so the audit log is where the person who sent
/// it is named. A locked account's delegate's send is AL-9's record, not
/// this one.
pub async fn audit_send_as(
&self,
token: &AccessToken,
@@ -459,7 +460,11 @@ impl Server {
let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else {
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;
}
let actor = self.audit_actor(token).await;
+24 -4
View File
@@ -46,19 +46,22 @@ impl Server {
// inbuxa: AL-2, AL-5: whether this account is locked, and which
// locked accounts are handed to it. The token is their cache: every
// 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
.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 delegations: Box<[super::Delegation]> =
inbuxa_features::lock::delegated_to(self.store(), account_id)
.await
.caused_by(trc::location!())?
.into_iter()
.filter(|(_, delegate)| delegate.is_current(now_secs))
.map(|(locked_id, delegate)| super::Delegation {
.filter(|(_, delegate, _)| delegate.is_current(now_secs))
.map(|(locked_id, delegate, kind)| super::Delegation {
account_id: locked_id,
kind,
access: delegate.access,
send_as: delegate.send_as,
until: delegate.until,
@@ -247,6 +250,7 @@ impl Server {
.map(ConcurrencyLimiter::new),
obj_size: 0,
locked,
shared_mailbox,
delegations: delegations.clone(),
revision,
revision_account,
@@ -300,6 +304,7 @@ impl Server {
.map(ConcurrencyLimiter::new),
obj_size: 0,
locked,
shared_mailbox,
delegations: delegations.clone(),
revision,
revision_account,
@@ -658,6 +663,7 @@ impl AccessToken {
credential_version: old_inner.credential_version,
obj_size: old_inner.obj_size,
locked: old_inner.locked,
shared_mailbox: old_inner.shared_mailbox,
delegations: old_inner.delegations.clone(),
};
@@ -848,6 +854,18 @@ impl AccessToken {
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
/// has one that hasn't ended.
/// 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(),
obj_size: Default::default(),
locked: false,
shared_mailbox: false,
delegations: Default::default(),
}),
}
@@ -988,6 +1007,7 @@ impl AccessTokenInner {
credential_version: Default::default(),
obj_size: Default::default(),
locked: false,
shared_mailbox: false,
delegations: Default::default(),
}
}
+4
View File
@@ -152,6 +152,8 @@ pub struct AccessTokenInner {
pub(crate) obj_size: u64,
// inbuxa: AL-2: the account is locked; it may not authenticate
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
pub(crate) delegations: Box<[Delegation]>,
}
@@ -165,6 +167,8 @@ pub struct Delegation {
pub send_as: bool,
/// Seconds since the epoch.
pub until: Option<u64>,
/// MA-S: a locked account, or a shared mailbox.
pub kind: inbuxa_features::lock::Kind,
}
#[derive(Debug, Default, Hash, Clone)]
+10 -2
View File
@@ -290,7 +290,11 @@ impl SieveScriptIngest for Server {
// inbuxa: AL-4: a locked account answers no sender, so a
// rejection is kept instead; sieve has already cleared
// 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)
&& !message.file_into.contains(&INBOX_ID)
{
@@ -403,7 +407,11 @@ impl SieveScriptIngest for Server {
// inbuxa: AL-4: a locked account sends nothing on its
// own: no redirect, vacation reply or notification. An
// 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!(
Sieve(SieveEvent::ActionReject),
Details = "Account is locked: nothing is sent",
+72 -3
View File
@@ -66,6 +66,53 @@ const KIND_DELEGATE: u8 = b'd';
/// Most delegates one lock may have (AL-5).
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).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
@@ -189,6 +236,9 @@ pub struct Replaced {
#[serde(rename_all = "camelCase")]
pub struct Lock {
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,
/// Seconds since the epoch.
pub locked_at: u64,
@@ -401,8 +451,9 @@ pub async fn all(data: &Store) -> trc::Result<Vec<Lock>> {
Ok(locks)
}
/// The accounts delegated to `delegate`, with its delegation in each.
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate)>> {
/// The accounts delegated to `delegate`, with its delegation in each and
/// 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();
data.iterate(
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?
&& let Some(delegation) = lock.delegate(delegate)
{
delegations.push((account_id, delegation.clone()));
delegations.push((account_id, delegation.clone(), lock.kind));
}
}
Ok(delegations)
@@ -472,6 +523,22 @@ pub async fn remove(data: &Store, lock: &Lock) -> trc::Result<()> {
mod tests {
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]
fn keys_read_back() {
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 {
Lock {
account_id: 1,
kind: Kind::Lock,
reason: "r".into(),
locked_at: 0,
locked_by: "admin".into(),
@@ -623,6 +691,7 @@ mod tests {
fn expired_delegations_grant_nothing() {
let lock = Lock {
account_id: 1,
kind: Kind::Lock,
reason: "Left the company".into(),
locked_at: 100,
locked_by: "admin".into(),
@@ -28,6 +28,8 @@ pub enum AccountLockProperty {
/// The locked account (on create; afterwards the same as `id`).
AccountId,
Name,
/// MA-S: `lock` (the default) or `sharedMailbox`; set on create only.
Kind,
Reason,
LockedAt,
LockedBy,
@@ -53,6 +55,7 @@ impl Property for AccountLockProperty {
AccountLockProperty::Id => "id",
AccountLockProperty::AccountId => "accountId",
AccountLockProperty::Name => "name",
AccountLockProperty::Kind => "kind",
AccountLockProperty::Reason => "reason",
AccountLockProperty::LockedAt => "lockedAt",
AccountLockProperty::LockedBy => "lockedBy",
@@ -68,6 +71,7 @@ impl AccountLockProperty {
b"id" => AccountLockProperty::Id,
b"accountId" => AccountLockProperty::AccountId,
b"name" => AccountLockProperty::Name,
b"kind" => AccountLockProperty::Kind,
b"reason" => AccountLockProperty::Reason,
b"lockedAt" => AccountLockProperty::LockedAt,
b"lockedBy" => AccountLockProperty::LockedBy,
+5 -1
View File
@@ -148,8 +148,12 @@ pub struct InbuxaDelegatedCapabilities {
#[derive(Debug, Clone, serde::Serialize)]
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,
/// MA-S: `lock` or `sharedMailbox`.
pub kind: &'static str,
/// `read`, `organize` or `full`.
pub access: &'static str,
#[serde(rename(serialize = "sendAs"))]
+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() {
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
D8e0s1e4Umau4gRh5MEW24KtsawKGPNvS-6LWrIFbtQ
OjbTKzNuSVcQRNR2Mb1UVvGnXui9r05aIdRASwyOSmo
+150
View File
@@ -447,6 +447,156 @@ pub async fn test(test: &mut TestServer) {
query["ids"].as_array().is_some_and(|ids| ids.len() >= 2),
"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`.