Compare commits

..

7 Commits

Author SHA1 Message Date
B. Petersen
b42f558801 performance: skip a costly call to chat.why_cant_send_ex() 2026-04-19 13:55:53 +02:00
B. Petersen
1eef44e790 feat: allow sending webxdc update from broadcast subscribers 2026-04-19 13:36:24 +02:00
link2xt
8cd06bb785 fix: use write transaction in SpkiHashStore.cleanup()
query_map_vec() uses read-only connection,
so it cannot be used to delete rows.
2026-04-19 09:15:13 +00:00
link2xt
bb816ff398 fix: do not sort prefetched messages by INTERNALDATE
Messages are iterated over in fetch_new_msg_batch()
and largest_uid_fetched variable is updated there
assuming that messages come in the order of increasing UID.
If UIDs are not increasing, it is possible
that largest_uid_fetched will be updated
even though smaller UID is not fetched yet
and the message will be lost.

INTERNALDATE sorting was introduced to
deal with email providers such as Gmail
that keep INTERNALDATE but not the UID
order when moving the messages.
Since we don't move the messages anymore
after commit 04c0e7da16,
there is no need for ordering by INTERNALDATE.
2026-04-19 09:00:40 +00:00
link2xt
9fcb26c849 chore(cargo): upgrade rand 0.8.5 to rand 0.8.6
This upgrade resolves RUSTSEC-2026-0097
2026-04-19 09:00:00 +00:00
B. Petersen
d9474a678e fix python test 2026-04-18 23:45:35 +02:00
B. Petersen
f1e1a240ac feat: webxdc sending contexts 2026-04-18 23:45:35 +02:00
11 changed files with 168 additions and 159 deletions

22
Cargo.lock generated
View File

@@ -1360,7 +1360,7 @@ dependencies = [
"proptest",
"qrcodegen",
"quick-xml",
"rand 0.8.5",
"rand 0.8.6",
"rand 0.9.4",
"ratelimit",
"regex",
@@ -2981,7 +2981,7 @@ dependencies = [
"pin-project",
"pkarr",
"portmapper",
"rand 0.8.5",
"rand 0.8.6",
"rcgen",
"reqwest",
"ring",
@@ -3056,7 +3056,7 @@ dependencies = [
"iroh-metrics",
"n0-future",
"postcard",
"rand 0.8.5",
"rand 0.8.6",
"rand_core 0.6.4",
"serde",
"serde-error",
@@ -3119,7 +3119,7 @@ checksum = "929d5d8fa77d5c304d3ee7cae9aede31f13908bd049f9de8c7c0094ad6f7c535"
dependencies = [
"bytes",
"getrandom 0.2.16",
"rand 0.8.5",
"rand 0.8.6",
"ring",
"rustc-hash",
"rustls",
@@ -3172,7 +3172,7 @@ dependencies = [
"pin-project",
"pkarr",
"postcard",
"rand 0.8.5",
"rand 0.8.6",
"reqwest",
"rustls",
"rustls-webpki 0.102.8",
@@ -3776,7 +3776,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.5",
"rand 0.8.6",
"serde",
"smallvec",
"zeroize",
@@ -4206,7 +4206,7 @@ dependencies = [
"p256",
"p384",
"p521",
"rand 0.8.5",
"rand 0.8.6",
"regex",
"replace_with",
"ripemd",
@@ -4453,7 +4453,7 @@ dependencies = [
"nested_enum_utils",
"netwatch",
"num_enum",
"rand 0.8.5",
"rand 0.8.6",
"serde",
"smallvec",
"snafu",
@@ -4776,9 +4776,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
@@ -5870,7 +5870,7 @@ dependencies = [
"hex",
"parking_lot",
"pnet_packet",
"rand 0.8.5",
"rand 0.8.6",
"socket2 0.5.9",
"thiserror 1.0.69",
"tokio",

View File

@@ -4234,10 +4234,8 @@ char* dc_msg_get_webxdc_blob (const dc_msg_t* msg, const char*
* true if the Webxdc should get internet access;
* this is the case i.e. for experimental maps integration.
* - self_addr: address to be used for `window.webxdc.selfAddr` in JS land.
* - app_sender_addr: address of the peer who initially shared the webxdc in the chat.
* Can be compared to self_addr to determine whether the app runs for the sender or a receiver.
* - can_only_send_updates_to_app_sender: true if updates sent by the local user
* will only be seen by the app sender.
* - is_app_sender: Define if the local user is the one who initially shared the webxdc application in the chat.
* - is_broadcast: Define if the app runs in a broadcasting context.
* - send_update_interval: Milliseconds to wait before calling `sendUpdate()` again since the last call.
* Should be exposed to `webxdc.sendUpdateInterval` in JS land.
* - send_update_max_size: Maximum number of bytes accepted for a serialized update object.

View File

@@ -37,11 +37,10 @@ pub struct WebxdcMessageInfo {
internet_access: bool,
/// Address to be used for `window.webxdc.selfAddr` in JS land.
self_addr: String,
/// Address of the peer who initially shared the webxdc in the chat.
app_sender_addr: String,
/// True if updates sent by the local user
/// will only be seen by the app sender.
can_only_send_updates_to_app_sender: bool,
/// Define if the local user is the one who initially shared the webxdc application in the chat.
is_app_sender: bool,
/// Define if the app runs in a broadcasting context.
is_broadcast: bool,
/// Milliseconds to wait before calling `sendUpdate()` again since the last call.
/// Should be exposed to `window.sendUpdateInterval` in JS land.
send_update_interval: usize,
@@ -65,8 +64,8 @@ impl WebxdcMessageInfo {
request_integration: _,
internet_access,
self_addr,
app_sender_addr,
can_only_send_updates_to_app_sender,
is_app_sender,
is_broadcast,
send_update_interval,
send_update_max_size,
} = message.get_webxdc_info(context).await?;
@@ -79,8 +78,8 @@ impl WebxdcMessageInfo {
source_code_url: maybe_empty_string_to_option(source_code_url),
internet_access,
self_addr,
app_sender_addr,
can_only_send_updates_to_app_sender,
is_app_sender,
is_broadcast,
send_update_interval,
send_update_max_size,
})

View File

@@ -1,59 +1,8 @@
import logging
import re
import time
from imap_tools import AND, U
from deltachat_rpc_client import Contact, EventType, Message
def test_reactions_for_a_reordering_move(acfactory, direct_imap):
"""When a batch of messages is moved from Inbox to another folder with a single MOVE command,
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
messages they refer to and thus dropped.
"""
(ac1,) = acfactory.get_online_accounts(1)
addr, password = acfactory.get_credentials()
ac2 = acfactory.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password})
assert ac2.is_configured()
ac2.bring_online()
chat1 = acfactory.get_accepted_chat(ac1, ac2)
ac2.stop_io()
logging.info("sending message + reaction from ac1 to ac2")
msg1 = chat1.send_text("hi")
msg1.wait_until_delivered()
# It's is sad, but messages must differ in their INTERNALDATEs to be processed in the correct
# order by DC, and most (if not all) mail servers provide only seconds precision.
time.sleep(1.1)
react_str = "\N{THUMBS UP SIGN}"
msg1.send_reaction(react_str).wait_until_delivered()
logging.info("moving messages to ac2's movebox folder in the reverse order")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("Movebox")
ac2_direct_imap.connect()
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()], reverse=True):
ac2_direct_imap.conn.move(uid, "Movebox")
logging.info("moving messages back")
ac2_direct_imap.select_folder("Movebox")
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()]):
ac2_direct_imap.conn.move(uid, "INBOX")
logging.info("receiving messages by ac2")
ac2.start_io()
msg2 = Message(ac2, ac2.wait_for_reactions_changed().msg_id)
assert msg2.get_snapshot().text == msg1.get_snapshot().text
reactions = msg2.get_reactions()
contacts = [Contact(ac2, int(i)) for i in reactions.reactions_by_contact]
assert len(contacts) == 1
assert contacts[0].get_snapshot().address == ac1.get_config("addr")
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
from deltachat_rpc_client import EventType
def test_moved_markseen(acfactory, direct_imap, log):

View File

@@ -18,6 +18,8 @@ def test_webxdc(acfactory) -> None:
"sourceCodeUrl": None,
"summary": None,
"selfAddr": webxdc_info["selfAddr"],
"isAppSender": False,
"isBroadcast": False,
"sendUpdateInterval": 1000,
"sendUpdateMaxSize": 18874368,
}

View File

@@ -27,15 +27,7 @@ ignore = [
# <https://rustsec.org/advisories/RUSTSEC-2026-0099>
"RUSTSEC-2026-0049",
"RUSTSEC-2026-0098",
"RUSTSEC-2026-0099",
# rand 0.8.x
# <https://rustsec.org/advisories/RUSTSEC-2026-0097>
# We already use rand 0.9,
# version 0.8 that cannot be upgraded
# is a dependency of iroh 0.35.0 and rPGP.
# rPGP upgrade is waiting for <https://github.com/rpgp/rpgp/pull/573>
"RUSTSEC-2026-0097"
"RUSTSEC-2026-0099"
]
[bans]

View File

@@ -2700,8 +2700,10 @@ async fn prepare_send_msg(
.unwrap_or_default(),
_ => false,
};
if let Some(reason) = chat.why_cant_send_ex(context, &skip_fn).await? {
bail!("Cannot send to {chat_id}: {reason}");
if msg.param.get_cmd() == SystemMessage::WebxdcStatusUpdate {
// Already checked in `send_webxdc_status_update_struct()`.
} else if let Some(reason) = chat.why_cant_send_ex(context, &skip_fn).await? {
bail!("Cannot prepare sending to {chat_id}: {reason}");
}
// Check a quote reply is not leaking data from other chats.

View File

@@ -16,7 +16,7 @@ use crate::net::session::SessionStream;
/// - Autocrypt-Setup-Message to check if a message is an autocrypt setup message,
/// not necessarily sent by Delta Chat.
/// - Chat-Is-Post-Message to skip it in background fetch or when it is > `DownloadLimit`.
const PREFETCH_FLAGS: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (\
const PREFETCH_FLAGS: &str = "(UID RFC822.SIZE BODY.PEEK[HEADER.FIELDS (\
MESSAGE-ID \
DATE \
X-MICROSOFT-ORIGINAL-MESSAGE-ID \
@@ -124,7 +124,7 @@ impl Session {
}
/// Prefetch `n_uids` messages starting from `uid_next`. Returns a list of fetch results in the
/// order of ascending delivery time to the server (INTERNALDATE).
/// order of ascending UIDs.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn prefetch(
&mut self,
@@ -142,10 +142,10 @@ impl Session {
let mut msgs = BTreeMap::new();
while let Some(msg) = list.try_next().await? {
if let Some(msg_uid) = msg.uid {
msgs.insert((msg.internal_date(), msg_uid), msg);
msgs.insert(msg_uid, msg);
}
}
Ok(msgs.into_iter().map(|((_, uid), msg)| (uid, msg)).collect())
Ok(Vec::from_iter(msgs))
}
}

View File

@@ -6,7 +6,6 @@
use std::collections::BTreeMap;
use anyhow::Context as _;
use anyhow::Result;
use base64::Engine as _;
use parking_lot::RwLock;
@@ -97,25 +96,28 @@ impl SpkiHashStore {
pub async fn cleanup(&self, sql: &Sql) -> Result<()> {
let now = time();
let removed_hosts = sql
.query_map_vec(
"DELETE FROM tls_spki WHERE ? > timestamp + ? RETURNING host",
(now, 30 * 24 * 60 * 60),
|row| {
.transaction(|transaction| {
let mut stmt = transaction
.prepare("DELETE FROM tls_spki WHERE ? > timestamp + ? RETURNING host")?;
let mut res = Vec::new();
for row in stmt.query_map((now, 30 * 24 * 60 * 60), |row| {
let host: String = row.get(0)?;
Ok(host)
},
)
.await
.context("DELETE FROM tls_spki")?;
})? {
res.push(row?);
}
// Fix timestamps that happen to be in the future
// if we had clock set incorrectly when the timestamp was stored.
// Otherwise entry may take more than 30 days to expire.
sql.execute(
"UPDATE tls_spki SET timestamp = ?1 WHERE timestamp > ?1",
(now,),
)
.await?;
// Fix timestamps that happen to be in the future
// if we had clock set incorrectly when the timestamp was stored.
// Otherwise entry may take more than 30 days to expire.
transaction.execute(
"UPDATE tls_spki SET timestamp = ?1 WHERE timestamp > ?1",
(now,),
)?;
Ok(res)
})
.await?;
let mut lock = self.hash_store.write();
for host in removed_hosts {

View File

@@ -34,9 +34,9 @@ use serde_json::Value;
use sha2::{Digest, Sha256};
use tokio::{fs::File, io::BufReader};
use crate::chat::{self, Chat};
use crate::chat::{self, CantSendReason, Chat};
use crate::constants::Chattype;
use crate::contact::{Contact, ContactId};
use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
use crate::key::self_fingerprint;
@@ -111,14 +111,11 @@ pub struct WebxdcInfo {
/// Address to be used for `window.webxdc.selfAddr` in JS land.
pub self_addr: String,
/// Address of the peer who initially shared the webxdc in the chat.
/// Can be compared to `self_addr` to determine
/// whether the app runs for the sender or a receiver.
pub app_sender_addr: String,
/// Define if the local user is the one who initially shared the webxdc application in the chat.
pub is_app_sender: bool,
/// `true` if updates sent by the local user
/// will only be seen by the app sender.
pub can_only_send_updates_to_app_sender: bool,
/// Define if the app runs in a broadcasting context.
pub is_broadcast: bool,
/// Milliseconds to wait before calling `sendUpdate()` again since the last call.
/// Should be exposed to `window.sendUpdateInterval` in JS land.
@@ -545,10 +542,16 @@ impl Context {
let chat = Chat::load_from_db(self, chat_id)
.await
.with_context(|| format!("Failed to load chat {chat_id} from the database"))?;
if let Some(reason) = chat.why_cant_send(self).await.with_context(|| {
format!("Failed to check if webxdc update can be sent to chat {chat_id}")
})? {
bail!("Cannot send to {chat_id}: {reason}.");
let skip_fn = |reason: &CantSendReason| matches!(reason, CantSendReason::InBroadcast);
if let Some(reason) = chat
.why_cant_send_ex(self, &skip_fn)
.await
.with_context(|| {
format!("Failed to check if webxdc update can be sent to chat {chat_id}")
})?
{
bail!("Cannot send update to {chat_id}: {reason}.");
}
let send_now = !matches!(
@@ -932,12 +935,11 @@ impl Message {
let internet_access = is_integrated;
let self_addr = self.get_webxdc_self_addr(context).await?;
let app_sender_addr = self.get_webxdc_app_sender_addr(context).await?;
let is_app_sender = self.from_id == ContactId::SELF;
let chat = Chat::load_from_db(context, self.chat_id)
.await
.with_context(|| "Failed to load chat from the database")?;
let can_only_send_updates_to_app_sender = chat.typ == Chattype::InBroadcast;
let is_broadcast = chat.typ == Chattype::InBroadcast || chat.typ == Chattype::OutBroadcast;
Ok(WebxdcInfo {
name: if let Some(name) = manifest.name {
@@ -976,8 +978,8 @@ impl Message {
request_integration,
internet_access,
self_addr,
app_sender_addr,
can_only_send_updates_to_app_sender,
is_app_sender,
is_broadcast,
send_update_interval: context.ratelimit.read().await.update_interval(),
send_update_max_size: RECOMMENDED_FILE_SIZE as usize,
})
@@ -990,29 +992,6 @@ impl Message {
Ok(format!("{hash:x}"))
}
/// Computes the webxdc address of the message sender.
async fn get_webxdc_app_sender_addr(&self, context: &Context) -> Result<String> {
// UNDEFINED may be preset during drafts or tests, will be SELF on sending
let fingerprint = if self.from_id == ContactId::SELF || self.from_id == ContactId::UNDEFINED
{
self_fingerprint(context).await?.to_string()
} else {
let contact = Contact::get_by_id(context, self.from_id).await?;
contact
.fingerprint()
.with_context(|| {
format!(
"No fingerprint for contact {} (webxdc sender)",
self.from_id
)
})?
.hex()
};
let data = format!("{}-{}", fingerprint, self.rfc724_mid);
let hash = Sha256::digest(data.as_bytes());
Ok(format!("{hash:x}"))
}
/// Get link attached to an info message.
///
/// The info message needs to be of type SystemMessage::WebxdcInfoMessage.

View File

@@ -2208,15 +2208,14 @@ async fn test_webxdc_info_app_sender() -> Result<()> {
let alice_instance = send_webxdc_instance(alice, alice_chat_id).await?;
let sent1 = alice.pop_sent_msg().await;
let alice_info = alice_instance.get_webxdc_info(alice).await?;
assert_eq!(alice_info.self_addr, alice_info.app_sender_addr);
assert!(!alice_info.can_only_send_updates_to_app_sender);
assert!(alice_info.is_app_sender);
assert!(!alice_info.is_broadcast);
// Bob receives group webxdc
let bob_instance = bob.recv_msg(&sent1).await;
let bob_info = bob_instance.get_webxdc_info(bob).await?;
assert_ne!(bob_info.self_addr, bob_info.app_sender_addr);
assert_eq!(bob_info.app_sender_addr, alice_info.self_addr);
assert!(!bob_info.can_only_send_updates_to_app_sender);
assert!(!bob_info.is_app_sender);
assert!(!bob_info.is_broadcast);
// Alice sends webxdc to broadcast channel
let alice_chat_id = create_broadcast(alice, "Broadcast".to_string()).await?;
@@ -2225,15 +2224,102 @@ async fn test_webxdc_info_app_sender() -> Result<()> {
let alice_instance = send_webxdc_instance(alice, alice_chat_id).await?;
let sent2 = alice.pop_sent_msg().await;
let alice_info = alice_instance.get_webxdc_info(alice).await?;
assert_eq!(alice_info.self_addr, alice_info.app_sender_addr);
assert!(!alice_info.can_only_send_updates_to_app_sender);
assert!(alice_info.is_app_sender);
assert!(alice_info.is_broadcast);
// Bob receives broadcast webxdc
let bob_instance = bob.recv_msg(&sent2).await;
let bob_info = bob_instance.get_webxdc_info(bob).await?;
assert_ne!(bob_info.self_addr, bob_info.app_sender_addr);
assert_eq!(bob_info.app_sender_addr, alice_info.self_addr);
assert!(bob_info.can_only_send_updates_to_app_sender);
assert!(!bob_info.is_app_sender);
assert!(bob_info.is_broadcast);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_webxdc_broadcast_channel_updates() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
// Alice creates a broadcast channel, Bob and Charlie join via securejoin QR
let alice_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
tcm.exec_securejoin_qr(bob, alice, &qr).await;
tcm.exec_securejoin_qr(charlie, alice, &qr).await;
// Alice sends a webxdc app to the channel, Bob and Charlie both receive it
let alice_instance = send_webxdc_instance(alice, alice_chat_id).await?;
let sent1 = alice.pop_sent_msg().await;
let bob_instance = bob.recv_msg(&sent1).await;
assert_eq!(bob_instance.viewtype, Viewtype::Webxdc);
let charlie_instance = charlie.recv_msg(&sent1).await;
assert_eq!(charlie_instance.viewtype, Viewtype::Webxdc);
// Verify broadcast context flags
let alice_info = alice_instance.get_webxdc_info(alice).await?;
let bob_info = bob_instance.get_webxdc_info(bob).await?;
let charlie_info = charlie_instance.get_webxdc_info(charlie).await?;
assert!(alice_info.is_app_sender);
assert!(alice_info.is_broadcast);
assert!(!bob_info.is_app_sender);
assert!(bob_info.is_broadcast);
assert!(!charlie_info.is_app_sender);
assert!(charlie_info.is_broadcast);
// Alice sends a webxdc update, Bob and Charlie both receive it
alice
.send_webxdc_status_update(alice_instance.id, r#"{"payload": "hello from alice"}"#)
.await?;
alice.flush_status_updates().await?;
let sent2 = alice.pop_sent_msg().await;
bob.recv_msg_trash(&sent2).await;
assert_eq!(
bob.get_webxdc_status_updates(bob_instance.id, StatusUpdateSerial(0))
.await?,
r#"[{"payload":"hello from alice","serial":1,"max_serial":1}]"#
);
charlie.recv_msg_trash(&sent2).await;
assert_eq!(
charlie
.get_webxdc_status_updates(charlie_instance.id, StatusUpdateSerial(0))
.await?,
r#"[{"payload":"hello from alice","serial":1,"max_serial":1}]"#
);
// Bob sends a webxdc update, Bob sees ones own update, Alice receives it, but Charlie does not
bob.send_webxdc_status_update(bob_instance.id, r#"{"payload": "hello from bob"}"#)
.await?;
bob.flush_status_updates().await?;
let sent3 = bob.pop_sent_msg().await;
assert_eq!(
bob.get_webxdc_status_updates(bob_instance.id, StatusUpdateSerial(0))
.await?,
r#"[{"payload":"hello from alice","serial":1,"max_serial":2},
{"payload":"hello from bob","serial":2,"max_serial":2}]"#
);
alice.recv_msg_trash(&sent3).await;
assert_eq!(
alice
.get_webxdc_status_updates(alice_instance.id, StatusUpdateSerial(0))
.await?,
r#"[{"payload":"hello from alice","serial":1,"max_serial":2},
{"payload":"hello from bob","serial":2,"max_serial":2}]"#
);
charlie.recv_msg_trash(&sent3).await;
assert_eq!(
charlie
.get_webxdc_status_updates(charlie_instance.id, StatusUpdateSerial(0))
.await?,
r#"[{"payload":"hello from alice","serial":1,"max_serial":1}]"#
);
Ok(())
}