From 9976d52e29e976b395ddc117c8b604f8981d792e Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 15:27:52 -0700 Subject: [PATCH] Shared mailboxes: a second kind of account lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared mailbox (support@, legal@) belongs to no one person: nobody signs in to it, and the people assigned open it beside their own mail at an access level an administrator chose. An account lock already is most of that: it keeps receiving mail, refuses every sign-in, and its delegates reach it through real grants on every container (so IMAP, DAV and JMAP honor them), never including Share. So a shared mailbox is a lock of a second kind (multi-account spec, MA-S; John, 2026-10-05). Lock gains kind: "lock" (the default, so stored locks read as before) or "sharedMailbox", set on create and fixed after. A shared mailbox: - needs no reason to make, change or end; - holds up to 100 people, where a lock holds 10; - runs its own Sieve replies and redirects, so an automatic acknowledgement goes out (a lock answers no one); - records only what is sent as it (audit_send_as, which now covers it), not AL-9's access and per-change records, which would bury the log for a busy desk; - sends only as itself (MA-S3): From and Reply-To must be its own addresses, so answers come back to the mailbox and not to whoever replied; anything else is forbiddenFrom. The session marks it delegation: {locked: true, kind: "sharedMailbox"}, so a front end that knows no kind still treats it as a lock. The console's layout gains Management › Directory › Shared Mailboxes (CustomComponent/SharedMailboxes). Tests: the account lock suite now goes on to a shared mailbox: made without a reason with twelve people, sign-in refused, the session's kind, its vacation reply delivered, an answer sent as it and recorded as the agent with no per-change records, and a Reply-To naming the agent refused; a lock unit test reads a stored lock without a kind. account_lock_tests, jmap_tests, audit_log_tests and imap_tests pass (RocksDB). --- crates/common/src/audit.rs | 13 +- crates/common/src/auth/access_token.rs | 28 +++- crates/common/src/auth/mod.rs | 4 + crates/email/src/sieve/ingest.rs | 12 +- crates/features/src/lock/mod.rs | 75 ++++++++- .../src/object/inbuxa_account_lock.rs | 4 + crates/jmap-proto/src/request/capability.rs | 6 +- crates/jmap/src/api/request.rs | 23 +-- crates/jmap/src/api/session.rs | 1 + crates/jmap/src/inbuxa/account_lock.rs | 49 ++++-- crates/jmap/src/submission/set.rs | 51 +++++- resources/schema/schema.json.gz | Bin 153498 -> 153512 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/system/account_lock.rs | 150 ++++++++++++++++++ 14 files changed, 382 insertions(+), 36 deletions(-) diff --git a/crates/common/src/audit.rs b/crates/common/src/audit.rs index 129b0c9..2e79d41 100644 --- a/crates/common/src/audit.rs +++ b/crates/common/src/audit.rs @@ -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; diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index da38492..9b7fe48 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -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(), } } diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 87cf335..648d5d6 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -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, + /// MA-S: a locked account, or a shared mailbox. + pub kind: inbuxa_features::lock::Kind, } #[derive(Debug, Default, Hash, Clone)] diff --git a/crates/email/src/sieve/ingest.rs b/crates/email/src/sieve/ingest.rs index 1a7c2c7..0980898 100644 --- a/crates/email/src/sieve/ingest.rs +++ b/crates/email/src/sieve/ingest.rs @@ -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", diff --git a/crates/features/src/lock/mod.rs b/crates/features/src/lock/mod.rs index 3d875c4..9dc7af1 100644 --- a/crates/features/src/lock/mod.rs +++ b/crates/features/src/lock/mod.rs @@ -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 { + 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> { Ok(locks) } -/// The accounts delegated to `delegate`, with its delegation in each. -pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result> { +/// 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> { let mut locked = Vec::new(); data.iterate( IterateParams::new( @@ -425,7 +476,7 @@ pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result 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::(&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, replaced: Vec) -> 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(), diff --git a/crates/jmap-proto/src/object/inbuxa_account_lock.rs b/crates/jmap-proto/src/object/inbuxa_account_lock.rs index 6634263..044170c 100644 --- a/crates/jmap-proto/src/object/inbuxa_account_lock.rs +++ b/crates/jmap-proto/src/object/inbuxa_account_lock.rs @@ -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, diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index 0344e15..de11758 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -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"))] diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 3a144f2..910aa63 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -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) = diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index bd2cd1b..38e1eda 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -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| { diff --git a/crates/jmap/src/inbuxa/account_lock.rs b/crates/jmap/src/inbuxa/account_lock.rs index 4f5479c..6b8837c 100644 --- a/crates/jmap/src/inbuxa/account_lock.rs +++ b/crates/jmap/src/inbuxa/account_lock.rs @@ -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, SetError

> { 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 = 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; } diff --git a/crates/jmap/src/submission/set.rs b/crates/jmap/src/submission/set.rs index ce187f7..18c5713 100644 --- a/crates/jmap/src/submission/set.rs +++ b/crates/jmap/src/submission/set.rs @@ -58,6 +58,7 @@ pub trait EmailSubmissionSet: Sync + Send { fn send_message( &self, account_id: u32, + own_addresses_only: bool, response: &SetResponse, instance: &Arc, 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, instance: &Arc, object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>, @@ -629,6 +638,46 @@ impl EmailSubmissionSet for Server { .unarchive::() .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() { diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 326969b37765440dcee942de563c54fa1aa0d5a2..2fc1ca2e8fba7d031173a1cba25f5efaeed661ad 100644 GIT binary patch delta 2293 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 BXBGee diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 27abc91..4a862a7 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -D8e0s1e4Umau4gRh5MEW24KtsawKGPNvS-6LWrIFbtQ \ No newline at end of file +OjbTKzNuSVcQRNR2Mb1UVvGnXui9r05aIdRASwyOSmo \ No newline at end of file diff --git a/tests/src/system/account_lock.rs b/tests/src/system/account_lock.rs index 9117751..19e2163 100644 --- a/tests/src/system/account_lock.rs +++ b/tests/src/system/account_lock.rs @@ -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, + lmtp: &mut SmtpConnection, +) { + println!("Running shared mailbox tests..."); + let support = admin + .create_user_account("support@example.com", "support-secret-3317", "Support", &[], vec![]) + .await; + let agent = admin + .create_user_account("agent@example.com", "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::) + .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( + "carol@remote.org", + &["support@example.com"], + // Addressed to it: a vacation reply answers only mail sent to it + "From: carol@remote.org\r\nTo: support@example.com\r\nSubject: My order\r\n\r\nHello.\r\n", + ) + .await; + assert_message_delivery( + smtp_rx, + MockMessage::new("", [""], "@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": "support@example.com"}], + "to": [{"email": "carol@remote.org"}], + "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("", [""], "@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("agent@example.com"), "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::>(); + 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`.