diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 57f32658ea..6e943aef21 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -1056,6 +1056,21 @@ uint32_t dc_send_text_msg (dc_context_t* context, uint32_t ch void dc_send_edit_request (dc_context_t* context, uint32_t msg_id, const char* new_text); +/** + * Send chat members a request to delete the given messages. + * + * Only outgoing messages can be deleted this way + * and all messages must be in the same chat. + * No tombstone or sth. like that is left. + * + * @memberof dc_context_t + * @param context The context object as returned from dc_context_new(). + * @param msg_ids An array of uint32_t containing all message IDs to delete. + * @param msg_cnt The number of messages IDs in the msg_ids array. + */ + void dc_send_delete_request (dc_context_t* context, const uint32_t* msg_ids, int msg_cnt); + + /** * Send invitation to a videochat. * diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 3d25e0c2b2..0c0e8b9677 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -1058,6 +1058,25 @@ pub unsafe extern "C" fn dc_send_edit_request( .unwrap_or_log_default(ctx, "Failed to send text edit") } +#[no_mangle] +pub unsafe extern "C" fn dc_send_delete_request( + context: *mut dc_context_t, + msg_ids: *const u32, + msg_cnt: libc::c_int, +) { + if context.is_null() || msg_ids.is_null() || msg_cnt <= 0 { + eprintln!("ignoring careless call to dc_send_delete_request()"); + return; + } + let ctx = &*context; + let msg_ids = convert_and_prune_message_ids(msg_ids, msg_cnt); + + block_on(message::delete_msgs_ex(ctx, &msg_ids, true)) + .context("failed dc_send_delete_request() call") + .log_err(ctx) + .ok(); +} + #[no_mangle] pub unsafe extern "C" fn dc_send_videochat_invitation( context: *mut dc_context_t, diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index 74d2bf4839..4fe4011c4b 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -3802,3 +3802,44 @@ async fn test_cannot_send_edit_request() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_send_delete_request() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + let alice_chat = alice.create_chat(bob).await; + let bob_chat = bob.create_chat(alice).await; + + // Bobs sends a message to Alice, so Alice learns Bob's key + let sent0 = bob.send_text(bob_chat.id, "¡ola!").await; + alice.recv_msg(&sent0).await; + + // Alice sends a message, then sends a deletion request + let sent1 = alice.send_text(alice_chat.id, "wtf").await; + let alice_msg = sent1.load_from_db().await; + assert_eq!(alice_chat.id.get_msg_cnt(alice).await?, 2); + + message::delete_msgs_ex(alice, &[alice_msg.id], true).await?; + let sent2 = alice.pop_sent_msg().await; + assert_eq!(alice_chat.id.get_msg_cnt(alice).await?, 1); + + // Bob receives both messages and has nothing the end + let bob_msg = bob.recv_msg(&sent1).await; + assert_eq!(bob_msg.text, "wtf"); + assert_eq!(bob_msg.chat_id.get_msg_cnt(bob).await?, 2); + + bob.recv_msg_opt(&sent2).await; + assert_eq!(bob_msg.chat_id.get_msg_cnt(bob).await?, 1); + + // Alice has another device, and there is also nothing at the end + let alice2 = &tcm.alice().await; + alice2.recv_msg(&sent0).await; + let alice2_msg = alice2.recv_msg(&sent1).await; + assert_eq!(alice2_msg.chat_id.get_msg_cnt(alice2).await?, 2); + + alice2.recv_msg_opt(&sent2).await; + assert_eq!(alice2_msg.chat_id.get_msg_cnt(alice2).await?, 1); + + Ok(()) +} diff --git a/src/headerdef.rs b/src/headerdef.rs index 8ce262a472..162cc4a73e 100644 --- a/src/headerdef.rs +++ b/src/headerdef.rs @@ -80,6 +80,9 @@ pub enum HeaderDef { ChatDispositionNotificationTo, ChatWebrtcRoom, + /// This message deletes the messages listed in the value by rfc724_mid. + ChatDelete, + /// This message obsoletes the text of the message defined here by rfc724_mid. ChatEdit, diff --git a/src/message.rs b/src/message.rs index bfee5d7723..4673e56d1c 100644 --- a/src/message.rs +++ b/src/message.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use tokio::{fs, io}; use crate::blob::BlobObject; -use crate::chat::{Chat, ChatId, ChatIdBlocked, ChatVisibility}; +use crate::chat::{send_msg, Chat, ChatId, ChatIdBlocked, ChatVisibility}; use crate::chatlist_events; use crate::config::Config; use crate::constants::{ @@ -1711,20 +1711,31 @@ pub(crate) async fn delete_msgs_locally_done( Ok(()) } -/// Deletes requested messages -/// by moving them to the trash chat -/// and scheduling for deletion on IMAP. +/// Delete messages on all devices and on IMAP. pub async fn delete_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> { + delete_msgs_ex(context, msg_ids, false).await +} + +/// Delete messages on all devices, on IMAP and optionally for all chat members. +/// Deleted messages are moved to the trash chat and scheduling for deletion on IMAP. +/// When deleting messages for others, all messages must be self-sent and in the same chat. +pub async fn delete_msgs_ex( + context: &Context, + msg_ids: &[MsgId], + delete_for_all: bool, +) -> Result<()> { let mut modified_chat_ids = HashSet::new(); let mut deleted_rfc724_mid = Vec::new(); let mut res = Ok(()); for &msg_id in msg_ids { let msg = Message::load_from_db(context, msg_id).await?; - delete_msg_locally(context, &msg).await?; + ensure!( + !delete_for_all || msg.from_id == ContactId::SELF, + "Can delete only own messages for others" + ); modified_chat_ids.insert(msg.chat_id); - deleted_rfc724_mid.push(msg.rfc724_mid.clone()); let target = context.get_delete_msgs_target().await?; @@ -1744,13 +1755,32 @@ pub async fn delete_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> { } res?; - delete_msgs_locally_done(context, msg_ids, modified_chat_ids).await?; + if delete_for_all { + ensure!( + modified_chat_ids.len() == 1, + "Can delete only from same chat." + ); + if let Some(chat_id) = modified_chat_ids.iter().next() { + let mut msg = Message::new_text("🚮".to_owned()); + msg.param.set_int(Param::GuaranteeE2ee, 1); + msg.param + .set(Param::DeleteRequestFor, deleted_rfc724_mid.join(" ")); + msg.hidden = true; + send_msg(context, *chat_id, &mut msg).await?; + } + } else { + context + .add_sync_item(SyncData::DeleteMessages { + msgs: deleted_rfc724_mid, + }) + .await?; + } - context - .add_sync_item(SyncData::DeleteMessages { - msgs: deleted_rfc724_mid, - }) - .await?; + for &msg_id in msg_ids { + let msg = Message::load_from_db(context, msg_id).await?; + delete_msg_locally(context, &msg).await?; + } + delete_msgs_locally_done(context, msg_ids, modified_chat_ids).await?; // Interrupt Inbox loop to start message deletion, run housekeeping and call send_sync_msg(). context.scheduler.interrupt_inbox().await; diff --git a/src/mimefactory.rs b/src/mimefactory.rs index f728ce77cc..8dec42481b 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -734,6 +734,12 @@ impl MimeFactory { ) .into(), )); + } else if let Some(rfc724_mid_list) = msg.param.get(Param::DeleteRequestFor) { + headers.push(( + "Chat-Delete", + mail_builder::headers::message_id::MessageId::new(rfc724_mid_list.to_string()) + .into(), + )); } } @@ -861,7 +867,10 @@ impl MimeFactory { if header_name == "message-id" { unprotected_headers.push(header.clone()); hidden_headers.push(header.clone()); - } else if header_name == "chat-user-avatar" || header_name == "chat-edit" { + } else if header_name == "chat-user-avatar" + || header_name == "chat-delete" + || header_name == "chat-edit" + { hidden_headers.push(header.clone()); } else if header_name == "autocrypt" && !context.get_config_bool(Config::ProtectAutocrypt).await? diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 12167dcc62..df739b24a9 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -292,6 +292,7 @@ impl MimeMessage { if !headers.contains_key(&key) && (key == "chat-user-avatar" || key == "chat-group-avatar" + || key == "chat-delete" || key == "chat-edit") { headers.insert(key.to_string(), field.get_value()); @@ -450,6 +451,7 @@ impl MimeMessage { HeaderDef::ChatGroupMemberAdded, HeaderDef::ChatGroupMemberTimestamps, HeaderDef::ChatGroupPastMembers, + HeaderDef::ChatDelete, HeaderDef::ChatEdit, ] { headers.remove(h.get_headername()); diff --git a/src/param.rs b/src/param.rs index e5c7125699..31d7998036 100644 --- a/src/param.rs +++ b/src/param.rs @@ -207,6 +207,9 @@ pub enum Param { /// For messages: Whether [crate::message::Viewtype::Sticker] should be forced. ForceSticker = b'X', + /// For messages: Message is a deletion request. The value is a list of rfc724_mid of the messages to delete. + DeleteRequestFor = b'M', + /// For messages: Message is a text edit message. the value of this parameter is the rfc724_mid of the original message. TextEditFor = b'I', diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 3e4a30773a..97f9e3f349 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -1532,6 +1532,38 @@ async fn add_parts( "Edit message: rfc724_mid {rfc724_mid:?} not found." ); } + } else if let Some(rfc724_mid_list) = mime_parser.get_header(HeaderDef::ChatDelete) { + chat_id = DC_CHAT_ID_TRASH; + if let Some(part) = mime_parser.parts.first() { + if part.param.get_bool(Param::GuaranteeE2ee).unwrap_or(false) { + let mut modified_chat_ids = HashSet::new(); + let mut msg_ids = Vec::new(); + + let rfc724_mid_vec: Vec<&str> = rfc724_mid_list.split_whitespace().collect(); + for rfc724_mid in rfc724_mid_vec { + if let Some((msg_id, _)) = + message::rfc724_mid_exists(context, rfc724_mid).await? + { + if let Some(msg) = Message::load_from_db_optional(context, msg_id).await? { + if msg.from_id == from_id { + message::delete_msg_locally(context, &msg).await?; + msg_ids.push(msg.id); + modified_chat_ids.insert(msg.chat_id); + } else { + warn!(context, "Delete message: Bad sender."); + } + } else { + warn!(context, "Delete message: Database entry does not exist."); + } + } else { + warn!(context, "Delete message: {rfc724_mid:?} not found."); + } + } + message::delete_msgs_locally_done(context, &msg_ids, modified_chat_ids).await?; + } else { + warn!(context, "Delete message: Not encrypted."); + } + } } let mut parts = mime_parser.parts.iter().peekable();