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 326969b..2fc1ca2 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ 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`.