@ -1,7 +1,7 @@
use std ::{ path ::Path , sync ::LazyLock , time ::Duration } ;
use chrono ::{ DateTime , TimeDelta , Utc } ;
use num_traits ::ToPrimitive ;
use num_traits ::{ FromPrimitive , ToPrimitive } ;
use rocket ::{
form ::Form ,
fs ::{ NamedFile , TempFile } ,
@ -12,11 +12,14 @@ use serde_json::Value;
use crate ::{
CONFIG ,
api ::{ ApiResult , EmptyResult , JsonResult , Notify , UpdateType } ,
auth ::{ Headers , Host , SendHeaders } ,
auth ::{ ClientVersion , Headers , Host , SendHeaders } ,
config ::PathType ,
db ::{
DbConn , DbPool ,
models ::{ Device , OrgPolicy , OrgPolicyType , Send , SendFileId , SendId , SendType , UserId } ,
models ::{
Cipher , CipherId , Device , DeviceType , OrgPolicy , OrgPolicyType , Send , SendAuthType , SendFileId , SendId ,
SendType , UserId , normalize_emails ,
} ,
} ,
util ::{ NumberOrString , save_temp_file } ,
} ;
@ -70,6 +73,7 @@ pub async fn purge_sends(pool: DbPool) {
#[ serde(rename_all = " camelCase " ) ]
pub struct SendData {
r#type : i32 ,
auth_type : Option < i32 > ,
pub key : String ,
password : Option < String > ,
max_access_count : Option < NumberOrString > ,
@ -84,6 +88,8 @@ pub struct SendData {
notes : Option < String > ,
text : Option < Value > ,
file : Option < Value > ,
// Item Sends: `{ encryptionVersion, data }`, the whole shared item encrypted client side
data : Option < Value > ,
file_length : Option < NumberOrString > ,
// Used for key rotations
@ -126,20 +132,191 @@ async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, c
Ok ( ( ) )
}
fn create_send ( data : SendData , user_id : UserId ) -> ApiResult < Send > {
let data_val = if data . r#type = = SendType ::Text as i32 {
data . text
} else if data . r#type = = SendType ::File as i32 {
data . file
} else {
err ! ( "Invalid Send type" )
/// Max length of the encrypted blob of an Item Send, the same limit as the Bitwarden server
const ITEM_DATA_MAX_LEN : usize = 500_000 ;
/// Max recipients of an email verified Send
const SEND_EMAILS_MAX : usize = 50 ;
/// Whether temporary item sharing (`pm-34203-temporary-item-sharing`) is enabled on this server
pub fn item_sharing_enabled ( ) -> bool {
crate ::util ::parse_experimental_client_feature_flags (
& CONFIG . experimental_client_feature_flags ( ) ,
& crate ::util ::FeatureFlagFilter ::ValidOnly ,
)
. contains_key ( "pm-34203-temporary-item-sharing" )
}
/// Whether this client version is recent enough for Item Sends and the SDK Sends API.
/// A client that does not tell its version is treated as too old.
pub fn client_version_supports_item_sharing ( client_version : Option < & ClientVersion > ) -> bool {
let Ok ( min ) = semver ::Version ::parse ( & CONFIG . item_sharing_min_client_version ( ) ) else {
return false ;
} ;
client_version . is_some_and ( | v | v . 0 > = min )
}
let data_str = if let Some ( mut d ) = data_val {
d . as_object_mut ( ) . and_then ( | o | o . remove ( "response" ) ) ;
serde_json ::to_string ( & d ) ?
} else {
err ! ( "Send data not provided" ) ;
/// Whether Item Sends can be listed to this client. An unknown Send type fails the whole sync of the
/// Android app (its Send type enum has no fallback), and the mobile apps follow their own release
/// train, so they never get Item Sends whatever version they report.
pub fn client_supports_item_sends ( device_type : i32 , client_version : Option < & ClientVersion > ) -> bool {
const MOBILE : [ i32 ; 3 ] = [ DeviceType ::Android as i32 , DeviceType ::Ios as i32 , DeviceType ::AndroidAmazon as i32 ] ;
item_sharing_enabled ( ) & & client_version_supports_item_sharing ( client_version ) & & ! MOBILE . contains ( & device_type )
}
/// Validates the encrypted blob of an Item Send and keeps only the fields the server stores.
fn item_data_str ( data : Option < & Value > ) -> ApiResult < String > {
let Some ( d ) = data else {
err ! ( "Send data not provided" )
} ;
let blob = d . get ( "data" ) . or_else ( | | d . get ( "Data" ) ) . and_then ( Value ::as_str ) . unwrap_or_default ( ) ;
if blob . is_empty ( ) {
err ! ( "Item Sends need the encrypted item data" )
}
if blob . len ( ) > ITEM_DATA_MAX_LEN {
err ! ( "The shared item is too large" )
}
// The id of the shared item travels unencrypted next to the sealed item, and the clients need it back
// to decrypt (bitwarden/server#8485). Required, as on the Bitwarden server: an Item Send without it
// is in the old format, which no client decrypts anymore.
let metadata = d . get ( "metadata" ) . or_else ( | | d . get ( "Metadata" ) ) ;
let item_id = metadata . and_then ( | m | m . get ( "itemId" ) . or_else ( | | m . get ( "ItemId" ) ) ) . and_then ( Value ::as_str ) ;
let Some ( item_id ) = item_id . and_then ( | i | uuid ::Uuid ::parse_str ( i ) . ok ( ) ) else {
err ! ( "Metadata is required for item sends." )
} ;
let version =
d . get ( "encryptionVersion" ) . or_else ( | | d . get ( "EncryptionVersion" ) ) . and_then ( Value ::as_i64 ) . unwrap_or ( 1 ) ;
Ok ( serde_json ::to_string ( & json ! ( {
"encryptionVersion" : version ,
"data" : blob ,
"metadata" : { "itemId" : item_id . to_string ( ) } ,
} ) ) ? )
}
/// Lets an Item Send share only what its owner may share, as the Bitwarden server does: their own item,
/// or an organization item they can edit and see the password of, and never a deleted, archived or SSH
/// key item. A save keeping the stored item id skips the check, so a Send whose item was deleted since
/// can still be edited. Missing and restricted items get the same error, so item ids can't be probed.
async fn validate_item_send (
send : & Send ,
stored_item_id : Option < CipherId > ,
user_id : & UserId ,
conn : & DbConn ,
) -> EmptyResult {
const SSH_KEY : i32 = 5 ;
let Some ( item_id ) = send . item_id ( ) else {
return Ok ( ( ) ) ;
} ;
if stored_item_id . as_ref ( ) = = Some ( & item_id ) {
return Ok ( ( ) ) ;
}
let Some ( cipher ) = Cipher ::find_by_uuid ( & item_id , conn ) . await else {
err ! ( "Item not found." , format ! ( "Item Send of {user_id}: item {item_id} does not exist" ) )
} ;
let can_share = matches ! ( cipher . get_access_restrictions ( user_id , None , conn ) . await , Some ( ( false , false , _ ) ) ) ;
if ! can_share
| | cipher . deleted_at . is_some ( )
| | cipher . atype = = SSH_KEY
| | cipher . get_archived_at ( user_id , conn ) . await . is_some ( )
{
err ! ( "Item not found." , format ! ( "Item Send of {user_id}: item {item_id} can't be shared by this user" ) )
}
Ok ( ( ) )
}
fn set_send_emails ( send : & mut Send , emails : & [ String ] , current : Option < & [ String ] > ) -> EmptyResult {
// Keeping the current recipients doesn't need mail: a key rotation re-saves every Send after other
// writes, and must not fail halfway because email was switched off after the Send was created
let unchanged = current . is_some_and ( | c | {
let ( mut a , mut b ) = ( c . to_vec ( ) , emails . to_vec ( ) ) ;
a . sort_unstable ( ) ;
b . sort_unstable ( ) ;
a = = b
} ) ;
if ! unchanged & & ! CONFIG . mail_enabled ( ) {
err ! ( "Email verified Sends need email to be configured on this server" )
}
if emails . len ( ) > SEND_EMAILS_MAX {
err ! ( format ! ( "A Send can be verified by at most {SEND_EMAILS_MAX} emails" ) )
}
if emails . iter ( ) . any ( | e | e . len ( ) > 254 | | ! e . contains ( '@' ) ) {
err ! ( "Invalid email address" )
}
send . set_auth_emails ( Some ( & emails . join ( "," ) ) )
}
/// Applies the requested access control, following the Bitwarden server: `authType` decides, and a
/// request carrying the type without its secret keeps the current one (the SDK edits that way when
/// the auth is not being changed). An existing email gate is never dropped implicitly: only an
/// explicit `authType` does it, unlike the Bitwarden server, which clears it for older clients.
fn apply_send_auth (
send : & mut Send ,
auth_type : Option < i32 > ,
password : Option < & str > ,
emails : Option < & str > ,
current_emails : Option < & [ String ] > ,
) -> EmptyResult {
let requested_emails = emails . map ( normalize_emails ) . filter ( | e | ! e . is_empty ( ) ) ;
match auth_type . map ( SendAuthType ::from_i32 ) {
Some ( None ) = > err ! ( "Invalid Send auth type" ) ,
Some ( Some ( SendAuthType ::Email ) ) = > {
let Some ( list ) = requested_emails . or_else ( | | current_emails . map ( < [ String ] > ::to_vec ) ) else {
err ! ( "Email verified Sends need at least one email" )
} ;
set_send_emails ( send , & list , current_emails ) ? ;
}
Some ( Some ( SendAuthType ::Password ) ) = > {
if password . is_none ( ) & & send . password_hash . is_none ( ) {
err ! ( "Password protected Sends need a password" )
}
send . set_auth_emails ( None ) ? ;
if let Some ( p ) = password {
send . set_password ( Some ( p ) ) ;
}
}
Some ( Some ( SendAuthType ::None ) ) = > {
send . set_auth_emails ( None ) ? ;
send . set_password ( None ) ;
}
// Clients from before the SDK Sends API don't send `authType`
None = > {
if let Some ( list ) = requested_emails {
set_send_emails ( send , & list , current_emails ) ? ;
} else if let Some ( p ) = password {
send . set_auth_emails ( None ) ? ;
send . set_password ( Some ( p ) ) ;
} else if let Some ( list ) = current_emails {
set_send_emails ( send , list , Some ( list ) ) ? ;
}
}
}
if send . atype = = SendType ::Item as i32 & & send . auth_emails ( ) . is_none ( ) {
err ! ( "Item Sends require email verification" )
}
Ok ( ( ) )
}
fn create_send ( data : SendData , user_id : UserId ) -> ApiResult < Send > {
let data_str = match SendType ::from_i32 ( data . r#type ) {
Some ( send_type @ ( SendType ::Text | SendType ::File ) ) = > {
let data_val = if send_type = = SendType ::Text {
data . text
} else {
data . file
} ;
let Some ( mut d ) = data_val else {
err ! ( "Send data not provided" ) ;
} ;
d . as_object_mut ( ) . and_then ( | o | o . remove ( "response" ) ) ;
serde_json ::to_string ( & d ) ?
}
Some ( SendType ::Item ) = > {
if ! item_sharing_enabled ( ) {
err ! ( "Item Sends are not enabled on this server" )
}
item_data_str ( data . data . as_ref ( ) ) ?
}
None = > err ! ( "Invalid Send type" ) ,
} ;
if data . deletion_date > Utc ::now ( ) + TimeDelta ::try_days ( 31 ) . unwrap ( ) {
@ -148,10 +325,6 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
) ;
}
if data . emails . is_some ( ) {
err ! ( "Sends with email verification is not supported" ) ;
}
let mut send = Send ::new ( data . r#type , data . name , data_str , data . key , data . deletion_date . naive_utc ( ) ) ;
send . user_uuid = Some ( user_id ) ;
send . notes = data . notes ;
@ -164,15 +337,22 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
send . hide_email = data . hide_email ;
send . atype = data . r#type ;
send . set_password ( data . password . as_deref ( ) ) ;
// A client could put the server side email list inside its own data: start from none
send . set_auth_emails ( None ) ? ;
apply_send_auth ( & mut send , data . auth_type , data . password . as_deref ( ) , data . emails . as_deref ( ) , None ) ? ;
Ok ( send )
}
#[ get( " /sends " ) ]
async fn get_sends ( headers : Headers , conn : DbConn ) -> Json < Value > {
let sends = Send ::find_by_user ( & headers . user . uuid , & conn ) ;
let sends_json : Vec < Value > = sends . await . iter ( ) . map ( Send ::to_json ) . collect ( ) ;
async fn get_sends ( headers : Headers , client_version : Option < ClientVersion > , conn : DbConn ) -> Json < Value > {
let show_items = client_supports_item_sends ( headers . device . atype , client_version . as_ref ( ) ) ;
let sends = Send ::find_by_user ( & headers . user . uuid , & conn ) . await ;
let sends_json : Vec < Value > = sends
. iter ( )
. filter ( | s | s . atype ! = SendType ::Item as i32 | | ( show_items & & ! s . is_legacy_item ( ) ) )
. map ( Send ::to_json )
. collect ( ) ;
Json ( json ! ( {
"data" : sends_json ,
@ -182,11 +362,21 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
}
#[ get( " /sends/<send_id> " ) ]
async fn get_send ( send_id : SendId , headers : Headers , conn : DbConn ) -> JsonResult {
if let Some ( send ) = Send ::find_by_uuid_and_user ( & send_id , & headers . user . uuid , & conn ) . await {
Ok ( Json ( send . to_json ( ) ) )
} else {
err ! ( "Send not found" , "Invalid send uuid or does not belong to user" )
async fn get_send (
send_id : SendId ,
headers : Headers ,
client_version : Option < ClientVersion > ,
conn : DbConn ,
) -> JsonResult {
match Send ::find_by_uuid_and_user ( & send_id , & headers . user . uuid , & conn ) . await {
Some ( send )
if send . atype ! = SendType ::Item as i32
| | ( ! send . is_legacy_item ( )
& & client_supports_item_sends ( headers . device . atype , client_version . as_ref ( ) ) ) = >
{
Ok ( Json ( send . to_json ( ) ) )
}
_ = > err ! ( "Send not found" , "Invalid send uuid or does not belong to user" ) ,
}
}
@ -201,7 +391,8 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
err ! ( "File sends should use /api/sends/file/v2" )
}
let mut send = create_send ( data , headers . user . uuid ) ? ;
let mut send = create_send ( data , headers . user . uuid . clone ( ) ) ? ;
validate_item_send ( & send , None , & headers . user . uuid , & conn ) . await ? ;
send . save ( & conn ) . await ? ;
nt . send_send_update (
UpdateType ::SyncSendCreate ,
@ -368,11 +559,11 @@ async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> Json
let Some ( mut send ) = Send ::find_by_uuid ( & headers . send_id , & conn ) . await else {
err_code ! ( SEND_INACCESSIBLE_MSG , 404 )
} ;
if ! send . is_accessible ( ) {
if ! send . is_accessible ( ) | | ( send . atype = = SendType ::Item as i32 & & ! item_sharing_enabled ( ) ) {
err_code ! ( SEND_INACCESSIBLE_MSG , 404 )
}
// Files are incremented during the download
if send . atype = = SendType ::Text as i32 & & ! send . register_access ( & conn ) . await ? {
// Files are incremented during the download, text and item Sends here
if send . atype ! = SendType ::File as i32 & & ! send . register_access ( & conn ) . await ? {
err_code ! ( SEND_INACCESSIBLE_MSG , 404 )
}
process_access ( send , conn , nt ) . await
@ -473,10 +664,6 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
err ! ( "Send not found" , "Send send_id is invalid or does not belong to user" )
} ;
if data . emails . is_some ( ) {
err ! ( "Sends with email verification is not supported" ) ;
}
update_send_from_data ( & mut send , data , & headers , & conn , & nt , UpdateType ::SyncSendUpdate ) . await ? ;
Ok ( Json ( send . to_json ( ) ) )
@ -504,8 +691,12 @@ async fn update_send_from_data(
) ;
}
// The email list lives inside `data`, which a Text or Item update replaces, and so does the item id
let current_emails = send . auth_emails ( ) ;
let stored_item_id = send . item_id ( ) ;
// When updating a file Send, we receive nulls in the File field, as it's immutable,
// so we only need to update the data field in the Text case
// so we only need to update the data field in the Text and Item cases
if data . r#type = = SendType ::Text as i32 {
let data_str = if let Some ( mut d ) = data . text {
d . as_object_mut ( ) . and_then ( | d | d . remove ( "response" ) ) ;
@ -514,6 +705,13 @@ async fn update_send_from_data(
err ! ( "Send data not provided" ) ;
} ;
send . data = data_str ;
} else if data . r#type = = SendType ::Item as i32 {
if ! item_sharing_enabled ( ) {
err ! ( "Item Sends are not enabled on this server" )
}
if data . data . is_some ( ) {
send . data = item_data_str ( data . data . as_ref ( ) ) ? ;
}
}
send . name = data . name ;
@ -528,10 +726,8 @@ async fn update_send_from_data(
send . hide_email = data . hide_email ;
send . disabled = data . disabled ;
// Only change the value if it's present
if let Some ( password ) = data . password {
send . set_password ( Some ( & password ) ) ;
}
apply_send_auth ( send , data . auth_type , data . password . as_deref ( ) , data . emails . as_deref ( ) , current_emails . as_deref ( ) ) ? ;
validate_item_send ( send , stored_item_id , & headers . user . uuid , conn ) . await ? ;
send . save ( conn ) . await ? ;
if ut ! = UpdateType ::None {
@ -580,3 +776,123 @@ async fn put_remove_password(send_id: SendId, headers: Headers, conn: DbConn, nt
Ok ( Json ( send . to_json ( ) ) )
}
#[ cfg(test) ]
mod tests {
use super ::* ;
fn new_send ( atype : SendType ) -> Send {
let deletion = ( Utc ::now ( ) + TimeDelta ::try_days ( 1 ) . unwrap ( ) ) . naive_utc ( ) ;
Send ::new ( atype as i32 , "2.name" . into ( ) , r#"{"text":"2.t"}"#.into(), "2.key" . into ( ) , deletion )
}
#[ test ]
fn item_data_is_validated_and_trimmed_to_known_fields ( ) {
let item_id = "5d4fbf2b-7a36-4b3c-9f2e-1a6d8c0e9b71" ;
let metadata = json ! ( { "itemId" : item_id , "extra" : 1 } ) ;
let stored = item_data_str ( Some (
& json ! ( { "encryptionVersion" : 1 , "data" : "blob" , "metadata" : metadata , "vwAuthEmails" : "x@y.z" } ) ,
) )
. unwrap ( ) ;
assert_eq ! (
serde_json ::from_str ::< Value > ( & stored ) . unwrap ( ) ,
json ! ( { "encryptionVersion" : 1 , "data" : "blob" , "metadata" : { "itemId" : item_id } } )
) ;
let metadata = json ! ( { "itemId" : item_id } ) ;
assert ! ( item_data_str ( None ) . is_err ( ) ) ;
assert ! ( item_data_str ( Some ( & json ! ( { "encryptionVersion" : 1 , "data" : "" , "metadata" : metadata } ) ) ) . is_err ( ) ) ;
let too_big = "a" . repeat ( ITEM_DATA_MAX_LEN + 1 ) ;
assert ! ( item_data_str ( Some ( & json ! ( { "data" : too_big , "metadata" : metadata } ) ) ) . is_err ( ) ) ;
}
#[ test ]
fn item_data_needs_the_item_id ( ) {
assert ! ( item_data_str ( Some ( & json ! ( { "encryptionVersion" : 1 , "data" : "blob" } ) ) ) . is_err ( ) , "old format" ) ;
assert ! ( item_data_str ( Some ( & json ! ( { "data" : "blob" , "metadata" : { } } ) ) ) . is_err ( ) ) ;
assert ! ( item_data_str ( Some ( & json ! ( { "data" : "blob" , "metadata" : { "itemId" : "c" } } ) ) ) . is_err ( ) ) ;
assert ! ( item_data_str ( Some ( & json ! ( { "data" : "blob" , "metadata" : { "itemId" : 7 } } ) ) ) . is_err ( ) ) ;
let upper = "5D4FBF2B-7A36-4B3C-9F2E-1A6D8C0E9B71" ;
let stored = item_data_str ( Some ( & json ! ( { "Data" : "blob" , "Metadata" : { "ItemId" : upper } } ) ) ) . unwrap ( ) ;
let stored = serde_json ::from_str ::< Value > ( & stored ) . unwrap ( ) ;
assert_eq ! ( stored [ "metadata" ] [ "itemId" ] , upper . to_lowercase ( ) , "stored as the clients write cipher ids" ) ;
}
#[ test ]
fn password_auth_needs_a_password_unless_one_exists ( ) {
let mut send = new_send ( SendType ::Text ) ;
assert ! ( apply_send_auth ( & mut send , Some ( SendAuthType ::Password as i32 ) , None , None , None ) . is_err ( ) ) ;
apply_send_auth ( & mut send , Some ( SendAuthType ::Password as i32 ) , Some ( "hash" ) , None , None ) . unwrap ( ) ;
let hash = send . password_hash . clone ( ) ;
assert ! ( hash . is_some ( ) ) ;
// The SDK edits with the type and no secret to keep the current password
apply_send_auth ( & mut send , Some ( SendAuthType ::Password as i32 ) , None , None , None ) . unwrap ( ) ;
assert_eq ! ( send . password_hash , hash ) ;
}
#[ test ]
fn explicit_none_removes_the_password ( ) {
let mut send = new_send ( SendType ::Text ) ;
send . set_password ( Some ( "hash" ) ) ;
apply_send_auth ( & mut send , Some ( SendAuthType ::None as i32 ) , None , None , None ) . unwrap ( ) ;
assert ! ( send . password_hash . is_none ( ) ) ;
}
#[ test ]
fn older_clients_keep_the_password_when_they_send_none ( ) {
let mut send = new_send ( SendType ::Text ) ;
send . set_password ( Some ( "hash" ) ) ;
apply_send_auth ( & mut send , None , None , None , None ) . unwrap ( ) ;
assert ! ( send . password_hash . is_some ( ) ) ;
}
#[ test ]
fn unknown_auth_type_is_refused ( ) {
let mut send = new_send ( SendType ::Text ) ;
assert ! ( apply_send_auth ( & mut send , Some ( 9 ) , None , None , None ) . is_err ( ) ) ;
}
#[ test ]
fn item_sends_can_not_drop_email_verification ( ) {
let mut send = new_send ( SendType ::Item ) ;
assert ! ( apply_send_auth ( & mut send , Some ( SendAuthType ::None as i32 ) , None , None , None ) . is_err ( ) ) ;
assert ! ( apply_send_auth ( & mut send , Some ( SendAuthType ::Password as i32 ) , Some ( "hash" ) , None , None ) . is_err ( ) ) ;
assert ! ( apply_send_auth ( & mut send , None , None , None , None ) . is_err ( ) ) ;
}
#[ test ]
fn email_verified_sends_need_mail ( ) {
// The test config has no SMTP: an email verified Send would be impossible to open
let mut send = new_send ( SendType ::Text ) ;
assert ! ( ! CONFIG . mail_enabled ( ) ) ;
assert ! ( apply_send_auth ( & mut send , Some ( SendAuthType ::Email as i32 ) , None , Some ( "a@x.com" ) , None ) . is_err ( ) ) ;
}
#[ test ]
fn keeping_the_recipients_needs_no_mail ( ) {
// A key rotation re-saves every Send: switching email off later must not make it fail halfway
assert ! ( ! CONFIG . mail_enabled ( ) ) ;
let current = | | Some ( vec ! [ "a@x.com" . to_owned ( ) , "b@x.com" . to_owned ( ) ] ) ;
let email = Some ( SendAuthType ::Email as i32 ) ;
let mut send = new_send ( SendType ::Text ) ;
assert ! ( apply_send_auth ( & mut send , email , None , Some ( "b@x.com, A@x.com" ) , current ( ) . as_deref ( ) ) . is_ok ( ) ) ;
assert ! ( apply_send_auth ( & mut send , email , None , None , current ( ) . as_deref ( ) ) . is_ok ( ) ) ;
assert ! ( apply_send_auth ( & mut send , None , None , None , current ( ) . as_deref ( ) ) . is_ok ( ) ) ;
assert_eq ! ( send . auth_emails ( ) , current ( ) ) ;
// Changing the recipients still needs mail
assert ! ( apply_send_auth ( & mut send , email , None , Some ( "a@x.com" ) , current ( ) . as_deref ( ) ) . is_err ( ) ) ;
assert ! ( apply_send_auth ( & mut send , None , None , Some ( "c@x.com" ) , current ( ) . as_deref ( ) ) . is_err ( ) ) ;
}
#[ test ]
fn min_client_version_gate ( ) {
let v = | s : & str | ClientVersion ( semver ::Version ::parse ( s ) . unwrap ( ) ) ;
assert ! ( ! client_version_supports_item_sharing ( None ) ) ;
assert ! ( ! client_version_supports_item_sharing ( Some ( & v ( "2026.9.3" ) ) ) ) ;
assert ! ( client_version_supports_item_sharing ( Some ( & v ( "2026.10.0" ) ) ) ) ;
assert ! ( client_version_supports_item_sharing ( Some ( & v ( "2027.1.0" ) ) ) ) ;
}
}