/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ use super::ImapContext; use crate::{ core::{MailboxId, SelectedMailbox, Session, SessionData}, spawn_op, }; use common::{ipc::PushNotification, network::SessionStream, storage::index::ObjectIndexBuilder}; use email::{ cache::{MessageCacheFetch, email::MessageCacheAccess}, mailbox::{JUNK_ID, TRASH_ID, UidMailbox}, message::{ copy::{CopyMessageError, EmailCopy}, ingest::EmailIngest, metadata::MessageData, }, }; use imap_proto::{ Command, ResponseCode, StatusResponse, protocol::copy_move::Arguments, receiver::Request, }; use registry::schema::enums::Permission; use std::{sync::Arc, time::Instant}; use store::{ ValueKey, roaring::RoaringBitmap, write::{AlignedBytes, Archive, BatchBuilder}, }; use trc::AddContext; use types::{ acl::Acl, collection::{Collection, VanishedCollection}, type_state::{DataType, StateChange}, }; impl Session { pub async fn handle_copy_move( &mut self, request: Request, is_move: bool, is_uid: bool, ) -> trc::Result<()> { // Validate access self.assert_has_permission(if is_move { Permission::ImapMove } else { Permission::ImapCopy })?; let op_start = Instant::now(); let arguments = request.parse_copy_move(self.is_utf8)?; let (data, src_mailbox) = self.state.mailbox_state(); let use_vanished = self.is_qresync || self.is_uidonly; // RFC 9738 places COPY under SAVELIMIT but leaves MOVE under MESSAGELIMIT let message_limit = if is_move { self.server.core.imap.max_messages_per_command } else { self.server.core.imap.max_messages_per_save }; spawn_op!(data, { // Refresh mailboxes data.synchronize_mailboxes(false) .await .imap_ctx(&arguments.tag, trc::location!())?; // Make sure the mailbox exists. let dest_mailbox = if let Some(mailbox) = data.get_mailbox_by_name(&arguments.mailbox_name) { mailbox } else { return Err(trc::ImapEvent::Error .into_err() .details("Destination mailbox does not exist.") .code(ResponseCode::TryCreate) .id(arguments.tag)); }; // Check that the destination mailbox is not the same as the source mailbox. if src_mailbox.id.account_id == dest_mailbox.account_id && src_mailbox.id.mailbox_id == dest_mailbox.mailbox_id { return Err(trc::ImapEvent::Error .into_err() .details("Source and destination mailboxes are the same.") .code(ResponseCode::Cannot) .id(arguments.tag)); } data.copy_move( arguments, src_mailbox, dest_mailbox, is_move, is_uid, use_vanished, message_limit, op_start, ) .await }) } } impl SessionData { #[allow(clippy::too_many_arguments)] pub async fn copy_move( &self, arguments: Arguments, src_mailbox: Arc, dest_mailbox: MailboxId, is_move: bool, is_uid: bool, use_vanished: bool, message_limit: u32, op_start: Instant, ) -> trc::Result<()> { self.synchronize_messages(&src_mailbox) .await .imap_ctx(&arguments.tag, trc::location!())?; // Convert IMAP ids to JMAP ids. let ids = src_mailbox .sequence_to_ids(&arguments.sequence_set, is_uid) .await .imap_ctx(&arguments.tag, trc::location!())?; if ids.is_empty() { trc::event!( Imap(if is_move { trc::ImapEvent::Move } else { trc::ImapEvent::Copy }), SpanId = self.session_id, Source = src_mailbox.id.account_id, Details = trc::Value::None, Uid = trc::Value::None, AccountId = dest_mailbox.account_id, MailboxId = dest_mailbox.mailbox_id, Elapsed = op_start.elapsed() ); return self .write_bytes( StatusResponse::ok(if is_move { "No messages were moved." } else { "No messages were copied." }) .with_tag(arguments.tag) .into_bytes(), ) .await; } // Verify that the user can delete messages from the source mailbox. if is_move && !self .check_mailbox_acl( src_mailbox.id.account_id, src_mailbox.id.mailbox_id, Acl::RemoveItems, ) .await .imap_ctx(&arguments.tag, trc::location!())? { return Err(trc::ImapEvent::Error .into_err() .details(concat!( "You do not have the required permissions to ", "remove messages from the source mailbox." )) .code(ResponseCode::NoPerm) .id(arguments.tag)); } // Verify that the user can append messages to the destination mailbox. let dest_mailbox_id = dest_mailbox.mailbox_id; if !self .check_mailbox_acl(dest_mailbox.account_id, dest_mailbox_id, Acl::AddItems) .await .imap_ctx(&arguments.tag, trc::location!())? { return Err(trc::ImapEvent::Error .into_err() .details(concat!( "You do not have the required permissions to ", "add messages to the destination mailbox." )) .code(ResponseCode::NoPerm) .id(arguments.tag)); } // RFC 9738 requires the highest UIDs to be processed first when truncating. // COPY is atomic, so it is refused outright rather than partially applied. let mut ids = ids; let message_limit = message_limit as usize; let mut limited_uid = None; if ids.len() > message_limit { let mut uids = ids.values().map(|imap_id| imap_id.uid).collect::>(); let cutoff = uids.len() - message_limit; let lowest_uid = *uids.select_nth_unstable(cutoff).1; if !is_move { return self .write_bytes( StatusResponse::no("Too many messages to copy, try a smaller subset.") .with_tag(arguments.tag) .with_code(ResponseCode::MessageLimit { limit: message_limit as u32, uid: lowest_uid.into(), }) .into_bytes(), ) .await; } ids.retain(|_, imap_id| imap_id.uid >= lowest_uid); limited_uid = Some(lowest_uid); } let response = StatusResponse::completed(if is_move { Command::Move(is_uid) } else { Command::Copy(is_uid) }); let mut error: Option<(ResponseCode, &'static str)> = None; let mut did_move = false; let mut copied_ids = Vec::with_capacity(ids.len()); if src_mailbox.id.account_id == dest_mailbox.account_id { // Mailboxes are in the same account let account_id = src_mailbox.id.account_id; let dest_mailbox_id = UidMailbox::new_unassigned(dest_mailbox_id); let mut batch = BatchBuilder::new(); for (id, imap_id) in ids { // Obtain mailbox tags let data_ = if let Some(result) = self .get_message_data(account_id, id) .await .imap_ctx(&arguments.tag, trc::location!())? { result } else { continue; }; // Deserialize let data = data_ .to_unarchived::() .imap_ctx(&arguments.tag, trc::location!())?; // Make sure the message still belongs to this mailbox if !data .inner .mailboxes .iter() .any(|mailbox| mailbox.mailbox_id == src_mailbox.id.mailbox_id) { continue; } // If the message is already in the destination mailbox, skip it. if let Some(mailbox) = data .inner .mailboxes .iter() .find(|mailbox| mailbox.mailbox_id == dest_mailbox_id.mailbox_id) { copied_ids.push((imap_id.uid, mailbox.uid.to_native())); if is_move { let mut new_data = data.inner.to_builder(); new_data.remove_mailbox(src_mailbox.id.mailbox_id); batch .with_account_id(account_id) .with_collection(Collection::Email) .with_document(id) .custom( ObjectIndexBuilder::new() .with_current(data) .with_changes(new_data.seal()), ) .imap_ctx(&arguments.tag, trc::location!())? .log_vanished_item( VanishedCollection::Email, (src_mailbox.id.mailbox_id, imap_id.uid), ) .commit_point(); did_move = true; } continue; } // Prepare changes let mut new_data = data.inner.to_builder(); // Add destination folder new_data.add_mailbox(dest_mailbox_id); if is_move { new_data.remove_mailbox(src_mailbox.id.mailbox_id); } // Assign IMAP UIDs let ids = self .server .assign_email_ids( account_id, new_data .mailboxes .iter() .filter(|m| m.uid == 0) .map(|m| m.mailbox_id), false, ) .await .caused_by(trc::location!())?; for (uid_mailbox, uid) in new_data .mailboxes .iter_mut() .filter(|m| m.uid == 0) .zip(ids) { copied_ids.push((imap_id.uid, uid)); uid_mailbox.uid = uid; } // Prepare write batch batch .with_account_id(account_id) .with_collection(Collection::Email) .with_document(id) .custom( ObjectIndexBuilder::new() .with_current(data) .with_changes(new_data.seal()), ) .imap_ctx(&arguments.tag, trc::location!())?; if is_move { batch.log_vanished_item( VanishedCollection::Email, (src_mailbox.id.mailbox_id, imap_id.uid), ); } // Add message to training queue if dest_mailbox_id.mailbox_id == JUNK_ID { self.server .add_account_spam_sample(&mut batch, account_id, id, true, self.session_id) .await .imap_ctx(&arguments.tag, trc::location!())?; } else if src_mailbox.id.mailbox_id == JUNK_ID && dest_mailbox_id.mailbox_id != TRASH_ID { self.server .add_account_spam_sample(&mut batch, account_id, id, false, self.session_id) .await .imap_ctx(&arguments.tag, trc::location!())?; } batch.commit_point(); // Update changelog if is_move { did_move = true; } } // Write changes self.server .commit_batch(batch) .await .imap_ctx(&arguments.tag, trc::location!())?; } else { // Obtain quota for target account let src_account_id = src_mailbox.id.account_id; let mut dest_change_id = None; let dest_account_id = dest_mailbox.account_id; let mut destroy_ids = RoaringBitmap::new(); let cache = self .server .get_cached_messages(src_account_id) .await .imap_ctx(&arguments.tag, trc::location!())?; let mut dest_cache = None; let train_spam = if dest_mailbox_id == JUNK_ID { Some(true) } else if src_mailbox.id.mailbox_id == JUNK_ID && dest_mailbox_id != TRASH_ID { Some(false) } else { None }; let mut train_batch = BatchBuilder::new(); let mut did_train = false; train_batch.with_account_id(src_account_id); for (id, imap_id) in ids { match self .server .copy_message( src_account_id, id, dest_account_id, vec![dest_mailbox_id], cache .email_by_id(&id) .map(|e| cache.expand_keywords(e).collect()) .unwrap_or_default(), None, self.session_id, ) .await .imap_ctx(&arguments.tag, trc::location!())? { Ok(email) => { dest_change_id = email.change_id.into(); if let Some(assigned_uid) = email.imap_uids.first() { debug_assert!(*assigned_uid > 0); copied_ids.push((imap_id.uid, *assigned_uid)); } } Err(CopyMessageError::AlreadyExists(existing_id)) => { if dest_cache.is_none() { dest_cache = self .server .get_cached_messages(dest_account_id) .await .imap_ctx(&arguments.tag, trc::location!())? .into(); } if let Some(uid) = dest_cache .as_ref() .and_then(|cache| cache.email_by_id(&existing_id)) .and_then(|message| { message .mailboxes .iter() .find(|mailbox| mailbox.mailbox_id == dest_mailbox_id) }) .map(|mailbox| mailbox.uid) { copied_ids.push((imap_id.uid, uid)); } else { let data_ = if let Some(data_) = self .get_message_data(dest_account_id, existing_id) .await .imap_ctx(&arguments.tag, trc::location!())? { data_ } else { continue; }; let data = data_ .to_unarchived::() .imap_ctx(&arguments.tag, trc::location!())?; if let Some(uid) = data.inner.message_uid(dest_mailbox_id) { copied_ids.push((imap_id.uid, uid)); } else { let mut new_data = data.inner.to_builder(); new_data.add_mailbox(UidMailbox::new_unassigned(dest_mailbox_id)); let uids = self .server .assign_email_ids( dest_account_id, new_data .mailboxes .iter() .filter(|m| m.uid == 0) .map(|m| m.mailbox_id), false, ) .await .caused_by(trc::location!())?; let mut assigned_uid = 0; for (uid_mailbox, uid) in new_data .mailboxes .iter_mut() .filter(|m| m.uid == 0) .zip(uids) { uid_mailbox.uid = uid; assigned_uid = uid; } let mut batch = BatchBuilder::new(); batch .with_account_id(dest_account_id) .with_collection(Collection::Email) .with_document(existing_id) .custom( ObjectIndexBuilder::new() .with_current(data) .with_changes(new_data.seal()), ) .imap_ctx(&arguments.tag, trc::location!())?; dest_change_id = self .server .commit_batch(batch) .await .and_then(|ids| ids.last_change_id(dest_account_id)) .imap_ctx(&arguments.tag, trc::location!())? .into(); copied_ids.push((imap_id.uid, assigned_uid)); } } } Err(CopyMessageError::OverQuota) => { error = Some((ResponseCode::OverQuota, "Mailbox quota exceeded")); continue; } Err(CopyMessageError::NotFound) => { continue; } }; if let Some(is_spam) = train_spam { self.server .add_account_spam_sample( &mut train_batch, src_account_id, id, is_spam, self.session_id, ) .await .imap_ctx(&arguments.tag, trc::location!())?; train_batch.commit_point(); did_train = true; } if is_move { destroy_ids.insert(id); } } if did_train { self.server .commit_batch(train_batch) .await .imap_ctx(&arguments.tag, trc::location!())?; } // Untag or delete emails if !destroy_ids.is_empty() { let mut batch = BatchBuilder::new(); self.email_untag_or_delete( src_account_id, src_mailbox.id.mailbox_id, &destroy_ids, &mut batch, ) .await .imap_ctx(&arguments.tag, trc::location!())?; self.server .commit_batch(batch) .await .imap_ctx(&arguments.tag, trc::location!())?; did_move = true; } // Broadcast changes on destination account if let Some(change_id) = dest_change_id { self.server .broadcast_push_notification(PushNotification::StateChange( StateChange::new(dest_account_id) .with_change_id(change_id) .with_change(DataType::Email) .with_change(DataType::Thread) .with_change(DataType::Mailbox), )) .await; } } // Map copied JMAP Ids to IMAP UIDs in the destination folder. if copied_ids.is_empty() { return if let Some((code, message)) = error { Err(trc::ImapEvent::Error .into_err() .details(message) .ctx(trc::Key::Code, code) .id(arguments.tag)) } else { trc::event!( Imap(if is_move { trc::ImapEvent::Move } else { trc::ImapEvent::Copy }), SpanId = self.session_id, Source = src_mailbox.id.account_id, Details = trc::Value::None, Uid = trc::Value::None, AccountId = dest_mailbox.account_id, MailboxId = dest_mailbox.mailbox_id, Elapsed = op_start.elapsed() ); self.write_bytes( StatusResponse::ok(if is_move { "No messages were moved." } else { "No messages were copied." }) .with_tag(arguments.tag) .into_bytes(), ) .await }; } // Prepare response let uid_validity = self .mailbox_state(&dest_mailbox) .map(|m| m.uid_validity as u32) .unwrap_or_default(); let mut src_uids = Vec::with_capacity(copied_ids.len()); let mut dest_uids = Vec::with_capacity(copied_ids.len()); for (src_uid, dest_uid) in copied_ids { src_uids.push(src_uid); dest_uids.push(dest_uid); } src_uids.sort_unstable(); dest_uids.sort_unstable(); trc::event!( Imap(if is_move { trc::ImapEvent::Move } else { trc::ImapEvent::Copy }), SpanId = self.session_id, Source = src_mailbox.id.account_id, Details = src_uids .iter() .map(|r| trc::Value::from(*r)) .collect::>(), AccountId = dest_mailbox.account_id, MailboxId = dest_mailbox.mailbox_id, Uid = dest_uids .iter() .map(|r| trc::Value::from(*r)) .collect::>(), Elapsed = op_start.elapsed() ); let response = if is_move { self.write_bytes( StatusResponse::ok("Copied UIDs") .with_code(ResponseCode::CopyUid { uid_validity, src_uids, dest_uids, }) .into_bytes(), ) .await?; if did_move { // Resynchronize source mailbox on a successful move self.write_mailbox_changes(&src_mailbox, use_vanished) .await .imap_ctx(&arguments.tag, trc::location!())?; } let response = response.with_tag(arguments.tag); match limited_uid { Some(uid) => response.with_code(ResponseCode::MessageLimit { limit: message_limit as u32, uid: uid.into(), }), None => response, } .into_bytes() } else { response .with_tag(arguments.tag) .with_code(ResponseCode::CopyUid { uid_validity, src_uids, dest_uids, }) .into_bytes() }; self.write_bytes(response).await } pub async fn get_message_data( &self, account_id: u32, id: u32, ) -> trc::Result>> { if let Some(data) = self .server .store() .get_value::>(ValueKey::archive( account_id, Collection::Email, id, )) .await? { Ok(Some(data)) } else { trc::event!( Store(trc::StoreEvent::NotFound), AccountId = account_id, Collection = Collection::Email, MessageId = id, SpanId = self.session_id, Details = "Message not found" ); Ok(None) } } }