From 822a99ea9c04c57775ad01208fa2c117a68c2eb9 Mon Sep 17 00:00:00 2001 From: link2xt Date: Fri, 13 Mar 2026 01:09:48 +0000 Subject: [PATCH] fix: do not send MDNs for hidden messages Hidden messages are marked as seen when chat is marked as noticed. MDNs to such messages should not be sent as this notifies the hidden message sender that the chat was opened. The issue discovered by Frank Seifferth. --- src/message.rs | 1 + src/reaction.rs | 154 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/src/message.rs b/src/message.rs index ccd41ccfe..fb89466d4 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1934,6 +1934,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec) -> Result<()> // We also don't send read receipts for contact requests. // Read receipts will not be sent even after accepting the chat. let to_id = if curr_blocked == Blocked::Not + && !curr_hidden && curr_param.get_bool(Param::WantsMdn).unwrap_or_default() && curr_param.get_cmd() == SystemMessage::Unknown && context.should_send_mdns().await? diff --git a/src/reaction.rs b/src/reaction.rs index 0782c9ad4..7d96776f6 100644 --- a/src/reaction.rs +++ b/src/reaction.rs @@ -393,7 +393,9 @@ mod tests { use crate::chatlist::Chatlist; use crate::config::Config; use crate::contact::{Contact, Origin}; - use crate::message::{MessageState, Viewtype, delete_msgs}; + use crate::key::{load_self_public_key, load_self_secret_key}; + use crate::message::{MessageState, Viewtype, delete_msgs, markseen_msgs}; + use crate::pgp::{SeipdVersion, pk_encrypt}; use crate::receive_imf::receive_imf; use crate::sql::housekeeping; use crate::test_utils::E2EE_INFO_MSGS; @@ -956,4 +958,154 @@ Content-Disposition: reaction\n\ } Ok(()) } + + /// Tests that if reaction requests a read receipt, + /// no read receipt is sent when the chat is marked as noticed. + /// + /// Reactions create hidden messages in the chat, + /// and when marking the chat as noticed marks + /// such messages as seen, read receipts should never be sent + /// to avoid the sender of reaction from learning + /// that receiver opened the chat. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_reaction_request_mdn() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let alice_chat_id = alice.create_chat_id(bob).await; + let alice_sent_msg = alice.send_text(alice_chat_id, "Hello!").await; + + let bob_msg = bob.recv_msg(&alice_sent_msg).await; + bob_msg.chat_id.accept(bob).await?; + assert_eq!(bob_msg.state, MessageState::InFresh); + let bob_chat_id = bob_msg.chat_id; + bob_chat_id.accept(bob).await?; + + markseen_msgs(bob, vec![bob_msg.id]).await?; + assert_eq!( + bob.sql + .count( + "SELECT COUNT(*) FROM smtp_mdns WHERE from_id!=?", + (ContactId::SELF,) + ) + .await?, + 1 + ); + bob.sql.execute("DELETE FROM smtp_mdns", ()).await?; + + // Construct reaction with an MDN request. + // Note the `Chat-Disposition-Notification-To` header. + let known_id = bob_msg.rfc724_mid; + let new_id = "e2b6e69e-4124-4e2a-b79f-e4f1be667165@localhost"; + + let plain_text = format!( + "Content-Type: text/plain; charset=\"utf-8\"; protected-headers=\"v1\"; \r + hp=\"cipher\"\r +Content-Disposition: reaction\r +From: \"Alice\" \r +To: \"Bob\" \r +Subject: Message from Alice\r +Date: Sat, 14 Mar 2026 01:02:03 +0000\r +In-Reply-To: <{known_id}>\r +References: <{known_id}>\r +Chat-Version: 1.0\r +Chat-Disposition-Notification-To: alice@example.org\r +Message-ID: <{new_id}>\r +HP-Outer: From: \r +HP-Outer: To: \"hidden-recipients\": ;\r +HP-Outer: Subject: [...]\r +HP-Outer: Date: Sat, 14 Mar 2026 01:02:03 +0000\r +HP-Outer: Message-ID: <{new_id}>\r +HP-Outer: In-Reply-To: <{known_id}>\r +HP-Outer: References: <{known_id}>\r +HP-Outer: Chat-Version: 1.0\r +Content-Transfer-Encoding: base64\r +\r +8J+RgA==\r +" + ); + + let alice_public_key = load_self_public_key(alice).await?; + let bob_public_key = load_self_public_key(bob).await?; + let alice_secret_key = load_self_secret_key(alice).await?; + let public_keys_for_encryption = vec![alice_public_key, bob_public_key]; + let compress = true; + let anonymous_recipients = true; + let encrypted_payload = pk_encrypt( + plain_text.as_bytes().to_vec(), + public_keys_for_encryption, + alice_secret_key, + compress, + anonymous_recipients, + SeipdVersion::V2, + ) + .await?; + + let boundary = "boundary123"; + let rcvd_mail = format!( + "From: \r +To: \"hidden-recipients\": ;\r +Subject: [...]\r +Date: Sat, 14 Mar 2026 01:02:03 +0000\r +Message-ID: <{new_id}>\r +In-Reply-To: <{known_id}>\r +References: <{known_id}>\r +Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\";\r + boundary=\"{boundary}\"\r +MIME-Version: 1.0\r +\r +--{boundary}\r +Content-Type: application/pgp-encrypted; charset=\"utf-8\"\r +Content-Description: PGP/MIME version identification\r +Content-Transfer-Encoding: 7bit\r +\r +Version: 1\r +\r +--{boundary}\r +Content-Type: application/octet-stream; name=\"encrypted.asc\";\r + charset=\"utf-8\"\r +Content-Description: OpenPGP encrypted message\r +Content-Disposition: inline; filename=\"encrypted.asc\";\r +Content-Transfer-Encoding: 7bit\r +\r +{encrypted_payload} +--{boundary}--\r +" + ); + + let received = receive_imf(bob, rcvd_mail.as_bytes(), false) + .await? + .unwrap(); + let bob_hidden_msg = Message::load_from_db(bob, *received.msg_ids.last().unwrap()) + .await + .unwrap(); + assert!(bob_hidden_msg.hidden); + assert_eq!(bob_hidden_msg.chat_id, bob_chat_id); + + // Bob does not see new message and cannot mark it as seen directly, + // but can mark the chat as noticed when opening it. + marknoticed_chat(bob, bob_chat_id).await?; + + assert_eq!( + bob.sql + .count( + "SELECT COUNT(*) FROM smtp_mdns WHERE from_id!=?", + (ContactId::SELF,) + ) + .await?, + 0, + "Bob should not send MDN to Alice" + ); + + // MDN request was ignored, but reaction was not. + let reactions = get_msg_reactions(bob, bob_msg.id).await?; + assert_eq!(reactions.reactions.len(), 1); + assert_eq!( + reactions.emoji_sorted_by_frequency(), + vec![("👀".to_string(), 1)] + ); + + Ok(()) + } }