Compare commits

..

13 Commits

Author SHA1 Message Date
iequidoo
417349197c 123 2026-03-17 07:20:37 -03:00
72374
a25304604a feat: Do not scale images down by a small amount 2026-03-17 07:00:08 -03:00
72374
52e4e42373 feat: Change multiplier to 7/8 when scaling down avatars
The resolution-limits for avatar-images are currently 512x512 or 256x256.
Reducing the resolution further, to 2/3 each step, can reduce the quality
much more than is necessary to fit within the file-size-limits,
which are currently 60 kB or 20 kB.

An image made entirely of noise (which results in unusually
large file-sizes), encoded with jpeg-quality 75,
and 4:2:2-colour-subsampling (the format currently used
for encoding images), can be below 60 kB at 227x227.
For the lower file-size-limit of 20 kB,
such images can be too large at 170x170, but fit at 149x149.
More normal images will have a lower file-size at the same resolution.

Before this change, the target-resolutions for resampling were:
512x512 ->                       341x341 ->                       227x227.

And for the lower file-size-limit:
256x256 ->                       170x170 ->                       113x113.

After this change, the target-resolutions for resampling will be:
512x512 -> 448x448 -> 392x392 -> 343x343 -> 300x300 -> 262x262 -> 229x229.

And for the lower file-size-limit, those will be:
256x256 -> 224x224 -> 196x196 -> 171x171 -> 149x149 -> 130x130 -> 113x113.

This does add 2 steps between the previous target-resolutions,
while still reaching target-resolutions close to the previous ones,
to reduce situations in which the quality will be lower than before.
2026-03-17 07:00:08 -03:00
72374
18ed89abd5 feat: Increase the resolution-limit WORSE_AVATAR_SIZE from 128 to 256
The file-size of many images will already be smaller than 20 kB,
when encoded at 256x256, and it can be a large improvement in quality.
2026-03-17 07:00:08 -03:00
72374
89a281dd27 feat: Make quality of images sent in chats more consistent
Currently, the resolution of a resized image that was sent in a chat,
depends on the aspect-ratio.

Assuming the `balanced`-quality-setting is used,
a square image, that is larger than the limits for resolution and file-size,
will be resized to 1280x1280 (1,638,400 pixels),
an image with an aspect-ratio of 16:9,
will be resized to 1280x720 (921,600 pixels),
and if the aspect-ratio is 32:9, to 1280x360 (460,800 pixels).

This change makes it so, that the number of pixels,
in images with different aspect-ratios, will be similar.
2026-03-17 07:00:08 -03:00
72374
456891a0af refactor: Move the definition of the target_wh-variable
to the only part of the function where it is used.
2026-03-17 07:00:08 -03:00
link2xt
e14151d6cc fix: fsync() the rename() of accounts.toml 2026-03-16 17:09:57 +00:00
link2xt
c6cdccdb97 fix: call sync_all() instead of sync_data() when writing accounts.toml 2026-03-16 17:09:57 +00:00
link2xt
822a99ea9c 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.
2026-03-15 20:54:50 +00:00
WofWca
bf02785a36 feat: add IncomingCallAccepted.from_this_device 2026-03-14 22:21:46 +04:00
iequidoo
01b2aa0f66 fix: Mark call message as seen when accepting/declining a call (#7842) 2026-03-14 13:46:25 -03:00
iequidoo
fb46c34b55 test: Shift time even more in flaky test_sync_broadcast_and_send_message
As of now, alice1 makes 3 more calls of create_smeared_timestamp() than alice2 does, so we need to
shift time by 3s to fix the test.
2026-03-14 16:20:46 +01:00
link2xt
9393753190 chore: bump version to 2.46.0-dev 2026-03-14 02:58:19 +00:00
25 changed files with 349 additions and 46 deletions

10
Cargo.lock generated
View File

@@ -1300,7 +1300,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.45.0"
version = "2.46.0-dev"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1410,7 +1410,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.45.0"
version = "2.46.0-dev"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1431,7 +1431,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "2.45.0"
version = "2.46.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1447,7 +1447,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.45.0"
version = "2.46.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1476,7 +1476,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.45.0"
version = "2.46.0-dev"
dependencies = [
"anyhow",
"deltachat",

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.45.0"
version = "2.46.0-dev"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.88"

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "2.45.0"
version = "2.46.0-dev"
description = "Deltachat FFI"
edition = "2018"
readme = "README.md"

View File

@@ -6755,6 +6755,7 @@ void dc_event_unref(dc_event_t* event);
* UI usually only takes action in case call UI was opened before, otherwise the event should be ignored.
*
* @param data1 (int) msg_id ID of the message referring to the call
* @param data2 (int) 1 if the call was accepted from this device (process).
*/
#define DC_EVENT_INCOMING_CALL_ACCEPTED 2560

View File

@@ -680,7 +680,6 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::ChatModified(_)
| EventType::ChatDeleted { .. }
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::IncomingCallAccepted { .. }
| EventType::OutgoingCallAccepted { .. }
| EventType::CallEnded { .. }
| EventType::EventChannelOverflow { .. }
@@ -703,6 +702,9 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
} => status_update_serial.to_u32() as libc::c_int,
EventType::WebxdcRealtimeData { data, .. } => data.len() as libc::c_int,
EventType::IncomingCall { has_video, .. } => *has_video as libc::c_int,
EventType::IncomingCallAccepted {
from_this_device, ..
} => *from_this_device as libc::c_int,
#[allow(unreachable_patterns)]
#[cfg(test)]

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "2.45.0"
version = "2.46.0-dev"
description = "DeltaChat JSON-RPC API"
edition = "2021"
license = "MPL-2.0"

View File

@@ -441,6 +441,8 @@ pub enum EventType {
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
/// The call was accepted from this device (process).
from_this_device: bool,
},
/// Outgoing call accepted.
@@ -634,9 +636,14 @@ impl From<CoreEventType> for EventType {
place_call_info,
has_video,
},
CoreEventType::IncomingCallAccepted { msg_id, chat_id } => IncomingCallAccepted {
CoreEventType::IncomingCallAccepted {
msg_id,
chat_id,
from_this_device,
} => IncomingCallAccepted {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
from_this_device,
},
CoreEventType::OutgoingCallAccepted {
msg_id,

View File

@@ -54,5 +54,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.45.0"
"version": "2.46.0-dev"
}

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "2.45.0"
version = "2.46.0-dev"
license = "MPL-2.0"
edition = "2021"
repository = "https://github.com/chatmail/core"

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.45.0"
version = "2.46.0-dev"
license = "MPL-2.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "2.45.0"
version = "2.46.0-dev"
description = "DeltaChat JSON-RPC server"
edition = "2021"
readme = "README.md"

View File

@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "2.45.0"
"version": "2.46.0-dev"
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.45.0"
version = "2.46.0-dev"
license = "MPL-2.0"
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
readme = "README.rst"

View File

@@ -686,13 +686,27 @@ impl Config {
file.write_all(toml::to_string_pretty(&self.inner)?.as_bytes())
.await
.context("failed to write a tmp config")?;
file.sync_data()
// We use `sync_all()` and not `sync_data()` here.
// This translates to `fsync()` instead of `fdatasync()`.
// `fdatasync()` may be insufficient for newely created files
// and may not even synchronize the file size on some operating systems,
// resulting in a truncated file.
file.sync_all()
.await
.context("failed to sync a tmp config")?;
drop(file);
fs::rename(&tmp_path, &self.file)
.await
.context("failed to rename config")?;
// Sync the rename().
#[cfg(not(windows))]
{
let parent = self.file.parent().context("No parent directory")?;
let parent_file = fs::File::open(parent).await?;
parent_file.sync_all().await?;
}
Ok(())
}

View File

@@ -389,14 +389,9 @@ impl<'a> BlobObject<'a> {
_ => img,
};
// max_wh is the maximum image width and height, i.e. the resolution-limit.
// target_wh target-resolution for resizing the image.
// max_wh is the maximum image width and height, i.e. the resolution-limit,
// as set by `Config::MediaQuality`.
let exceeds_wh = img.width() > max_wh || img.height() > max_wh;
let mut target_wh = if exceeds_wh {
max_wh
} else {
max(img.width(), img.height())
};
let exceeds_max_bytes = nr_bytes > max_bytes as u64;
let jpeg_quality = 75;
@@ -435,6 +430,32 @@ impl<'a> BlobObject<'a> {
});
if do_scale {
let n_px_longest_side = max(img.width(), img.height());
// target_wh will be used as the target-resolution for resizing the image,
// so that the longest sides of the image match the target-resolution.
let mut target_wh = if !is_avatar {
let n_all_px_sqrt = f64::from(img.width() * img.height()).sqrt();
// Limit resolution to the number of pixels that fit within max_wh * max_wh,
// so that the image-quality does not depend on the aspect-ratio.
let mut resolution_limit =
(f64::from(n_px_longest_side) * (f64::from(max_wh) / n_all_px_sqrt)) as u32;
// Align (at least) two sides of the resampled image to a multiple of 8 pixels,
// to have fewer partially used JPEG-blocks (which represent 8x8 pixels each).
while !resolution_limit.is_multiple_of(8) {
resolution_limit -= 1
}
resolution_limit
} else {
max_wh
};
if target_wh > n_px_longest_side
|| !is_avatar && target_wh > (f64::from(n_px_longest_side) * 0.98) as u32
{
target_wh = n_px_longest_side;
};
loop {
if mem::take(&mut add_white_bg) {
self::add_white_bg(&mut img);
@@ -468,7 +489,7 @@ impl<'a> BlobObject<'a> {
));
}
target_wh = target_wh * 2 / 3;
target_wh = target_wh * 7 / 8;
} else {
info!(
context,

View File

@@ -258,6 +258,26 @@ async fn test_selfavatar_copy_without_recode() {
assert_eq!(avatar_cfg, avatar_blob.to_str().map(|s| s.to_string()));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_image_long_narrow() {
let bytes = include_bytes!("../../test-data/image/screenshot.png");
SendImageCheckMediaquality {
viewtype: Viewtype::Image,
media_quality_config: "0",
bytes,
extension: "jpg",
original_width: 1920,
original_height: 1080,
compressed_width: 1872,
compressed_height: 1053,
..Default::default()
}
.test()
.await
.unwrap();
assert!(false);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_image_1() {
let bytes = include_bytes!("../../test-data/image/avatar1000x1000.jpg");
@@ -384,8 +404,8 @@ async fn test_recode_image_balanced_png() {
extension: "png",
original_width: 1920,
original_height: 1080,
compressed_width: constants::WORSE_IMAGE_SIZE,
compressed_height: constants::WORSE_IMAGE_SIZE * 1080 / 1920,
compressed_width: 848,
compressed_height: 477,
..Default::default()
}
.test()
@@ -475,8 +495,8 @@ async fn test_recode_image_rgba_png_to_jpeg() {
extension: "png",
original_width: 1920,
original_height: 1080,
compressed_width: constants::WORSE_IMAGE_SIZE,
compressed_height: constants::WORSE_IMAGE_SIZE * 1080 / 1920,
compressed_width: 848,
compressed_height: 477,
..Default::default()
}
.test()
@@ -495,8 +515,8 @@ async fn test_recode_image_huge_jpg() {
has_exif: true,
original_width: 1920,
original_height: 1080,
compressed_width: constants::BALANCED_IMAGE_SIZE,
compressed_height: constants::BALANCED_IMAGE_SIZE * 1080 / 1920,
compressed_width: 1704,
compressed_height: 959,
..Default::default()
}
.test()

View File

@@ -11,7 +11,7 @@ use crate::context::{Context, WeakContext};
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::log::warn;
use crate::message::{Message, MsgId, Viewtype};
use crate::message::{Message, MsgId, Viewtype, markseen_msgs};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::net::dns::lookup_host_with_cache;
use crate::param::Param;
@@ -249,6 +249,7 @@ impl Context {
if chat.is_contact_request() {
chat.id.accept(self).await?;
}
markseen_msgs(self, vec![call_id]).await?;
// send an acceptance message around: to the caller as well as to the other devices of the callee
let mut msg = Message {
@@ -265,6 +266,7 @@ impl Context {
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
from_this_device: true,
});
self.emit_msgs_changed(call.msg.chat_id, call_id);
Ok(())
@@ -283,6 +285,7 @@ impl Context {
if !call.is_accepted() {
if call.is_incoming() {
call.mark_as_ended(self).await?;
markseen_msgs(self, vec![call_id]).await?;
let declined_call_str = stock_str::declined_call(self).await;
call.update_text(self, &declined_call_str).await?;
} else {
@@ -430,6 +433,7 @@ impl Context {
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
from_this_device: false,
});
} else {
let accept_call_info = mime_message

View File

@@ -2,6 +2,7 @@ use super::*;
use crate::chat::forward_msgs;
use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::message::MessageState;
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager};
@@ -115,9 +116,28 @@ async fn accept_call() -> Result<CallSetup> {
// Bob accepts the incoming call
bob.accept_incoming_call(bob_call.id, ACCEPT_INFO.to_string())
.await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Incoming video call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.get_matching(|evt| {
matches!(
evt,
EventType::IncomingCallAccepted {
from_this_device: true,
..
}
)
})
.await;
let sent2 = bob.pop_sent_msg().await;
let info = bob
@@ -131,7 +151,15 @@ async fn accept_call() -> Result<CallSetup> {
bob2.recv_msg_trash(&sent2).await;
assert_text(&bob, bob_call.id, "Incoming video call").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.get_matching(|evt| {
matches!(
evt,
EventType::IncomingCallAccepted {
from_this_device: false,
..
}
)
})
.await;
let info = bob2
.load_call_by_id(bob2_call.id)
@@ -200,9 +228,20 @@ async fn test_accept_call_callee_ends() -> Result<()> {
bob2_call,
..
} = accept_call().await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob has accepted the call and also ends it
bob.end_call(bob_call.id).await?;
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -328,8 +367,18 @@ async fn test_callee_rejects_call() -> Result<()> {
} = setup_call().await?;
// Bob has accepted Alice before, but does not want to talk with Alice
bob_call.chat_id.accept(&bob).await?;
bob.end_call(bob_call.id).await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Declined call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -370,6 +419,35 @@ async fn test_callee_rejects_call() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_callee_sees_contact_request_call() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
alice
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true)
.await?;
let sent1 = alice.pop_sent_msg().await;
let bob_call = bob.recv_msg(&sent1).await;
// Bob can't end_call() because the contact request isn't accepted, but he can mark the call as
// seen.
markseen_msgs(bob, vec![bob_call.id]).await?;
assert_eq!(bob_call.id.get_state(bob).await?, MessageState::InSeen);
// Bob sends an MDN only to self so that an unaccepted contact can't know anything.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, ContactId::SELF)
)
.await?,
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_caller_cancels_call() -> Result<()> {
// Alice calls Bob

View File

@@ -4733,9 +4733,10 @@ async fn test_sync_broadcast_and_send_message() -> Result<()> {
vec![a2b_contact_id]
);
// alice2's smeared clock may be behind alice's one, so "hi" from alice2 may appear before "You
// joined the channel." for bob.
SystemTime::shift(Duration::from_secs(1));
// alice2's smeared clock may be behind alice1's one, so we need to work around "hi" appearing
// before "You joined the channel." for bob. alice1 makes 3 more calls of
// create_smeared_timestamp() than alice2 does as of 2026-03-10.
SystemTime::shift(Duration::from_secs(3));
tcm.section("Alice's second device sends a message to the channel");
let sent_msg = alice2.send_text(a2_broadcast_id, "hi").await;
let msg = bob.recv_msg(&sent_msg).await;

View File

@@ -193,17 +193,17 @@ pub const DC_LP_AUTH_NORMAL: i32 = 0x4;
pub const DC_LP_AUTH_FLAGS: i32 = DC_LP_AUTH_OAUTH2 | DC_LP_AUTH_NORMAL;
// max. weight of images to send w/o recoding
pub const BALANCED_IMAGE_BYTES: usize = 500_000;
pub const BALANCED_IMAGE_BYTES: usize = 1;
pub const WORSE_IMAGE_BYTES: usize = 130_000;
// max. width/height and bytes of an avatar
pub(crate) const BALANCED_AVATAR_SIZE: u32 = 512;
pub(crate) const BALANCED_AVATAR_BYTES: usize = 60_000;
pub(crate) const WORSE_AVATAR_SIZE: u32 = 128;
pub(crate) const WORSE_AVATAR_SIZE: u32 = 256;
pub(crate) const WORSE_AVATAR_BYTES: usize = 20_000; // this also fits to Outlook servers don't allowing headers larger than 32k.
// max. width/height of images scaled down because of being too huge
pub const BALANCED_IMAGE_SIZE: u32 = 1280;
pub const BALANCED_IMAGE_SIZE: u32 = 1408;
pub const WORSE_IMAGE_SIZE: u32 = 640;
/// Limit for received images size. Bigger images become `Viewtype::File` to avoid excessive memory

View File

@@ -397,6 +397,8 @@ pub enum EventType {
msg_id: MsgId,
/// ID of the chat which the message belongs to.
chat_id: ChatId,
/// The call was accepted from this device (process).
from_this_device: bool,
},
/// Outgoing call accepted.

View File

@@ -1934,6 +1934,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> 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?

View File

@@ -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\" <alice@example.org>\r
To: \"Bob\" <bob@example.net>\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: <alice@example.org>\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: <alice@example.org>\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(())
}
}

View File

@@ -34,7 +34,7 @@ async fn test_additional_text_on_different_viewtypes() -> Result<()> {
let (pre_message, _, _) = send_large_image_message(alice, a_group_id).await?;
let msg = bob.recv_msg(&pre_message).await;
assert_eq!(msg.text, "test".to_owned());
assert_eq!(msg.get_text(), "test [Image 146.12 KiB]".to_owned());
assert_eq!(msg.get_text(), "test [Image 228.45 KiB]".to_owned());
Ok(())
}

View File

@@ -393,9 +393,9 @@ async fn test_receive_pre_message_image() -> Result<()> {
// test that metadata is correctly returned by methods
assert_eq!(msg.get_post_message_viewtype(), Some(Viewtype::Image));
// recoded image dimensions
assert_eq!(msg.get_filebytes(bob).await?, Some(149632));
assert_eq!(msg.get_height(), 1280);
assert_eq!(msg.get_width(), 720);
assert_eq!(msg.get_filebytes(bob).await?, Some(233935));
assert_eq!(msg.get_height(), 1704);
assert_eq!(msg.get_width(), 959);
Ok(())
}