Compare commits

...
Author SHA1 Message Date
link2xt 2b24f24f5d ci: update Rust to 1.99.0 2026-10-01 14:15:15 +00:00
link2xt 736979b451 feat: do not request MDNs for unencrypted messages 2026-09-29 16:02:09 +00:00
holger krekel 5a882b0aca fix: delete handled securejoin messages on all relays
A join request is sent to every relay of an inviter, but the answering
inviter device only deleted the copy it downloaded. Another device of the inviter
fetching a different relay later answered the request again, re-adding
members who had left or been removed in the meantime.
2026-09-29 14:51:29 +02:00
link2xt 1e36fb74be chore: remove some unwrap() calls 2026-09-28 15:40:37 +00:00
biørn ebf1c87483 feat: re-add "second device added" message if deleted soon after transfer (#8758)
this PR makes it easier to spot unwanted "add second device" actions, if
the unlocked device is left unattended and/or a system lock is not in
place (so cannot be checked directly before doing the "add second
device" action).

already today, the sending device gets the message "ℹ️ Profile
transferred to your second device", which, however, can be deleted.

with this PR, if the message is deleted soon after the transfer, it
first looks as if this was successful. however, the message is re-added
later, at the first housekeeping at least an hour after the transfer.
deleting the message after that is final, so users can still get rid of
it.

It is clear that this does not catch all eventualities, it is
best-effort. e.g. it is known that subsequent "add second device"
attempts remove checks of previous ones.
First line of defense is device locking and asking for secret explicitly
before adding a second device.

previous discussions about that at
https://github.com/chatmail/core/issues/4303
2026-09-28 14:49:58 +02:00
d2weber 172ebc08f9 fix: regenerate deltachat.pc if env changed (#8752)
Paths in deltachat.pc are passed via env variables to build.rs. With
this fix, the pc will be regenerated if these env variables change.

This came up when building via CMake: the paths depend on
CMAKE_INSTALL_PREFIX, if the CMAKE_INSTALL_PREFIX changes during a
reconfigure, deltachat.pc should have the corresponding paths adjusted.
2026-09-28 11:01:22 +02:00
missytake eeb9337a56 chore: add chatmail.cc (#8756) 2026-09-26 23:00:35 +02:00
holger krekel a1eb593d81 chore: add chatmail.au 2026-09-26 19:34:15 +02:00
link2xt 7e070efc28 chore: enable clippy::unnecessary_wraps 2026-09-25 11:10:23 +00:00
link2xt 5d3145d165 chore: fix clippy::string_lit_as_bytes suggestions 2026-09-25 11:10:23 +00:00
link2xt 1605b971ba chore: fix clippy::redundant_clone suggestions 2026-09-25 11:10:23 +00:00
Hocuriandl 22578ea4b6 fix: Correctly percent-encode addresses in securejoin invite codes (#8747)
This PR fixes how addresses in securejoin invite codes (i.e. QR codes
and invite links) are percent-encoded.

Before this PR,
- `@` in the addresses was percent-encoded. This makes the invite code
harder to read, and is not necessary; at least, every software we tested
correctly handled links that contain `@` (Signal, WhatsApp, Telegram,
Thunderbird, Delta Chat on Android, iOS, and Desktop)
- _But_, the first address in the `r=` parameter of a securejoin link
was not percent-encoded at all. This was a sneaky bug caused by using
the `reduce` function; I always find it hard to follow code that uses
`reduce` (and similar functions like `fold`), and apparently others have
the same problem since neither @j-g00da nor @link2xt noticed the problem
when implementing & reviewing the PR that introduced the bug.

With this PR:
- `@` is allowed in addresses in securejoin invite codes
- all addresses are percent-encoded
- `reduced` is not used anymore

---------

Co-authored-by: l <link2xt@testrun.org>
2026-09-24 20:58:11 +00:00
link2xt 076f83f320 chore: reduce noise created by key-contact migration
Stop logging the time migration takes
and don't log anything when migration runs on a fresh database
and only creates empty tables.

Without these changes every time profile is created,
the following info lines are logged:

    src/sql/migrations.rs:35: Starting key-contact transition.
    src/sql/migrations.rs:82: Not yet configured, no need to migrate key-contacts
    src/sql/migrations.rs:1942: key-contacts migration took 1.143721ms in total.
2026-09-24 16:25:22 +00:00
link2xt 8ab98019a7 feat(deltachat-repl): remove "reset" command
This is not a correct way to reset the database, it does not even clear the transports table
so account stays configured. If someone needs a fresh database, then REPL should be restarted
with a new path.
2026-09-23 12:52:17 +00:00
link2xt 76d664f9cb refactor!: stop saving legacy config values when configuring a transport
Users downgrading to UIs that load legacy config values is unlikely,
and cannot work correctly with multiple transports.

BREAKING CHANGE: core does not set deprecated config values `addr`, `mail_pw` etc. Use `list_transports()` API instead.
2026-09-23 12:13:40 +00:00
Hocuri f0e8e80894 test: Add basic tests for rendering unencrypted messages (#8736)
This adds two tests for rendering unencrypted messages.

As a follow-up to,
https://github.com/chatmail/core/pull/8543#issuecomment-5207509885, I
would like to make sure that sensitive headers don't go into unencrypted
messages. While looking into this, I noticed that we don't have any
tests at all yet that test how exactly an unencrypted message is
rendered, only for specific parts.

Right now, we're sending `Chat-Disposition-Notification-To:
alice@example.org` in unencrypted messages, I think it should be
removed, I'll make a follow-up PR with a whitelist of allowed
unencrypted headers.
2026-09-23 13:09:52 +02:00
link2xt 3756537a13 api: add PinnedMessagesChanged event 2026-09-23 11:05:50 +00:00
link2xt 2ad4301f44 test: move pinned messages tests into pinned_messages_tests module
This is similar to how it is for crate::transport::transport_tests
No changes made to the code of the tests in this commit.
2026-09-22 18:54:04 +00:00
link2xt 6f4587a030 chore(release): bump version to 2.63.0-dev 2026-09-22 17:51:52 +00:00
64 changed files with 949 additions and 632 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.98.1
RUST_VERSION: 1.99.0
# Minimum Supported Rust Version
MSRV: 1.89.0
Generated
+6 -6
View File
@@ -1327,7 +1327,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.62.0"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1435,7 +1435,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.62.0"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1456,14 +1456,14 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc-bindings"
version = "2.62.0"
version = "2.63.0-dev"
dependencies = [
"deltachat-jsonrpc",
]
[[package]]
name = "deltachat-repl"
version = "2.62.0"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1479,7 +1479,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.62.0"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1508,7 +1508,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.62.0"
version = "2.63.0-dev"
dependencies = [
"anyhow",
"deltachat",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.62.0"
version = "2.63.0-dev"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.89"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "2.62.0"
version = "2.63.0-dev"
description = "Deltachat FFI"
edition = "2024"
license = "MPL-2.0"
+3 -3
View File
@@ -21,9 +21,9 @@ fn main() {
url = env::var("CARGO_PKG_HOMEPAGE").unwrap_or_else(|_| "".to_string()),
version = env::var("CARGO_PKG_VERSION").unwrap(),
libs_priv = libs_priv,
prefix = env::var("PREFIX").unwrap_or_else(|_| "/usr/local".to_string()),
libdir = env::var("LIBDIR").unwrap_or_else(|_| "/usr/local/lib".to_string()),
includedir = env::var("INCLUDEDIR").unwrap_or_else(|_| "/usr/local/include".to_string()),
prefix = option_env!("PREFIX").unwrap_or_else(|| "/usr/local"),
libdir = option_env!("LIBDIR").unwrap_or_else(|| "/usr/local/lib"),
includedir = option_env!("INCLUDEDIR").unwrap_or_else(|| "/usr/local/include"),
);
fs::create_dir_all(target_path.join("pkgconfig")).unwrap();
+11
View File
@@ -6178,6 +6178,17 @@ void dc_event_unref(dc_event_t* event);
#define DC_EVENT_CHAT_DELETED 2023
/**
* The list of pinned messages for the chat has changed.
*
* Some message got pinned, or pinned message is unpinned or deleted.
*
* @param data1 (int) chat_id
* @param data2 (int) 0
*/
#define DC_EVENT_PINNED_MESSAGES_CHANGED 2024
/**
* Contact(s) created, renamed, blocked or deleted.
*
+7 -3
View File
@@ -514,6 +514,7 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
EventType::ChatModified(_) => 2020,
EventType::ChatEphemeralTimerModified { .. } => 2021,
EventType::ChatDeleted { .. } => 2023,
EventType::PinnedMessagesChanged { .. } => 2024,
EventType::ContactsChanged(_) => 2030,
EventType::LocationChanged(_) => 2035,
EventType::ConfigureProgress { .. } => 2041,
@@ -587,7 +588,8 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
| EventType::MsgReadCountChanged { chat_id, .. }
| EventType::ChatModified(chat_id)
| EventType::ChatEphemeralTimerModified { chat_id, .. }
| EventType::ChatDeleted { chat_id } => chat_id.to_u32() as libc::c_int,
| EventType::ChatDeleted { chat_id }
| EventType::PinnedMessagesChanged { chat_id } => chat_id.to_u32() as libc::c_int,
EventType::ContactsChanged(id) | EventType::LocationChanged(id) => {
let id = id.unwrap_or_default();
id.to_u32() as libc::c_int
@@ -661,7 +663,8 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::OutgoingCallAccepted { .. }
| EventType::CallEnded { .. }
| EventType::EventChannelOverflow { .. }
| EventType::TransportsModified => 0,
| EventType::TransportsModified
| EventType::PinnedMessagesChanged { .. } => 0,
EventType::MsgsChanged { msg_id, .. }
| EventType::ReactionsChanged { msg_id, .. }
| EventType::IncomingReaction { msg_id, .. }
@@ -762,7 +765,8 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
| EventType::AccountsItemChanged
| EventType::IncomingCallAccepted { .. }
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::TransportsModified => ptr::null_mut(),
| EventType::TransportsModified
| EventType::PinnedMessagesChanged { .. } => ptr::null_mut(),
EventType::IncomingCall {
place_call_info, ..
} => place_call_info.strdup(),
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc-bindings"
version = "2.62.0"
version = "2.63.0-dev"
description = "Autogenerate DeltaChat JSON-RPC API bindings at build time"
edition = "2024"
license = "MPL-2.0"
@@ -54,5 +54,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.62.0"
"version": "2.63.0-dev"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "2.62.0"
version = "2.63.0-dev"
description = "DeltaChat JSON-RPC API"
edition = "2024"
license = "MPL-2.0"
+12
View File
@@ -258,6 +258,15 @@ pub enum EventType {
chat_id: u32,
},
/// The list of pinned messages for the chat has changed.
///
/// Some message got pinned, or pinned message is unpinned or deleted.
#[serde(rename_all = "camelCase")]
PinnedMessagesChanged {
/// ID of the chat where the list of pinned messages changed.
chat_id: u32,
},
/// Contact(s) created, renamed, blocked or deleted.
#[serde(rename_all = "camelCase")]
ContactsChanged {
@@ -512,6 +521,9 @@ impl From<CoreEventType> for EventType {
msg_id: msg_id.to_u32(),
contact_id: contact_id.to_u32(),
},
CoreEventType::PinnedMessagesChanged { chat_id } => PinnedMessagesChanged {
chat_id: chat_id.to_u32(),
},
CoreEventType::IncomingReaction {
chat_id,
contact_id,
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "2.62.0"
version = "2.63.0-dev"
license = "MPL-2.0"
edition = "2024"
repository = "https://github.com/chatmail/core"
-59
View File
@@ -27,55 +27,6 @@ use deltachat::sql;
use deltachat::tools::*;
use tokio::fs;
/// Reset database tables.
/// Argument is a bitmask, executing single or multiple actions in one call.
/// e.g. bitmask 7 triggers actions defined with bits 1, 2 and 4.
async fn reset_tables(context: &Context, bits: i32) {
println!("Resetting tables ({bits})...");
if 0 != bits & 4 {
context
.sql()
.execute("DELETE FROM keypairs;", ())
.await
.unwrap();
println!("(4) Private keypairs reset.");
}
if 0 != bits & 8 {
context
.sql()
.execute("DELETE FROM contacts WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM chats WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM chats_contacts;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM msgs WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute(
"DELETE FROM config WHERE keyname LIKE 'imap.%' OR keyname LIKE 'configured%';",
(),
)
.await
.unwrap();
context.sql().config_cache().write().await.clear();
println!("(8) Rest but server config reset.");
}
context.emit_msgs_changed_without_ids();
}
async fn poke_eml_file(context: &Context, filename: &Path) -> Result<()> {
let data = read_file(context, filename).await?;
@@ -304,7 +255,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
export-keys\n\
import-keys <key-file>\n\
poke [<eml-file>|<folder>|<addr> <key-file>]\n\
reset <flags>\n\
stop\n\
============================================="
),
@@ -444,15 +394,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"poke" => {
ensure!(poke_spec(&context, Some(arg1)).await, "Poke failed");
}
"reset" => {
ensure!(
!arg1.is_empty(),
"Argument <bits> missing: 4=private keys, 8=rest but server config"
);
let bits: i32 = arg1.parse()?;
ensure!(bits < 16, "<bits> must be lower than 16.");
reset_tables(&context, bits).await;
}
"stop" => {
context.stop_ongoing().await;
}
+1 -2
View File
@@ -147,7 +147,7 @@ impl Completer for DcHelper {
}
}
const IMEX_COMMANDS: [&str; 10] = [
const IMEX_COMMANDS: [&str; 9] = [
"has-backup",
"export-backup",
"import-backup",
@@ -156,7 +156,6 @@ const IMEX_COMMANDS: [&str; 10] = [
"export-keys",
"import-keys",
"poke",
"reset",
"stop",
];
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.62.0"
version = "2.63.0-dev"
license = "MPL-2.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
+10 -2
View File
@@ -24,8 +24,16 @@ class DirectImap:
def __init__(self, account: Account, addr=None, password=None) -> None:
self.account = account
self.addr = addr or account.get_config("addr")
self.password = password or account.get_config("mail_pw")
if addr is None or password is None:
transport = account.list_transports()[-1]
if addr is None:
self.addr = transport["addr"]
else:
self.addr = addr
if password is None:
self.password = transport["password"]
else:
self.password = password
self.logid = account.get_config("displayname") or id(account)
self._idling = False
self.connect()
+37 -10
View File
@@ -1,4 +1,5 @@
import logging
import time
import pytest
@@ -27,7 +28,7 @@ def test_qr_setup_contact(acf) -> None:
def test_qr_setup_contact_svg(acf) -> None:
alice = acf.new_configured_account()
_, _, domain = alice.get_config("addr").rpartition("@")
_, _, domain = alice.list_transports()[0]["addr"].rpartition("@")
_qr_code, svg = alice.get_qr_code_svg()
@@ -43,6 +44,7 @@ def test_qr_setup_contact_svg(acf) -> None:
def test_qr_securejoin(acf):
alice, bob, fiona = acf.get_online_accounts(3)
alice.set_config("displayname", "Alice")
# Setup second device for Alice
# to test observing securejoin protocol.
alice2 = alice.clone()
@@ -67,7 +69,7 @@ def test_qr_securejoin(acf):
assert alice_contact_bob_snapshot.e2ee_avail
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You were added by {}.".format(alice.get_config("addr"))
assert snapshot.text == "You were added by Alice."
bob_contact_alice = bob.create_contact(alice)
bob_contact_alice_snapshot = bob_contact_alice.get_snapshot()
@@ -89,6 +91,30 @@ def test_qr_securejoin(acf):
fiona.wait_for_securejoin_joiner_success()
def test_qr_securejoin_request_deleted_on_all_transports(acf):
alice, bob = acf.get_online_accounts(2)
alice.add_transport_from_qr(acf.get_account_qr())
alice.bring_online()
alice_chat = alice.create_group("Group")
qr_code = alice_chat.get_qr_code()
alice2 = alice.clone()
bob.secure_join(qr_code)
alice.wait_for_securejoin_inviter_success()
alice_chat.remove_contact(bob)
# Stop and start io in order to wait until the remove message is sent out.
alice.stop_io()
alice.bring_online()
# Membership timestamps have a resolution of one second.
time.sleep(1)
# By now, the securejoin message sent by Bob must be deleted on Alice's relays.
# Otherwise, alice2 would execute securejoin again and re-add Bob.
alice2.bring_online()
alice2_chat = alice2.get_chat_by_id(alice_chat.id)
assert alice2.create_contact(bob) not in alice2_chat.get_contacts()
@pytest.mark.parametrize("all_devices_online", [True, False])
def test_qr_securejoin_broadcast(acf, all_devices_online):
alice, bob, fiona = acf.get_online_accounts(3)
@@ -499,11 +525,9 @@ def test_aeap_flow(acf):
assert msg_in_1.text == msg_out.text
logging.info("changing email account")
ac1.set_config("addr", addr)
ac1.set_config("mail_pw", password)
ac1.stop_io()
ac1.configure()
ac1.start_io()
old_addr = ac1.list_transports()[0]["addr"]
ac1.add_transport_from_qr(acf.get_account_qr())
ac1.delete_transport(old_addr)
logging.info("sending second message")
msg_out = chat.send_text("changed address").get_snapshot()
@@ -525,6 +549,7 @@ def test_securejoin_after_contact_resetup(acf) -> None:
but different key fingerprint while a securejoin with that contact is still pending.
"""
ac1, ac2, ac3 = acf.get_online_accounts(3)
ac3.set_config("displayname", "ac3")
# ac3 creates a group with ac1.
ac3_chat = ac3.create_group("Group")
@@ -536,7 +561,7 @@ def test_securejoin_after_contact_resetup(acf) -> None:
# ac1 waits for member added message and creates a QR code.
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You were added by {}.".format(ac3.get_config("addr"))
assert snapshot.text == "You were added by ac3."
ac1_qr_code = snapshot.chat.get_qr_code()
# ac2 sets up contact with ac1
@@ -576,6 +601,8 @@ def test_securejoin_after_contact_resetup(acf) -> None:
def test_withdraw_securejoin_qr(acf):
alice, bob = acf.get_online_accounts(2)
alice.set_config("displayname", "Alice")
bob.set_config("displayname", "Bob")
logging.info("Alice creates a group")
alice_chat = alice.create_group("Group")
@@ -588,11 +615,11 @@ def test_withdraw_securejoin_qr(acf):
alice.clear_all_events()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You were added by {}.".format(alice.get_config("addr"))
assert snapshot.text == "You were added by Alice."
bob_chat.leave()
snapshot = alice.get_message_by_id(alice.wait_for_msgs_changed_event().msg_id).get_snapshot()
assert snapshot.text == "Group left by {}.".format(bob.get_config("addr"))
assert snapshot.text == "Group left by Bob."
logging.info("Alice withdraws QR code.")
qr = alice.check_qr(qr_code)
+2 -4
View File
@@ -155,7 +155,7 @@ def test_list_transports(acf) -> None:
def test_account(acf) -> None:
alice, bob = acf.get_online_accounts(2)
bob_addr = bob.get_config("addr")
bob_addr = bob.get_config("configured_addr")
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_text("Hello!")
@@ -318,7 +318,7 @@ def test_chat(acf) -> None:
def test_contact(acf) -> None:
alice, bob = acf.get_online_accounts(2)
bob_addr = bob.get_config("addr")
bob_addr = bob.get_config("configured_addr")
alice_contact_bob = alice.create_contact(bob, "Bob")
assert alice_contact_bob == alice.get_contact_by_id(alice_contact_bob.id)
@@ -604,7 +604,6 @@ def test_import_export_online_all(acf, tmp_path, rpcdata, log) -> None:
(ac1, some1) = acf.get_online_accounts(2)
log.section("create some chat content")
some1_addr = some1.get_config("addr")
chat1 = ac1.create_contact(some1).create_chat()
chat1.send_text("msg1")
assert len(ac1.get_contacts()) == 1
@@ -622,7 +621,6 @@ def test_import_export_online_all(acf, tmp_path, rpcdata, log) -> None:
contacts = ac.get_contacts()
assert len(contacts) == 1
contact2 = contacts[0]
assert contact2.get_snapshot().address == some1_addr
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 3 + E2EE_INFO_MSGS
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "2.62.0"
version = "2.63.0-dev"
description = "DeltaChat JSON-RPC server"
edition = "2024"
license = "MPL-2.0"
@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "2.62.0"
"version": "2.63.0-dev"
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.62.0"
version = "2.63.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"
+3 -1
View File
@@ -34,6 +34,8 @@ const DEFAULT_RELAY_CANDIDATES: &[&str] = &[
"chat.nuvon.app",
"chat.tinydispatch.org",
"chat.vim.wtf",
"chatmail.au",
"chatmail.cc",
"chatmail.uk",
"chtml.ca",
"deltachat.me",
@@ -163,7 +165,7 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool
for _ in 0..NUM_TRANSPORTS_TARGET {
if context.count_transports().await? >= NUM_TRANSPORTS_TARGET {
context
.set_config_internal(Config::AutorelayFinished, config::from_bool(true))
.set_config_internal(Config::AutorelayFinished, Some(config::from_bool(true)))
.await?;
return Ok(relay_added);
+3 -3
View File
@@ -164,9 +164,9 @@ impl<'a> BlobObject<'a> {
/// you want to create a [BlobObject] for a filename read from the
/// database.
pub fn from_name(context: &'a Context, name: &str) -> Result<BlobObject<'a>> {
let name = match name.starts_with("$BLOBDIR/") {
true => name.splitn(2, '/').last().unwrap(),
false => name,
let name = match name.strip_prefix("$BLOBDIR/") {
Some(name) => name,
None => name,
};
if !BlobObject::is_acceptible_blob_name(name) {
return Err(format_err!("not an acceptable blob name: {name}"));
+11 -4
View File
@@ -3639,7 +3639,7 @@ pub(crate) async fn create_out_broadcast_ext(
)?;
ensure!(cnt == 0, "{cnt} chats exist with grpid {grpid}");
let mut params: Params = Params::new();
params.update_timestamp(Param::GroupNameTimestamp, time())?;
params.update_timestamp(Param::GroupNameTimestamp, time());
t.execute(
"INSERT INTO chats
@@ -4795,12 +4795,14 @@ pub(crate) async fn get_chat_id_by_grpid(
///
/// Optional `label` can be provided to ensure that message is added only once.
/// If `important` is true, a notification will be sent.
/// `timestamp_sent` is the time shown on the message; it does not affect ordering.
#[expect(clippy::arithmetic_side_effects)]
pub async fn add_device_msg_with_importance(
context: &Context,
label: Option<&str>,
msg: Option<&mut Message>,
important: bool,
timestamp_sent: i64,
) -> Result<MsgId> {
ensure!(
label.is_some() || msg.is_some(),
@@ -4820,11 +4822,10 @@ pub async fn add_device_msg_with_importance(
chat_id = ChatId::get_for_contact(context, ContactId::DEVICE).await?;
let rfc724_mid = create_outgoing_rfc724_mid();
let timestamp_sent = time();
// makes sure, the added message is the last one,
// even if the date is wrong (useful esp. when warning about bad dates)
msg.timestamp_sort = timestamp_sent;
msg.timestamp_sort = time();
if let Some(last_msg_time) = chat_id.get_timestamp(context).await?
&& msg.timestamp_sort <= last_msg_time
{
@@ -4892,7 +4893,7 @@ pub async fn add_device_msg(
label: Option<&str>,
msg: Option<&mut Message>,
) -> Result<MsgId> {
add_device_msg_with_importance(context, label, msg, false).await
add_device_msg_with_importance(context, label, msg, false, time()).await
}
/// Returns true if device message with a given label was ever added to the device chat.
@@ -4920,6 +4921,12 @@ pub(crate) async fn delete_and_reset_all_device_msgs(context: &Context) -> Resul
.execute("DELETE FROM msgs WHERE from_id=?;", (ContactId::DEVICE,))
.await?;
context.sql.execute("DELETE FROM devmsglabels;", ()).await?;
context
.set_config_internal(Config::BackupTransferMsgId, None)
.await?;
context
.set_config_internal(Config::BackupTransferTimestamp, None)
.await?;
// Insert labels for welcome messages to avoid them being re-added on reconfiguration.
context
+14 -3
View File
@@ -336,6 +336,17 @@ pub enum Config {
/// Timestamp of the last time housekeeping was run
LastHousekeeping,
/// ID of the device message added after backup transfer.
///
/// If the message does not exist on the first housekeeping
/// after `READD_BACKUP_TRANSFER_MSG_DELAY`, it is re-added.
BackupTransferMsgId,
/// Timestamp of the device message added after backup transfer.
///
/// Needed to check against `READD_BACKUP_TRANSFER_MSG_DELAY`.
BackupTransferTimestamp,
/// Timestamp of the last time accumulated broadcast channel reactions were sent
LastReactionsBroadcast,
@@ -809,7 +820,7 @@ impl Context {
/// Set the given config to a boolean value.
pub async fn set_config_bool(&self, key: Config, value: bool) -> Result<()> {
self.set_config(key, from_bool(value)).await?;
self.set_config(key, Some(from_bool(value))).await?;
Ok(())
}
@@ -830,8 +841,8 @@ impl Context {
}
/// Returns a value for use in `Context::set_config_*()` for the given `bool`.
pub(crate) fn from_bool(val: bool) -> Option<&'static str> {
Some(if val { "1" } else { "0" })
pub(crate) fn from_bool(val: bool) -> &'static str {
if val { "1" } else { "0" }
}
pub(crate) fn bool_from_config(config: Option<&str>) -> bool {
+1 -3
View File
@@ -162,9 +162,7 @@ impl Context {
.await;
self.free_ongoing().await;
res?;
param.save_legacy(self).await
res
}
/// Adds a new email account as a transport
+1 -1
View File
@@ -29,7 +29,7 @@ impl ServerParams {
if self.username.is_empty() {
vec![Self {
username: addr.to_string(),
..self.clone()
..self
}]
} else {
vec![self]
+2
View File
@@ -294,6 +294,8 @@ async fn test_get_info_completeness() {
// too sensitive or summarized in another item.
let skip_from_get_info = vec![
"addr",
"backup_transfer_msg_id",
"backup_transfer_timestamp",
"displayname",
"imap_certificate_checks",
"mail_server",
+23 -9
View File
@@ -363,7 +363,7 @@ pub(crate) async fn start_chat_ephemeral_timers(context: &Context, chat_id: Chat
/// Selects messages which are expired according to
/// `delete_device_after` setting or `ephemeral_timestamp` column.
///
/// For each message a row ID, chat id, viewtype and location ID is returned.
/// For each message a row ID, chat id, viewtype, whether the message is pinned and location ID is returned.
///
/// Unknown viewtypes are returned as `Viewtype::Unknown`
/// and not as errors bubbled up, easily resulting in infinite loop or leaving messages undeleted.
@@ -371,12 +371,12 @@ pub(crate) async fn start_chat_ephemeral_timers(context: &Context, chat_id: Chat
async fn select_expired_messages(
context: &Context,
now: i64,
) -> Result<Vec<(MsgId, ChatId, Viewtype, u32)>> {
) -> Result<Vec<(MsgId, ChatId, Viewtype, bool, u32)>> {
let mut rows = context
.sql
.query_map_vec(
r#"
SELECT id, chat_id, type, location_id
SELECT id, chat_id, type, pinned, location_id
FROM msgs
WHERE
ephemeral_timestamp != 0
@@ -392,8 +392,9 @@ WHERE
.context("Using default viewtype for ephemeral handling.")
.log_err(context)
.unwrap_or_default();
let pinned: bool = row.get("pinned")?;
let location_id: u32 = row.get("location_id")?;
Ok((id, chat_id, viewtype, location_id))
Ok((id, chat_id, viewtype, pinned, location_id))
},
)
.await?;
@@ -414,7 +415,7 @@ WHERE
.sql
.query_map_vec(
r#"
SELECT id, chat_id, type, location_id
SELECT id, chat_id, type, pinned, location_id
FROM msgs
WHERE
timestamp < ?1
@@ -437,8 +438,9 @@ WHERE
.context("Using default viewtype for delete-old handling.")
.log_err(context)
.unwrap_or_default();
let pinned: bool = row.get("pinned")?;
let location_id: u32 = row.get("location_id")?;
Ok((id, chat_id, viewtype, location_id))
Ok((id, chat_id, viewtype, pinned, location_id))
},
)
.await?;
@@ -463,11 +465,15 @@ pub(crate) async fn delete_expired_messages(context: &Context, now: i64) -> Resu
if !rows.is_empty() {
info!(context, "Attempting to delete {} messages.", rows.len());
let (msgs_changed, webxdc_deleted) = context
let (msgs_changed, webxdc_deleted, pinned_chat_ids) = context
.sql
.transaction(|transaction| {
let mut msgs_changed = Vec::with_capacity(rows.len());
let mut webxdc_deleted = Vec::new();
// IDs of the chats in which pinned messages were deleted.
let mut pinned_chat_ids = BTreeSet::new();
// If you change which information is preserved here, also change `MsgId::trash()`
// and other places it references.
let mut del_msg_stmt = transaction.prepare(
@@ -478,7 +484,7 @@ SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ? FROM msgs WHERE id=?1
)?;
let mut del_location_stmt =
transaction.prepare("DELETE FROM locations WHERE independent=1 AND id=?")?;
for (msg_id, chat_id, viewtype, location_id) in rows {
for (msg_id, chat_id, viewtype, is_pinned, location_id) in rows {
del_msg_stmt.execute((msg_id, ChatId::TRASH))?;
if location_id > 0 {
del_location_stmt.execute((location_id,))?;
@@ -488,8 +494,12 @@ SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ? FROM msgs WHERE id=?1
if viewtype == Viewtype::Webxdc {
webxdc_deleted.push(msg_id)
}
if is_pinned {
pinned_chat_ids.insert(chat_id);
}
}
Ok((msgs_changed, webxdc_deleted))
Ok((msgs_changed, webxdc_deleted, pinned_chat_ids))
})
.await?;
@@ -504,6 +514,10 @@ SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ? FROM msgs WHERE id=?1
context.emit_msgs_changed_without_msg_id(modified_chat_id);
}
for chat_id in pinned_chat_ids {
context.emit_event(EventType::PinnedMessagesChanged { chat_id });
}
for msg_id in webxdc_deleted {
context.emit_event(EventType::WebxdcInstanceDeleted { msg_id });
}
+8
View File
@@ -93,6 +93,14 @@ pub enum EventType {
contact_id: ContactId,
},
/// The list of pinned messages for the chat has changed.
///
/// Some message got pinned, or pinned message is unpinned or deleted.
PinnedMessagesChanged {
/// ID of the chat where the list of pinned messages changed.
chat_id: ChatId,
},
/// A reaction to one's own sent message received.
/// Typically, the UI will show a notification for that.
///
+2 -2
View File
@@ -1454,7 +1454,7 @@ impl Session {
/// or flags have been changed.
/// In this case we may want to skip next IDLE and do a round
/// of fetching new messages and synchronizing seen flags.
fn drain_unsolicited_responses(&self, context: &Context) -> Result<bool> {
fn drain_unsolicited_responses(&self, context: &Context) -> bool {
use UnsolicitedResponse::*;
use async_imap::imap_proto::Response;
use async_imap::imap_proto::ResponseCode;
@@ -1499,7 +1499,7 @@ impl Session {
}
}
}
Ok(should_refetch)
should_refetch
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ impl Session {
self.select_with_uidvalidity(context, folder).await?;
if self.drain_unsolicited_responses(context)? {
if self.drain_unsolicited_responses(context) {
self.new_mail = true;
}
+1
View File
@@ -30,6 +30,7 @@ use crate::tools::{
mod transfer;
use ::pgp::types::KeyDetails;
pub(crate) use transfer::maybe_readd_backup_transfer_msg;
pub use transfer::{BackupProvider, get_backup};
// Name of the database file in the backup.
+97 -7
View File
@@ -40,21 +40,68 @@ use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::EventType;
use crate::chat::add_device_msg;
use crate::chat::add_device_msg_with_importance;
use crate::config::Config;
use crate::context::Context;
use crate::imex::BlobDirContents;
use crate::key;
use crate::log::warn;
use crate::message::Message;
use crate::message::{Message, MsgId};
use crate::qr::Qr;
use crate::stock_str::backup_transfer_msg_body;
use crate::tools::{TempPathGuard, create_id};
use crate::tools::{TempPathGuard, create_id, time};
use super::{DBFILE_BACKUP_NAME, export_backup_stream, export_database, import_backup_stream};
/// ALPN protocol identifier for the backup transfer protocol.
const BACKUP_ALPN: &[u8] = b"/deltachat/backup";
/// Minimum time after a backup transfer before we check
/// whether the "second device added" device message still exists.
///
/// The check is done in the first housekeeping after this delay;
/// if the message was deleted until then, it is re-added with the original timestamp.
/// After the check, the user can delete the message as usual without it being re-added.
///
/// This makes it easier to spot unwanted "add second device" actions.
/// It is clear that this does not catch all eventualities, it is best-effort.
/// First line of defense is device locking and asking for secret explicitly before adding a second device.
const READD_BACKUP_TRANSFER_MSG_DELAY: i64 = 60 * 60;
/// Checks `Config::BackupTransferMsgId`
/// and re-adds the backup transfer device message if it does not exist and some time passed.
pub(crate) async fn maybe_readd_backup_transfer_msg(context: &Context) -> Result<()> {
let Some(msg_id) = context
.get_config_parsed::<u32>(Config::BackupTransferMsgId)
.await?
else {
return Ok(());
};
let timestamp = context
.get_config_i64(Config::BackupTransferTimestamp)
.await?;
if time() < timestamp.saturating_add(READD_BACKUP_TRANSFER_MSG_DELAY) {
return Ok(());
}
context
.set_config_internal(Config::BackupTransferMsgId, None)
.await?;
context
.set_config_internal(Config::BackupTransferTimestamp, None)
.await?;
if Message::load_from_db_optional(context, MsgId::new(msg_id))
.await?
.is_none()
{
let mut msg = Message::new_text(backup_transfer_msg_body(context));
add_device_msg_with_importance(context, None, Some(&mut msg), false, timestamp).await?;
}
Ok(())
}
/// Provide or send a backup of this device.
///
/// This creates a backup of the current device and starts a service which offers another
@@ -212,8 +259,23 @@ impl BackupProvider {
info!(context, "Received backup reception acknowledgement.");
context.emit_event(EventType::ImexProgress(1000));
let timestamp = time();
let mut msg = Message::new_text(backup_transfer_msg_body(&context));
add_device_msg(&context, None, Some(&mut msg)).await?;
let msg_id =
add_device_msg_with_importance(&context, None, Some(&mut msg), false, timestamp)
.await?;
context
.set_config_internal(
Config::BackupTransferTimestamp,
Some(&timestamp.to_string()),
)
.await?;
context
.set_config_internal(
Config::BackupTransferMsgId,
Some(&msg_id.to_u32().to_string()),
)
.await?;
Ok(())
}
@@ -393,14 +455,17 @@ pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> {
mod tests {
use std::time::Duration;
use crate::chat::{ChatItem, get_chat_msgs, send_msg};
use crate::message::Viewtype;
use crate::chat::{ChatId, ChatItem, get_chat_msgs, send_msg};
use crate::contact::ContactId;
use crate::message::{Viewtype, delete_msgs};
use crate::sql::housekeeping;
use crate::test_utils::TestContextManager;
use crate::tools::SystemTime;
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_receive() {
async fn test_send_receive() -> Result<()> {
let mut tcm = TestContextManager::new();
// Create first device.
@@ -472,6 +537,31 @@ mod tests {
.get_matching(|ev| matches!(ev, EventType::ImexProgress(1000)))
.await;
}
// When deleting the backup transfer message on the sending device,
// it is re-added once on housekeeping,
// but not within the first hour after the transfer.
let device_chat_id = ChatId::get_for_contact(&ctx0, ContactId::DEVICE).await?;
let original_msg = ctx0.get_last_msg_in(device_chat_id).await;
assert_eq!(original_msg.text, backup_transfer_msg_body(&ctx0));
delete_msgs(&ctx0, &[original_msg.id]).await?;
housekeeping(&ctx0).await?;
assert!(get_chat_msgs(&ctx0, device_chat_id).await?.is_empty()); // re-adding not done in first hour
SystemTime::shift(Duration::from_secs(60 * 60));
housekeeping(&ctx0).await?;
let readded_msg = ctx0.get_last_msg_in(device_chat_id).await;
assert_ne!(readded_msg.id, original_msg.id);
assert_eq!(readded_msg.get_text(), original_msg.get_text());
assert_eq!(readded_msg.get_timestamp(), original_msg.get_timestamp());
assert!(readded_msg.get_sort_timestamp() > original_msg.get_sort_timestamp());
delete_msgs(&ctx0, &[readded_msg.id]).await?;
housekeeping(&ctx0).await?;
let device_msgs = get_chat_msgs(&ctx0, device_chat_id).await?;
assert!(device_msgs.is_empty()); // re-adding is done only once
Ok(())
}
/// Tests that trying to accidentally overwrite a profile
+2 -1
View File
@@ -98,7 +98,8 @@ pub trait DcKey: Serialize + Deserializable + Clone {
// Because we write to a Vec<u8> the io::Write impls never
// fail and we can hide this error.
let mut buf = Vec::new();
self.to_writer(&mut buf).unwrap();
self.to_writer(&mut buf)
.expect("Writing to Vec<u8> cannot fail");
buf
}
+2 -1
View File
@@ -15,7 +15,8 @@
clippy::explicit_iter_loop,
clippy::explicit_into_iter_loop,
clippy::cloned_instead_of_copied,
clippy::manual_is_variant_and
clippy::manual_is_variant_and,
clippy::unnecessary_wraps
)]
#![cfg_attr(not(test), warn(clippy::arithmetic_side_effects))]
#![cfg_attr(not(test), forbid(clippy::indexing_slicing))]
-95
View File
@@ -9,14 +9,12 @@
use std::fmt;
use anyhow::{Context as _, Result};
use num_traits::ToPrimitive as _;
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::context::Context;
pub use crate::net::proxy::ProxyConfig;
pub use crate::provider::Socket;
use crate::tools::ToOption;
/// User-entered setting for certificate checks.
///
@@ -232,62 +230,6 @@ impl EnteredLoginParam {
oauth2: false,
})
}
/// Saves entered account settings,
/// so that they can be prefilled if the user wants to configure the server again.
///
/// This is needed in case a UI is not yet updated, and still uses `get_config("mail_pw")` etc.
/// in order to prefill the entered account settings.
pub(crate) async fn save_legacy(&self, context: &Context) -> Result<()> {
context.set_config(Config::Addr, Some(&self.addr)).await?;
context
.set_config(Config::MailServer, self.imap.server.to_option())
.await?;
context
.set_config(Config::MailPort, self.imap.port.to_option().as_deref())
.await?;
context
.set_config(
Config::MailSecurity,
self.imap.security.to_i32().to_option().as_deref(),
)
.await?;
context
.set_config(Config::MailUser, self.imap.user.to_option())
.await?;
context
.set_config(Config::MailPw, self.imap.password.to_option())
.await?;
context
.set_config(Config::SendServer, self.smtp.server.to_option())
.await?;
context
.set_config(Config::SendPort, self.smtp.port.to_option().as_deref())
.await?;
context
.set_config(
Config::SendSecurity,
self.smtp.security.to_i32().to_option().as_deref(),
)
.await?;
context
.set_config(Config::SendUser, self.smtp.user.to_option())
.await?;
context
.set_config(Config::SendPw, self.smtp.password.to_option())
.await?;
context
.set_config(
Config::ImapCertificateChecks,
self.certificate_checks.to_i32().to_option().as_deref(),
)
.await?;
Ok(())
}
}
impl fmt::Display for EnteredLoginParam {
@@ -369,41 +311,4 @@ mod tests {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_save_entered_login_param() -> Result<()> {
let t = TestContext::new().await;
let param = EnteredLoginParam {
addr: "alice@example.org".to_string(),
imap: EnteredImapLoginParam {
server: "".to_string(),
port: 0,
folder: "".to_string(),
security: Socket::Starttls,
user: "".to_string(),
password: "foobar".to_string(),
},
smtp: EnteredSmtpLoginParam {
server: "".to_string(),
port: 2947,
security: Socket::default(),
user: "".to_string(),
password: "".to_string(),
},
certificate_checks: Default::default(),
oauth2: false,
};
param.save_legacy(&t).await?;
assert_eq!(
t.get_config(Config::Addr).await?.unwrap(),
"alice@example.org"
);
assert_eq!(t.get_config(Config::MailPw).await?.unwrap(), "foobar");
assert_eq!(t.get_config(Config::SendPw).await?, None);
assert_eq!(t.get_config_int(Config::SendPort).await?, 2947);
assert_eq!(EnteredLoginParam::load_legacy(&t).await?, param);
Ok(())
}
}
+28 -9
View File
@@ -580,7 +580,7 @@ impl Message {
if let Some(msg) = &mut msg {
msg.additional_text =
Self::get_additional_text(context, msg.download_state, &msg.param)?;
Self::get_additional_text(context, msg.download_state, &msg.param);
}
Ok(msg)
@@ -618,7 +618,7 @@ impl Message {
context: &Context,
download_state: DownloadState,
param: &Params,
) -> Result<String> {
) -> String {
if download_state != DownloadState::Done {
let file_size = param
.get(Param::PostMessageFileBytes)
@@ -635,14 +635,14 @@ impl Message {
.unwrap_or("?".to_owned());
return match viewtype {
Viewtype::File => Ok(format!(" [{file_name} – {file_size}]")),
Viewtype::File => format!(" [{file_name} – {file_size}]"),
_ => {
let translated_viewtype = viewtype.to_locale_string(context);
Ok(format!(" [{translated_viewtype} – {file_size}]"))
format!(" [{translated_viewtype} – {file_size}]")
}
};
}
Ok(String::new())
String::new()
}
/// Returns the MIME type of an attached file if it exists.
@@ -1641,11 +1641,15 @@ pub(crate) async fn delete_msgs_locally_done(
context: &Context,
msg_ids: &[MsgId],
modified_chat_ids: BTreeSet<ChatId>,
pinned_messages_changed_chat_ids: BTreeSet<ChatId>,
) -> Result<()> {
for modified_chat_id in modified_chat_ids {
context.emit_msgs_changed_without_msg_id(modified_chat_id);
chatlist_events::emit_chatlist_item_changed(context, modified_chat_id);
}
for chat_id in pinned_messages_changed_chat_ids {
context.emit_event(EventType::PinnedMessagesChanged { chat_id });
}
if !msg_ids.is_empty() {
context.emit_msgs_changed_without_ids();
chatlist_events::emit_chatlist_changed(context);
@@ -1671,6 +1675,7 @@ pub async fn delete_msgs_ext(
delete_for_all: bool,
) -> Result<()> {
let mut modified_chat_ids = BTreeSet::new();
let mut pinned_messages_changed_chat_ids = BTreeSet::new();
let mut deleted_rfc724_mid = Vec::new();
let mut res = Ok(());
@@ -1686,6 +1691,9 @@ pub async fn delete_msgs_ext(
);
modified_chat_ids.insert(msg.chat_id);
if msg.is_pinned() {
pinned_messages_changed_chat_ids.insert(msg.chat_id);
}
deleted_rfc724_mid.push(msg.rfc724_mid.clone());
let update_db = |trans: &mut rusqlite::Transaction| {
@@ -1743,7 +1751,13 @@ pub async fn delete_msgs_ext(
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?;
delete_msgs_locally_done(
context,
msg_ids,
modified_chat_ids,
pinned_messages_changed_chat_ids,
)
.await?;
// Interrupt Inbox loop to start message deletion, run housekeeping and call send_sync_msg().
context.scheduler.interrupt_inbox().await;
@@ -2003,14 +2017,19 @@ pub(crate) async fn set_msg_failed(
/// Inserts a tombstone into `msgs` table
/// to prevent downloading the same message in the future.
/// With `on_server`, copies seen on IMAP later are deleted on the server.
///
/// Returns tombstone database row ID.
pub(crate) async fn insert_tombstone(context: &Context, rfc724_mid: &str) -> Result<MsgId> {
pub(crate) async fn insert_tombstone(
context: &Context,
rfc724_mid: &str,
on_server: bool,
) -> Result<MsgId> {
let row_id = context
.sql
.insert(
"INSERT INTO msgs(rfc724_mid, chat_id) VALUES (?,?)",
(rfc724_mid, ChatId::TRASH),
"INSERT INTO msgs(rfc724_mid, chat_id, deleted) VALUES (?,?,?)",
(rfc724_mid, ChatId::TRASH, on_server),
)
.await?;
let msg_id = MsgId::new(u32::try_from(row_id)?);
+1 -3
View File
@@ -784,7 +784,7 @@ async fn test_get_existing_msg_ids() -> Result<()> {
}
#[test]
fn test_can_fail() -> Result<()> {
fn test_can_fail() {
use MessageState::*;
// states that are not allowed to transition to OutFailed
@@ -799,6 +799,4 @@ fn test_can_fail() -> Result<()> {
assert!(OutPending.can_fail());
assert!(OutDelivered.can_fail());
assert!(OutFailed.can_fail());
Ok(())
}
+6 -5
View File
@@ -511,11 +511,6 @@ impl MimeFactory {
let mut member_fingerprints = Vec::new();
let mut member_timestamps = Vec::new();
let mut recipient_ids = HashSet::new();
let req_mdn = !chat.is_self_talk()
&& !msg.is_system_message()
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0
&& context.should_request_mdns().await?;
let self_fingerprint = self_fingerprint(context).await?;
let encryption = if chat.is_self_talk() {
@@ -796,6 +791,12 @@ impl MimeFactory {
}
};
let req_mdn = encryption.is_encrypted()
&& !chat.is_self_talk()
&& !msg.is_system_message()
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0
&& context.should_request_mdns().await?;
let (in_reply_to, references) = context
.sql
.query_row(
+140 -22
View File
@@ -22,7 +22,7 @@ use crate::key::{load_self_secret_key, secret_key_to_public_key};
use crate::message;
use crate::mimeparser::MimeMessage;
use crate::receive_imf::receive_imf;
use crate::test_utils;
use crate::test_utils::{self, SentMessage};
use crate::test_utils::{TestContext, TestContextManager, get_chat_msg};
use crate::tools::SystemTime;
@@ -795,7 +795,7 @@ async fn test_protected_headers_directive() -> Result<()> {
// Long messages are truncated and MimeMessage::decoded_data is set for them. We need
// decoded_data to check presence of the necessary headers.
msg.set_text("a".repeat(constants::DC_DESIRED_TEXT_LEN + 1));
msg.set_file_from_bytes(&bob, "foo.bar", "content".as_bytes(), None)?;
msg.set_file_from_bytes(&bob, "foo.bar", b"content", None)?;
let sent = bob.send_msg(chat, &mut msg).await;
assert!(msg.get_showpadlock());
assert!(sent.payload.contains("\r\nSubject: [...]\r\n"));
@@ -1022,7 +1022,7 @@ END:VCARD";
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_render_outer_headers() -> Result<()> {
async fn test_render_outer_headers_of_encrypted_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
@@ -1030,26 +1030,9 @@ async fn test_render_outer_headers() -> Result<()> {
let chat_id = alice.create_chat_id(bob).await;
let sent = alice.send_text(chat_id, "Hello!").await;
let (unencrypted, _encrypted) = sent
.payload()
.split_once("-----BEGIN PGP MESSAGE-----")
.unwrap();
let payload = normalized_payload(sent).await;
// Normalize the parts of the message that vary between runs
// (MIME boundary, Date, Message-ID)
let boundary = unencrypted
.split_once("boundary=\"")
.and_then(|(_, rest)| rest.split_once('"'))
.map(|(b, _)| b)
.unwrap_or_default();
let unencrypted = unencrypted.replace(boundary, "BOUNDARY");
let rfc724_mid = sent.load_from_db().await.rfc724_mid;
let unencrypted = unencrypted.replace(&rfc724_mid, "MESSAGE_ID@localhost");
let unencrypted = regex!(r"Date:[^\r\n]*")
.replace(&unencrypted, "Date: DATE")
.to_string();
let (unencrypted, _encrypted) = payload.split_once("-----BEGIN PGP MESSAGE-----").unwrap();
let expected = r#"From: <alice@example.org>
Date: DATE
@@ -1085,3 +1068,138 @@ expected (debug print): {expected:?}"
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_render_unencrypted_msg_basic() -> Result<()> {
let alice = &TestContext::new_alice().await;
alice.allow_unencrypted().await?;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.net")
.await;
let sent = alice.send_text(chat.id, "Hello!").await;
let unencrypted = normalized_payload(sent).await;
let expected = r#"From: <alice@example.org>
Message-ID: <MESSAGE_ID@localhost>
MIME-Version: 1.0
Autocrypt: addr=alice@example.org; prefer-encrypt=mutual; keydata=mDMEXlh13RYJKwYBBAHaRw8BAQdAzfVIAleCXMJrq8VeLlEVof6ITCviMktKjmcBKAu4m5
DCtAQfFggAZgUCXlh13RYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDAhsDAh4JBAsJCAcFFQgJCgsDFgIB
AycJAgIZASwUgAAAAAASABFyZWxheXNAY2hhdG1haWwuYXRhbGljZUBleGFtcGxlLm9yZwAAb1QA/0
HbvPN3/Vn02Gk1dcQMEcyGyETld9dSsRo8uwHAyW35AQCrFJjAQFLTud7XK61uYt9BC/QHipCfIGbq
X1FjMbTUC80TPGFsaWNlQGV4YW1wbGUub3JnPsKRBBMWCAA5BQJeWHXdFiEELm+iyyO1MtcoY0tYZL
CPYantlEMCGwMCHgkECwkIBwUVCAkKCwMWAgEDJwkCAhkBAAoJEGSwj2Gp7ZRD1m4A/iOifEzIOiP8
wW0O8I/sg69gQtG8Czn4MsVV6Ea1EyIqAP4uByHaUJdy8MSQPfv/Usr09KsidNgy2Jh37yg82fKUBr
g4BF5Ydd0SCisGAQQBl1UBBQEBB0AG7cjWy2SFAU8KnltlubVW67rFiyfp01JrRe6Xqy22HQMBCAeI
eAQYFggAIBYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDBQJeWHXdAhsMAAoJEGSwj2Gp7ZRDLo8BAObE8G
nsGVwKzNqCvHeWgJsqhjS3C6gvSlV3tEm9XmF6AQDXucIyVfoBwoyMh2h6cSn/ATn5QJb35pgo+ivp
3jsMAg==
Content-Type: text/plain; charset="utf-8"
Date: DATE
To: <bob@example.net>
Subject: Message from alice@example.org
References: <MESSAGE_ID@localhost>
Chat-Version: 1.0
Content-Transfer-Encoding: 7bit
Hello!"#
.replace("\n", "\r\n");
assert_eq!(
unencrypted, expected,
"---------------- Actual: ----------------
{unencrypted}
-----------------------------------------
actual (debug print): {unencrypted:?}
expected (debug print): {expected:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_render_unencrypted_msg_with_attachment() -> Result<()> {
let alice = &TestContext::new_alice().await;
alice.allow_unencrypted().await?;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.net")
.await;
let mut msg = Message::new(Viewtype::File);
msg.set_text("Hello!".to_string());
msg.set_file_from_bytes(alice, "foo.bar", b"content", None)?;
let sent = alice.send_msg(chat.id, &mut msg).await;
let unencrypted = normalized_payload(sent).await;
let expected = r#"From: <alice@example.org>
Message-ID: <MESSAGE_ID@localhost>
MIME-Version: 1.0
Autocrypt: addr=alice@example.org; prefer-encrypt=mutual; keydata=mDMEXlh13RYJKwYBBAHaRw8BAQdAzfVIAleCXMJrq8VeLlEVof6ITCviMktKjmcBKAu4m5
DCtAQfFggAZgUCXlh13RYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDAhsDAh4JBAsJCAcFFQgJCgsDFgIB
AycJAgIZASwUgAAAAAASABFyZWxheXNAY2hhdG1haWwuYXRhbGljZUBleGFtcGxlLm9yZwAAb1QA/0
HbvPN3/Vn02Gk1dcQMEcyGyETld9dSsRo8uwHAyW35AQCrFJjAQFLTud7XK61uYt9BC/QHipCfIGbq
X1FjMbTUC80TPGFsaWNlQGV4YW1wbGUub3JnPsKRBBMWCAA5BQJeWHXdFiEELm+iyyO1MtcoY0tYZL
CPYantlEMCGwMCHgkECwkIBwUVCAkKCwMWAgEDJwkCAhkBAAoJEGSwj2Gp7ZRD1m4A/iOifEzIOiP8
wW0O8I/sg69gQtG8Czn4MsVV6Ea1EyIqAP4uByHaUJdy8MSQPfv/Usr09KsidNgy2Jh37yg82fKUBr
g4BF5Ydd0SCisGAQQBl1UBBQEBB0AG7cjWy2SFAU8KnltlubVW67rFiyfp01JrRe6Xqy22HQMBCAeI
eAQYFggAIBYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDBQJeWHXdAhsMAAoJEGSwj2Gp7ZRDLo8BAObE8G
nsGVwKzNqCvHeWgJsqhjS3C6gvSlV3tEm9XmF6AQDXucIyVfoBwoyMh2h6cSn/ATn5QJb35pgo+ivp
3jsMAg==
Content-Type: multipart/mixed;
boundary="BOUNDARY"
Date: DATE
To: <bob@example.net>
Subject: Message from alice@example.org
References: <MESSAGE_ID@localhost>
Chat-Version: 1.0
--BOUNDARY
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
Hello!
--BOUNDARY
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="foo.bar"
Content-Transfer-Encoding: base64
Y29udGVudA==
--BOUNDARY--
"#
.replace("\n", "\r\n");
assert_eq!(
unencrypted, expected,
"---------------- Actual: ----------------
{unencrypted}
-----------------------------------------
actual (debug print): {unencrypted:?}
expected (debug print): {expected:?}"
);
Ok(())
}
/// Normalize the parts of the message that vary between runs
/// (MIME boundary, Date, Message-ID)
async fn normalized_payload(sent: SentMessage<'_>) -> String {
let rfc724_mid = sent.load_from_db().await.rfc724_mid;
let mut payload = sent.payload;
if let Some(boundary) = payload
.split_once("boundary=\"")
.and_then(|(_, rest)| rest.split_once('"'))
.map(|(b, _)| b)
{
payload = payload.replace(boundary, "BOUNDARY");
}
payload = payload.replace(&rfc724_mid, "MESSAGE_ID@localhost");
payload = regex!(r"Date:[^\r\n]*")
.replace(&payload, "Date: DATE")
.to_string();
payload
}
+7 -10
View File
@@ -294,11 +294,7 @@ impl MimeMessage {
&mut wants_mdn,
&mail,
);
headers_removed.extend(
headers
.extract_if(|k, _v| is_hidden(k))
.map(|(k, _v)| k.to_string()),
);
headers_removed.extend(headers.extract_if(|k, _v| is_hidden(k)).map(|(k, _v)| k));
// Parse hidden headers.
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
@@ -935,9 +931,10 @@ impl MimeMessage {
// See if an MDN is requested from the other side
if self.decryption_error.is_none()
&& (!self.parts.is_empty() || matches!(&self.pre_message, PreMessageMode::Pre { .. }))
&& self.wants_mdn
&& self.incoming
&& let Some(part) = self.parts.last_mut()
&& self.wants_mdn
&& self.was_encrypted() // Do not send MDNs for unencrypted messages.
&& self.incoming
&& let Some(part) = self.parts.last_mut()
{
part.param.set_int(Param::WantsMdn, 1);
}
@@ -951,7 +948,7 @@ impl MimeMessage {
typ: Viewtype::Text,
..Default::default()
};
if self.wants_mdn && self.incoming {
if self.wants_mdn && self.was_encrypted() && self.incoming {
part.param.set_int(Param::WantsMdn, 1);
}
if let Some(ref subject) = self.get_subject()
@@ -1751,7 +1748,7 @@ impl MimeMessage {
headers_removed.extend(
headers
.extract_if(|k, _v| has_header_protection || is_protected(k))
.map(|(k, _v)| k.to_string()),
.map(|(k, _v)| k),
);
if has_header_protection {
+1 -1
View File
@@ -46,7 +46,7 @@ use crate::mimeparser::SystemMessage;
/// The length of an ed25519 `PublicKey`, in bytes.
const PUBLIC_KEY_LENGTH: usize = 32;
const PUBLIC_KEY_STUB: &[u8] = "static_string".as_bytes();
const PUBLIC_KEY_STUB: &[u8] = b"static_string";
/// Store Iroh peer channels for the context.
#[derive(Debug)]
+10 -10
View File
@@ -86,14 +86,14 @@ async fn test_can_communicate() {
.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(alice, alice_webxdc.id, "alice -> bob".as_bytes().to_vec())
.send_webxdc_realtime_data(alice, alice_webxdc.id, b"alice -> bob".to_vec())
.await
.unwrap();
loop {
let event = bob.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "alice -> bob".as_bytes() {
if data == b"alice -> bob" {
break;
} else {
panic!(
@@ -107,14 +107,14 @@ async fn test_can_communicate() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice".as_bytes().to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice".to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "bob -> alice".as_bytes() {
if data == b"bob -> alice" {
break;
} else {
panic!(
@@ -149,14 +149,14 @@ async fn test_can_communicate() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice 2".as_bytes().to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice 2".to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "bob -> alice 2".as_bytes() {
if data == b"bob -> alice 2" {
break;
} else {
panic!(
@@ -314,14 +314,14 @@ async fn test_can_reconnect() {
.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(alice, alice_webxdc.id, "alice -> bob".as_bytes().to_vec())
.send_webxdc_realtime_data(alice, alice_webxdc.id, b"alice -> bob".to_vec())
.await
.unwrap();
loop {
let event = bob.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "alice -> bob".as_bytes() {
if data == b"alice -> bob" {
break;
} else {
panic!(
@@ -373,14 +373,14 @@ async fn test_can_reconnect() {
bob.get_or_try_init_peer_channel()
.await
.unwrap()
.send_webxdc_realtime_data(bob, bob_webxdc.id, "bob -> alice".as_bytes().to_vec())
.send_webxdc_realtime_data(bob, bob_webxdc.id, b"bob -> alice".to_vec())
.await
.unwrap();
loop {
let event = alice.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == "bob -> alice".as_bytes() {
if data == b"bob -> alice" {
break;
} else {
panic!(
+1 -1
View File
@@ -401,7 +401,7 @@ pub fn merge_openpgp_certificates(
// such as Alice's key in `test-data/key/alice-secret.asc`.
let best_user: Option<SignedUser> = old_users
.into_iter()
.chain(new_users.clone())
.chain(new_users)
.filter_map(|SignedUser { id, signatures }| {
// Select the best signature for each User ID.
// If User ID has no valid signatures, it is filtered out.
+1 -1
View File
@@ -384,7 +384,7 @@ fn test_merge_openpgp_certificates() {
// Cannot merge certificates with different primary key.
assert!(merge_openpgp_certificates(alice.clone(), bob.clone()).is_err());
assert!(merge_openpgp_certificates(bob.clone(), alice.clone()).is_err());
assert!(merge_openpgp_certificates(bob, alice).is_err());
}
/// Test PQC support.
+6 -223
View File
@@ -13,6 +13,7 @@ use anyhow::{Result, ensure};
use crate::chat::{ChatId, send_msg};
use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
use crate::log::warn;
use crate::message::{Message, MessageState, MsgId, Viewtype};
use crate::mimeparser::SystemMessage;
@@ -78,7 +79,11 @@ async fn update_pinned_state_in_db(
(new_pinned_state, msg.id),
)
.await?;
context.emit_msgs_changed(msg.chat_id, msg.id);
context.emit_event(EventType::PinnedMessagesChanged {
chat_id: msg.chat_id,
});
Ok(())
}
@@ -140,226 +145,4 @@ pub(crate) async fn handle_pinned_state_from_wire(
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::{ChatItem, add_info_msg, create_broadcast, get_chat_msgs};
use crate::config::Config;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{TestContextManager, sync};
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_pinned_messages() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await; // Alice's second device
let bob = &tcm.bob().await;
alice.set_config_bool(Config::SyncMsgs, true).await?;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
// Alice creates all chat types upfront, with Bob as member if possible
let single_chat_id = alice.create_chat(bob).await.id;
let group_chat_id = alice.create_group_with_members("Group", &[bob]).await;
let broadcast_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(broadcast_chat_id)).await?;
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let self_chat_id = alice.get_self_chat().await.id;
sync(alice, alice2).await;
for alice_chat_id in [
single_chat_id,
group_chat_id,
broadcast_chat_id,
self_chat_id,
] {
let pinned = get_pinned_messages(alice, alice_chat_id).await?;
assert!(pinned.is_empty());
// Alice sends message "Foo" and pins it
let sent1 = alice.send_text(alice_chat_id, "Foo").await;
let msg1 = sent1.load_from_db().await;
assert!(!msg1.is_pinned());
set_pinned_state(alice, msg1.id, true).await?;
let sent2 = alice.pop_sent_msg().await;
assert!(sent1.load_from_db().await.is_pinned());
let info_msg = sent2.load_from_db().await;
assert!(info_msg.is_info());
assert!(!info_msg.hidden);
assert_eq!(info_msg.get_info_type(), SystemMessage::MessagePinned);
assert!(info_msg.get_info_contact_id(alice).await?.is_none()); // contact not needed, tapping shall jump to message
let pinned = get_pinned_messages(alice, alice_chat_id).await?;
assert_eq!(pinned.len(), 1);
assert_eq!(pinned[0], msg1.id);
// Pinning an info message does not work
assert!(set_pinned_state(alice, info_msg.id, true).await.is_err());
// Unpin the initially pinned message.
// Before, send another message "Bar". To test, no visible info message is added this time,
let sent3 = alice.send_text(alice_chat_id, "Bar").await;
set_pinned_state(alice, msg1.id, false).await?;
let sent4 = alice.pop_sent_msg().await;
assert!(!sent1.load_from_db().await.is_pinned());
let pinned = get_pinned_messages(alice, alice_chat_id).await?;
assert!(pinned.is_empty());
let msg3 = sent3.load_from_db().await;
assert!(!msg3.is_info());
assert!(!msg3.is_pinned());
assert_eq!(alice.get_last_msg_id_in(msg3.chat_id).await, msg3.id); // last message is still "Bar", not an info message
if alice_chat_id != self_chat_id {
// Bob receives message "Foo"
let msg1 = bob.recv_msg(&sent1).await;
assert!(!msg1.is_pinned());
let pinned = get_pinned_messages(bob, msg1.chat_id).await?;
assert!(pinned.is_empty());
// Bob receives info message to pin "Foo"
bob.recv_msg(&sent2).await;
assert!(Message::load_from_db(bob, msg1.id).await?.is_pinned());
let pinned = get_pinned_messages(bob, msg1.chat_id).await?;
assert_eq!(pinned.len(), 1);
assert_eq!(pinned[0], msg1.id);
let info_msg =
Message::load_from_db(bob, bob.get_last_msg_id_in(msg1.chat_id).await).await?;
assert!(info_msg.is_info());
assert!(!info_msg.hidden);
assert_eq!(info_msg.get_info_type(), SystemMessage::MessagePinned);
assert!(info_msg.get_info_contact_id(bob).await?.is_none());
// Bob receives message "Bar" and hidden message to unpin message "Foo"
bob.recv_msg(&sent3).await;
bob.recv_msg_trash(&sent4).await;
assert!(!Message::load_from_db(bob, msg1.id).await?.is_pinned());
let pinned = get_pinned_messages(bob, msg1.chat_id).await?;
assert!(pinned.is_empty());
let no_info_msg =
Message::load_from_db(bob, bob.get_last_msg_id_in(msg1.chat_id).await).await?;
assert!(!no_info_msg.is_info());
assert_eq!(no_info_msg.text, "Bar");
}
// Alice's second device receives all four messages and ends up in the same state
let msg1 = alice2.recv_msg(&sent1).await;
alice2.recv_msg(&sent2).await;
assert!(Message::load_from_db(alice2, msg1.id).await?.is_pinned());
alice2.recv_msg(&sent3).await;
alice2.recv_msg_trash(&sent4).await;
assert!(!Message::load_from_db(alice2, msg1.id).await?.is_pinned());
let no_info_msg =
Message::load_from_db(alice2, alice2.get_last_msg_id_in(msg1.chat_id).await)
.await?;
assert!(!no_info_msg.is_info());
assert_eq!(no_info_msg.text, "Bar");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_pinned_messages_order() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let chat_id = alice.get_self_chat().await.id;
let boilerplate_msg_count = get_chat_msgs(alice, chat_id).await?.len();
// create three messages, sent1 and sent2 have different timestamp, sent2 and sent3 may differ by ID only
let sent1 = alice.send_text(chat_id, "1").await;
tokio::time::sleep(Duration::from_millis(1100)).await;
let sent2 = alice.send_text(chat_id, "2").await;
let sent3 = alice.send_text(chat_id, "3").await;
// get_chat_msgs() start with the oldest message
let chat_msgs = get_chat_msgs(alice, chat_id).await?;
let msg_ids: Vec<_> = chat_msgs
.into_iter()
.filter_map(|item| match item {
ChatItem::Message { msg_id } => Some(msg_id),
ChatItem::DayMarker { .. } => None,
})
.collect();
assert_eq!(
&msg_ids[boilerplate_msg_count..],
&[
sent1.sender_msg_id,
sent2.sender_msg_id,
sent3.sender_msg_id
]
);
// get_pinned_messages() has the same order, also starting with the oldest message
set_pinned_state(alice, sent1.sender_msg_id, true).await?;
set_pinned_state(alice, sent2.sender_msg_id, true).await?;
set_pinned_state(alice, sent3.sender_msg_id, true).await?;
let pinned = get_pinned_messages(alice, chat_id).await?;
assert_eq!(pinned.len(), 3);
assert_eq!(pinned[0], sent1.sender_msg_id);
assert_eq!(pinned[1], sent2.sender_msg_id);
assert_eq!(pinned[2], sent3.sender_msg_id);
// order of pinning does not affect the order of pinned messages.
// this is to keep scrolling direction of chat bubbles and pinned banner scrollbar in sync,
// and not jumping wildly around.
// this is also what most other messengers are doing.
set_pinned_state(alice, sent1.sender_msg_id, false).await?;
set_pinned_state(alice, sent2.sender_msg_id, false).await?;
set_pinned_state(alice, sent3.sender_msg_id, false).await?;
let pinned = get_pinned_messages(alice, chat_id).await?;
assert_eq!(pinned.len(), 0);
set_pinned_state(alice, sent3.sender_msg_id, true).await?;
set_pinned_state(alice, sent2.sender_msg_id, true).await?;
set_pinned_state(alice, sent1.sender_msg_id, true).await?;
let pinned = get_pinned_messages(alice, chat_id).await?;
assert_eq!(pinned.len(), 3);
assert_eq!(pinned[0], sent1.sender_msg_id);
assert_eq!(pinned[1], sent2.sender_msg_id);
assert_eq!(pinned[2], sent3.sender_msg_id);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_handle_pinned_state_from_wire() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let chat_id = alice.get_self_chat().await.id;
let sent1 = alice.send_text(chat_id, "pinnable").await;
let msg1 = sent1.load_from_db().await;
assert!(is_pinnable(&msg1));
assert!(
handle_pinned_state_from_wire(alice, &msg1, true)
.await
.is_ok()
);
// For not-pinnable messages, handle_pinned_state_from_wire() logs a warning and returns "ok".
// otherwise if there is an incompatibility in which messages are treated as "pinnable",
// this error will bubble up and user will get a device message saying "please report a bug".
let msg2_id = add_info_msg(alice, chat_id, "not pinnable").await?;
let msg2 = Message::load_from_db(alice, msg2_id).await?;
assert!(!is_pinnable(&msg2));
assert!(
handle_pinned_state_from_wire(alice, &msg2, true)
.await
.is_ok()
);
alice.assert_warn("Message is not pinnable").await;
Ok(())
}
}
mod pinned_messages_tests;
@@ -0,0 +1,309 @@
use super::*;
use crate::chat::{ChatItem, add_info_msg, create_broadcast, get_chat_msgs};
use crate::config::Config;
use crate::ephemeral;
use crate::message;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{TestContext, TestContextManager, sync};
use crate::tools::{SystemTime, time};
use std::time::Duration;
/// Waits for a PinnedMessagesChanged for a given `chat_id`.
///
/// Panics if event arrives for the wrong `chat_id`.
async fn expect_pinned_message_event(context: &TestContext, chat_id: ChatId) {
let EventType::PinnedMessagesChanged {
chat_id: event_chat_id,
} = context
.evtracker
.get_matching(|evt| matches!(evt, EventType::PinnedMessagesChanged { .. }))
.await
else {
unreachable!();
};
assert_eq!(event_chat_id, chat_id);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_pinned_messages() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await; // Alice's second device
let bob = &tcm.bob().await;
alice.set_config_bool(Config::SyncMsgs, true).await?;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
// Alice creates all chat types upfront, with Bob as member if possible
let single_chat_id = alice.create_chat(bob).await.id;
let group_chat_id = alice.create_group_with_members("Group", &[bob]).await;
let broadcast_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(broadcast_chat_id)).await?;
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let self_chat_id = alice.get_self_chat().await.id;
sync(alice, alice2).await;
for alice_chat_id in [
single_chat_id,
group_chat_id,
broadcast_chat_id,
self_chat_id,
] {
let pinned = get_pinned_messages(alice, alice_chat_id).await?;
assert!(pinned.is_empty());
// Alice sends message "Foo" and pins it
let sent1 = alice.send_text(alice_chat_id, "Foo").await;
let msg1 = sent1.load_from_db().await;
assert!(!msg1.is_pinned());
set_pinned_state(alice, msg1.id, true).await?;
let sent2 = alice.pop_sent_msg().await;
expect_pinned_message_event(alice, msg1.chat_id).await;
assert!(sent1.load_from_db().await.is_pinned());
let info_msg = sent2.load_from_db().await;
assert!(info_msg.is_info());
assert!(!info_msg.hidden);
assert_eq!(info_msg.get_info_type(), SystemMessage::MessagePinned);
assert!(info_msg.get_info_contact_id(alice).await?.is_none()); // contact not needed, tapping shall jump to message
let pinned = get_pinned_messages(alice, alice_chat_id).await?;
assert_eq!(pinned.len(), 1);
assert_eq!(pinned[0], msg1.id);
// Pinning an info message does not work
assert!(set_pinned_state(alice, info_msg.id, true).await.is_err());
// Unpin the initially pinned message.
// Before, send another message "Bar". To test, no visible info message is added this time,
let sent3 = alice.send_text(alice_chat_id, "Bar").await;
set_pinned_state(alice, msg1.id, false).await?;
let sent4 = alice.pop_sent_msg().await;
assert!(!sent1.load_from_db().await.is_pinned());
expect_pinned_message_event(alice, msg1.chat_id).await;
let pinned = get_pinned_messages(alice, alice_chat_id).await?;
assert!(pinned.is_empty());
let msg3 = sent3.load_from_db().await;
assert!(!msg3.is_info());
assert!(!msg3.is_pinned());
assert_eq!(alice.get_last_msg_id_in(msg3.chat_id).await, msg3.id); // last message is still "Bar", not an info message
if alice_chat_id != self_chat_id {
// Bob receives message "Foo"
let msg1 = bob.recv_msg(&sent1).await;
assert!(!msg1.is_pinned());
let pinned = get_pinned_messages(bob, msg1.chat_id).await?;
assert!(pinned.is_empty());
// Bob receives info message to pin "Foo"
bob.recv_msg(&sent2).await;
expect_pinned_message_event(bob, msg1.chat_id).await;
assert!(Message::load_from_db(bob, msg1.id).await?.is_pinned());
let pinned = get_pinned_messages(bob, msg1.chat_id).await?;
assert_eq!(pinned.len(), 1);
assert_eq!(pinned[0], msg1.id);
let info_msg =
Message::load_from_db(bob, bob.get_last_msg_id_in(msg1.chat_id).await).await?;
assert!(info_msg.is_info());
assert!(!info_msg.hidden);
assert_eq!(info_msg.get_info_type(), SystemMessage::MessagePinned);
assert!(info_msg.get_info_contact_id(bob).await?.is_none());
// Bob receives message "Bar" and hidden message to unpin message "Foo"
bob.recv_msg(&sent3).await;
bob.recv_msg_trash(&sent4).await;
expect_pinned_message_event(bob, msg1.chat_id).await;
assert!(!Message::load_from_db(bob, msg1.id).await?.is_pinned());
let pinned = get_pinned_messages(bob, msg1.chat_id).await?;
assert!(pinned.is_empty());
let no_info_msg =
Message::load_from_db(bob, bob.get_last_msg_id_in(msg1.chat_id).await).await?;
assert!(!no_info_msg.is_info());
assert_eq!(no_info_msg.text, "Bar");
}
// Alice's second device receives all four messages and ends up in the same state
let msg1 = alice2.recv_msg(&sent1).await;
alice2.recv_msg(&sent2).await;
expect_pinned_message_event(alice2, msg1.chat_id).await;
assert!(Message::load_from_db(alice2, msg1.id).await?.is_pinned());
alice2.recv_msg(&sent3).await;
alice2.recv_msg_trash(&sent4).await;
expect_pinned_message_event(alice2, msg1.chat_id).await;
assert!(!Message::load_from_db(alice2, msg1.id).await?.is_pinned());
let no_info_msg =
Message::load_from_db(alice2, alice2.get_last_msg_id_in(msg1.chat_id).await).await?;
assert!(!no_info_msg.is_info());
assert_eq!(no_info_msg.text, "Bar");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_pinned_messages_order() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let chat_id = alice.get_self_chat().await.id;
let boilerplate_msg_count = get_chat_msgs(alice, chat_id).await?.len();
// create three messages, sent1 and sent2 have different timestamp, sent2 and sent3 may differ by ID only
let sent1 = alice.send_text(chat_id, "1").await;
tokio::time::sleep(Duration::from_millis(1100)).await;
let sent2 = alice.send_text(chat_id, "2").await;
let sent3 = alice.send_text(chat_id, "3").await;
// get_chat_msgs() start with the oldest message
let chat_msgs = get_chat_msgs(alice, chat_id).await?;
let msg_ids: Vec<_> = chat_msgs
.into_iter()
.filter_map(|item| match item {
ChatItem::Message { msg_id } => Some(msg_id),
ChatItem::DayMarker { .. } => None,
})
.collect();
assert_eq!(
&msg_ids[boilerplate_msg_count..],
&[
sent1.sender_msg_id,
sent2.sender_msg_id,
sent3.sender_msg_id
]
);
// get_pinned_messages() has the same order, also starting with the oldest message
set_pinned_state(alice, sent1.sender_msg_id, true).await?;
set_pinned_state(alice, sent2.sender_msg_id, true).await?;
set_pinned_state(alice, sent3.sender_msg_id, true).await?;
let pinned = get_pinned_messages(alice, chat_id).await?;
assert_eq!(pinned.len(), 3);
assert_eq!(pinned[0], sent1.sender_msg_id);
assert_eq!(pinned[1], sent2.sender_msg_id);
assert_eq!(pinned[2], sent3.sender_msg_id);
// order of pinning does not affect the order of pinned messages.
// this is to keep scrolling direction of chat bubbles and pinned banner scrollbar in sync,
// and not jumping wildly around.
// this is also what most other messengers are doing.
set_pinned_state(alice, sent1.sender_msg_id, false).await?;
set_pinned_state(alice, sent2.sender_msg_id, false).await?;
set_pinned_state(alice, sent3.sender_msg_id, false).await?;
let pinned = get_pinned_messages(alice, chat_id).await?;
assert_eq!(pinned.len(), 0);
set_pinned_state(alice, sent3.sender_msg_id, true).await?;
set_pinned_state(alice, sent2.sender_msg_id, true).await?;
set_pinned_state(alice, sent1.sender_msg_id, true).await?;
let pinned = get_pinned_messages(alice, chat_id).await?;
assert_eq!(pinned.len(), 3);
assert_eq!(pinned[0], sent1.sender_msg_id);
assert_eq!(pinned[1], sent2.sender_msg_id);
assert_eq!(pinned[2], sent3.sender_msg_id);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_handle_pinned_state_from_wire() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let chat_id = alice.get_self_chat().await.id;
let sent1 = alice.send_text(chat_id, "pinnable").await;
let msg1 = sent1.load_from_db().await;
assert!(is_pinnable(&msg1));
assert!(
handle_pinned_state_from_wire(alice, &msg1, true)
.await
.is_ok()
);
// For not-pinnable messages, handle_pinned_state_from_wire() logs a warning and returns "ok".
// otherwise if there is an incompatibility in which messages are treated as "pinnable",
// this error will bubble up and user will get a device message saying "please report a bug".
let msg2_id = add_info_msg(alice, chat_id, "not pinnable").await?;
let msg2 = Message::load_from_db(alice, msg2_id).await?;
assert!(!is_pinnable(&msg2));
assert!(
handle_pinned_state_from_wire(alice, &msg2, true)
.await
.is_ok()
);
alice.assert_warn("Message is not pinnable").await;
Ok(())
}
/// Tests that disappearing pinned message expires and emits `PinnedMessagesChanged` event.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_pinned_message() -> 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;
// Alice sends ephemeral timer in single chat with Bob.
alice_chat_id
.set_ephemeral_timer(alice, ephemeral::Timer::from_u32(60))
.await?;
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
// Alice sends "Hello!" message to Bob.
let bob_msg = tcm.send_recv_accept(alice, bob, "Hello!").await;
let bob_chat_id = bob_msg.chat_id;
// Bob reads the message, so the timer starts.
message::markseen_msgs(bob, vec![bob_msg.id]).await?;
// Bob pins "Hello!" message received from Alice.
set_pinned_state(bob, bob_msg.id, true).await?;
expect_pinned_message_event(bob, bob_chat_id).await;
let pinned = get_pinned_messages(bob, bob_chat_id).await?;
assert_eq!(pinned.len(), 1);
// Wait until the message expires.
SystemTime::shift(Duration::from_secs(100));
ephemeral::delete_expired_messages(bob, time()).await?;
expect_pinned_message_event(bob, bob_chat_id).await;
let pinned = get_pinned_messages(bob, bob_chat_id).await?;
assert!(pinned.is_empty());
Ok(())
}
/// Tests that `PinnedMessagesChanged` event is emitted when pinned message is deleted.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_pinned_message() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let bob_msg = tcm.send_recv_accept(alice, bob, "Hello!").await;
let bob_chat_id = bob_msg.chat_id;
// Bob pins "Hello!" message received from Alice.
set_pinned_state(bob, bob_msg.id, true).await?;
expect_pinned_message_event(bob, bob_chat_id).await;
let pinned = get_pinned_messages(bob, bob_chat_id).await?;
assert_eq!(pinned.len(), 1);
message::delete_msgs(bob, &[bob_msg.id]).await?;
expect_pinned_message_event(bob, bob_chat_id).await;
let pinned = get_pinned_messages(bob, bob_chat_id).await?;
assert!(pinned.is_empty());
Ok(())
}
+5 -9
View File
@@ -1,6 +1,6 @@
//! Handle plain text together with some attributes.
use std::sync::LazyLock;
use regex::regex;
use crate::simplify::remove_message_footer;
@@ -25,12 +25,8 @@ impl PlainText {
/// Convert plain text to HTML.
/// The function handles quotes, links, fixed and floating text paragraphs.
pub fn to_html(&self) -> String {
static LINKIFY_MAIL_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\b([\w.\-+]+@[\w.\-]+)\b").unwrap());
static LINKIFY_URL_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\b((http|https|ftp|ftps):[\w.,:;$/@!?&%\-~=#+]+)").unwrap()
});
let linkify_mail_re = regex!(r"\b([\w.\-+]+@[\w.\-]+)\b");
let linkify_url_re = regex!(r"\b((http|https|ftp|ftps):[\w.,:;$/@!?&%\-~=#+]+)");
let lines: Vec<&str> = self.text.lines().collect();
let (lines, _footer) = remove_message_footer(&lines);
@@ -52,12 +48,12 @@ impl PlainText {
// to avoid double encoding, we escape our html-entities by \r that must not be used in the string elsewhere.
let line = line.to_string().replace('\r', "");
let mut line = LINKIFY_MAIL_RE
let mut line = linkify_mail_re
.replace_all(&line, "\rLTa href=\rQUOTmailto:$1\rQUOT\rGT$1\rLT/a\rGT")
.as_ref()
.to_string();
line = LINKIFY_URL_RE
line = linkify_url_re
.replace_all(&line, "\rLTa href=\rQUOT$1\rQUOT\rGT$1\rLT/a\rGT")
.as_ref()
.to_string();
+8 -10
View File
@@ -2,13 +2,13 @@
mod dclogin_scheme;
use std::collections::BTreeMap;
use std::sync::LazyLock;
use anyhow::{Context as _, Result, anyhow, bail, ensure};
pub use dclogin_scheme::LoginOptions;
pub(crate) use dclogin_scheme::login_param_from_login_qr;
use deltachat_contact_tools::{ContactAddress, addr_normalize, may_be_valid_addr};
use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, percent_encode};
use regex::regex;
use serde::Deserialize;
use crate::autorelay::login_param_from_host;
@@ -764,7 +764,7 @@ fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
let addr = server_config.addr();
let host = addr.host().to_string();
let host = addr.host();
let port = addr.port();
Ok(Qr::Proxy {
url: qr.to_string(),
@@ -1071,16 +1071,11 @@ async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
Qr::from_address(context, name, &addr, None).await
}
static VCARD_NAME_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
static VCARD_EMAIL_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
/// Extract address for the vcard scheme.
///
/// Scheme: `VCARD:BEGIN\nN:last name;first name;...;\nEMAIL;<type>:addr...;`
async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
let name = VCARD_NAME_RE
let name = regex!(r"(?m)^N:([^;]*);([^;\n]*)")
.captures(qr)
.and_then(|caps| {
let last_name = caps.get(1)?.as_str().trim();
@@ -1090,7 +1085,10 @@ async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
})
.unwrap_or_default();
let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
let addr = if let Some(cap) = regex!(r"(?m)^EMAIL([^:\n]*):([^;\n]*)")
.captures(qr)
.and_then(|caps| caps.get(2))
{
normalize_address(cap.as_str().trim())?
} else {
bail!("Bad e-mail address");
@@ -1124,7 +1122,7 @@ fn normalize_address(addr: &str) -> Result<String> {
ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
Ok(new_addr.to_string())
Ok(new_addr)
}
#[cfg(test)]
+1 -1
View File
@@ -120,7 +120,7 @@ pub(super) fn decode_login(qr: &str) -> Result<Qr> {
};
Ok(Qr::Login {
address: addr.to_owned(),
address: addr,
options,
})
} else {
+1 -1
View File
@@ -165,7 +165,7 @@ async fn set_msg_id_reaction(
.await?;
if chat
.param
.update_timestamp(Param::LastReactionTimestamp, timestamp)?
.update_timestamp(Param::LastReactionTimestamp, timestamp)
{
chat.param
.set_i64(Param::LastReactionMsgId, i64::from(msg_id.to_u32()));
+18 -7
View File
@@ -483,7 +483,7 @@ pub(crate) async fn receive_imf_inner(
}
let trash = || async {
let msg_ids = vec![insert_tombstone(context, rfc724_mid).await?];
let msg_ids = vec![insert_tombstone(context, rfc724_mid, false).await?];
Ok(Some(ReceivedMsg {
chat_id: ChatId::TRASH,
state: MessageState::Undefined,
@@ -668,14 +668,15 @@ pub(crate) async fn receive_imf_inner(
match res {
securejoin::HandshakeMessage::Done | securejoin::HandshakeMessage::Ignore => {
let msg_id = insert_tombstone(context, rfc724_mid).await?;
let needs_delete_job = res == securejoin::HandshakeMessage::Done;
let msg_id = insert_tombstone(context, rfc724_mid, needs_delete_job).await?;
received_msg = Some(ReceivedMsg {
chat_id: ChatId::TRASH,
state: MessageState::InSeen,
hidden: false,
sort_timestamp: mime_parser.timestamp_sent,
msg_ids: vec![msg_id],
needs_delete_job: res == securejoin::HandshakeMessage::Done,
needs_delete_job,
});
}
securejoin::HandshakeMessage::Propagate => {
@@ -2311,7 +2312,7 @@ INSERT INTO msgs
// This way, `LastSubject` actually refers to the most recent message _shown_ in the chat.
if chat
.param
.update_timestamp(Param::SubjectTimestamp, sort_timestamp)?
.update_timestamp(Param::SubjectTimestamp, sort_timestamp)
{
// write the last subject even if empty -
// otherwise a reply may get an outdated subject.
@@ -2392,6 +2393,7 @@ async fn handle_edit_delete(
}
let mut modified_chat_ids = BTreeSet::new();
let mut pinned_messages_changed_chat_ids = BTreeSet::new();
let mut msg_ids = Vec::new();
let rfc724_mid_vec: Vec<&str> = rfc724_mid_list.split_whitespace().collect();
@@ -2400,7 +2402,7 @@ async fn handle_edit_delete(
let Some(msg_id) = message::rfc724_mid_exists(context, rfc724_mid).await? else {
warn!(context, "Delete message: {rfc724_mid:?} not found.");
// Insert a tombstone so that the message will be ignored if it arrives later within a period specified in prune_tombstones().
insert_tombstone(context, rfc724_mid).await?;
insert_tombstone(context, rfc724_mid, false).await?;
continue;
};
@@ -2416,8 +2418,17 @@ async fn handle_edit_delete(
message::delete_msg_locally(context, &msg).await?;
msg_ids.push(msg.id);
modified_chat_ids.insert(msg.chat_id);
if msg.is_pinned() {
pinned_messages_changed_chat_ids.insert(msg.chat_id);
}
}
message::delete_msgs_locally_done(context, &msg_ids, modified_chat_ids).await?;
message::delete_msgs_locally_done(
context,
&msg_ids,
modified_chat_ids,
pinned_messages_changed_chat_ids,
)
.await?;
}
Ok(())
}
@@ -3404,7 +3415,7 @@ async fn apply_chat_name_avatar_and_description_changes(
&& is_from_in_chat
&& chat
.param
.update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)?
.update_timestamp(Param::AvatarTimestamp, mime_parser.timestamp_sent)
{
info!(context, "Group-avatar change for {}.", chat.id);
match avatar_action {
+3 -1
View File
@@ -416,7 +416,9 @@ async fn test_escaped_from() {
);
let msg = get_chat_msg(&t, chat_id, 0, 1).await;
assert_eq!(msg.text, "hello");
assert_eq!(msg.param.get_int(Param::WantsMdn).unwrap(), 1);
// MDN request from unencrypted message is ignored.
assert!(msg.param.get_int(Param::WantsMdn).is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+14 -15
View File
@@ -33,14 +33,14 @@ pub(crate) use qrinvite::QrInvite;
use crate::token::Namespace;
const DISALLOWED_CHARACTERS: &AsciiSet = &NON_ALPHANUMERIC_WITHOUT_DOT.remove(b'_');
const DISALLOWED_CHARACTERS: &AsciiSet = &NON_ALPHANUMERIC_WITHOUT_DOT.remove(b'_').remove(b'@');
fn inviter_progress(
context: &Context,
contact_id: ContactId,
chat_id: ChatId,
chat_type: Chattype,
) -> Result<()> {
) {
// No other values are used.
let progress = 1000;
context.emit_event(EventType::SecurejoinInviterProgress {
@@ -49,8 +49,6 @@ fn inviter_progress(
chat_type,
progress,
});
Ok(())
}
/// Shorten name to max. `length` characters.
@@ -124,18 +122,19 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
let self_addr = context.get_primary_self_addr().await?;
let self_addr_urlencoded = utf8_percent_encode(&self_addr, DISALLOWED_CHARACTERS).to_string();
let r_param = context
let encoded_extra_relays: Vec<String> = context
.get_self_addrs()
.await?
.into_iter()
.filter(|addr| *addr != self_addr)
.reduce(|acc, addr| {
format!(
"{acc},{}",
utf8_percent_encode(&addr, DISALLOWED_CHARACTERS)
)
})
.map_or(String::default(), |addrs| format!("&r={addrs}"));
.map(|addr| utf8_percent_encode(&addr, DISALLOWED_CHARACTERS).to_string())
.collect();
let r_param = if encoded_extra_relays.is_empty() {
"".to_string()
} else {
format!("&r={}", encoded_extra_relays.join(","))
};
let self_name = context
.get_config(Config::Displayname)
@@ -659,7 +658,7 @@ pub(crate) async fn handle_securejoin_handshake(
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
}
inviter_progress(context, contact_id, joining_chat_id, chat.typ)?;
inviter_progress(context, contact_id, joining_chat_id, chat.typ);
// IMAP-delete the message to avoid handling it by another device and adding the
// member twice. Another device will know the member's key from Autocrypt-Gossip.
Ok(HandshakeMessage::Done)
@@ -670,7 +669,7 @@ pub(crate) async fn handle_securejoin_handshake(
.await
.context("failed sending vc-contact-confirm message")?;
inviter_progress(context, contact_id, chat_id, Chattype::Single)?;
inviter_progress(context, contact_id, chat_id, Chattype::Single);
Ok(HandshakeMessage::Ignore) // "Done" would delete the message and break multi-device (the key from Autocrypt-header is needed)
}
}
@@ -817,7 +816,7 @@ pub(crate) async fn observe_securejoin_on_other_device(
// and tests which don't care about the chat ID,
// so we pass invalid chat ID here.
let chat_id = ChatId::new(0);
inviter_progress(context, contact_id, chat_id, chat_type)?;
inviter_progress(context, contact_id, chat_id, chat_type);
}
if matches!(step, SecureJoinStep::MemberAdded) {
+65
View File
@@ -6,6 +6,7 @@ use crate::chat::{CantSendReason, ChatId, add_contact_to_chat, remove_contact_fr
use crate::chatlist::Chatlist;
use crate::constants::Chattype;
use crate::key::self_fingerprint;
use crate::message::rfc724_mid_exists_ext;
use crate::qr::Qr;
use crate::receive_imf::receive_imf;
use crate::stock_str::{self, messages_e2ee_info_msg};
@@ -13,6 +14,7 @@ use crate::test_utils::{
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
TimeShiftFalsePositiveNote, get_chat_msg, sync,
};
use crate::transport::add_pseudo_transport;
#[derive(PartialEq)]
enum SetupContactCase {
@@ -1142,6 +1144,43 @@ async fn test_get_securejoin_qr_name_is_last() -> Result<()> {
Ok(())
}
/// Test that addresses in QR codes are percent-encoded.
/// `@` should not be encoded unnecessarily,
/// since this would just make the QR code longer.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_securejoin_qr_encoding() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// `@` in email addresses must not be percent-encoded:
add_pseudo_transport(alice, "asdf@example.org").await?;
// But `%` does need percent-encoding:
add_pseudo_transport(alice, "jk%l@example.net").await?;
let qr = get_securejoin_qr(alice, None).await?;
assert!(
qr.contains("a=alice@example.org"),
"{qr} doesn't contain 'a=alice@example.org'"
);
assert!(
qr.contains("r=jk%25l@example.net,asdf@example.org"),
"{qr} doesn't contain 'r=jk%25l@example.net,asdf@example.org'"
);
let qr = check_qr(bob, &qr).await?;
let Qr::AskVerifyContact { mut addrs, .. } = qr else {
unreachable!()
};
addrs.sort();
assert_eq!(
addrs,
vec!["alice@example.org", "asdf@example.org", "jk%l@example.net",]
);
Ok(())
}
/// QR codes should not get arbitrary big because of long names.
/// The truncation, however, should not let the url end with a `.`, which is a call for trouble in linkfiers.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -1525,3 +1564,29 @@ async fn test_deduplicate_member_added() -> Result<()> {
Ok(())
}
/// Tests that a handled join request is also marked as "deleted"
/// in the database, so that if a copy of the request arrives via
/// other relays in the future, then this copy will also be deleted.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_join_request_deleted_on_all_relays() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = chat::create_group(alice, "Group").await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
bob.add_or_lookup_contact_id(alice).await;
join_securejoin(bob, &qr).await?;
let request = bob.pop_sent_msg().await;
alice.recv_msg_trash(&request).await;
let rfc724_mid = Message::load_from_db(bob, request.sender_msg_id)
.await?
.rfc724_mid;
let (_, deleted) = rfc724_mid_exists_ext(alice, &rfc724_mid, "deleted=1")
.await?
.unwrap();
assert!(deleted);
Ok(())
}
+2 -2
View File
@@ -356,7 +356,7 @@ pub(crate) async fn insert_into_smtp(
queued_msg: &QueuedMail,
) -> Result<()> {
let now = tools::time();
let msg_id = message::insert_tombstone(context, rfc724_mid).await?;
let msg_id = message::insert_tombstone(context, rfc724_mid, false).await?;
context
.sql
.transaction(|transaction| queue::enqueue_mail(transaction, now, msg_id, queued_msg, None))
@@ -720,7 +720,7 @@ async fn send_mdn_rfc724_mid(
.ok()
})
.collect();
message::insert_tombstone(context, &rendered_msg.rfc724_mid).await?;
message::insert_tombstone(context, &rendered_msg.rfc724_mid, false).await?;
match smtp_send(context, &recipients, &body, smtp, None).await {
SendResult::Success => {
if !recipients.is_empty() {
+7 -1
View File
@@ -14,7 +14,7 @@ use crate::config::Config;
use crate::context::Context;
use crate::debug_logging::set_debug_logging_xdc;
use crate::ephemeral::start_ephemeral_timers;
use crate::imex::BLOBS_BACKUP_NAME;
use crate::imex::{self, BLOBS_BACKUP_NAME};
use crate::location;
use crate::log::{LogExt, warn};
use crate::message::MsgId;
@@ -827,6 +827,12 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
);
}
imex::maybe_readd_backup_transfer_msg(context)
.await
.context("Failed to re-add backup transfer message")
.log_err(context)
.ok();
if let Err(err) = incremental_vacuum(context).await {
warn!(context, "Failed to run incremental vacuum: {err:#}.");
}
+4 -12
View File
@@ -32,8 +32,6 @@ fn migrate_key_contacts(
context: &Context,
transaction: &mut rusqlite::Transaction<'_>,
) -> std::result::Result<(), anyhow::Error> {
info!(context, "Starting key-contact transition.");
// =============================== Step 1: ===============================
// Alter tables
transaction.execute_batch(
@@ -79,13 +77,12 @@ fn migrate_key_contacts(
.optional()
.context("Step 0")?
else {
info!(
context,
"Not yet configured, no need to migrate key-contacts"
);
// Not yet configured, no need to migrate key-contacts.
return Ok(());
};
info!(context, "Starting key-contact transition.");
// =============================== Step 2: ===============================
// Create up to 3 new contacts for every contact that has a peerstate:
// one from the Autocrypt key fingerprint, one from the verified key fingerprint,
@@ -1936,14 +1933,9 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint);
inc_and_check(&mut migration_version, 132)?;
if dbversion < migration_version {
let start = Time::now();
sql.execute_migration_transaction(|t| migrate_key_contacts(context, t), migration_version)
.await?;
info!(
context,
"key-contacts migration took {:?} in total.",
time_elapsed(&start),
);
// Schedule `msgs_to_key_contacts()`.
context
.set_config_internal(Config::LastHousekeeping, None)
+11 -1
View File
@@ -384,6 +384,7 @@ impl Context {
async fn sync_message_deletion(&self, msgs: &[String]) -> Result<()> {
let mut modified_chat_ids = BTreeSet::new();
let mut pinned_messages_changed_chat_ids = BTreeSet::new();
let mut msg_ids = Vec::new();
for rfc724_mid in msgs {
if let Some(msg_id) = message::rfc724_mid_exists(self, rfc724_mid).await? {
@@ -391,6 +392,9 @@ impl Context {
message::delete_msg_locally(self, &msg).await?;
msg_ids.push(msg.id);
modified_chat_ids.insert(msg.chat_id);
if msg.is_pinned() {
pinned_messages_changed_chat_ids.insert(msg.chat_id);
}
} else {
warn!(self, "Sync message delete: Database entry does not exist.");
}
@@ -398,7 +402,13 @@ impl Context {
warn!(self, "Sync message delete: {rfc724_mid:?} not found.");
}
}
message::delete_msgs_locally_done(self, &msg_ids, modified_chat_ids).await?;
message::delete_msgs_locally_done(
self,
&msg_ids,
modified_chat_ids,
pinned_messages_changed_chat_ids,
)
.await?;
Ok(())
}
}
+1 -26
View File
@@ -220,6 +220,7 @@ async fn maybe_warn_on_bad_time(context: &Context, now: i64, known_past_timestam
),
Some(&mut msg),
true,
time(),
)
.await
.ok();
@@ -520,32 +521,6 @@ where
}
}
pub(crate) trait ToOption<T> {
fn to_option(self) -> Option<T>;
}
impl<'a> ToOption<&'a str> for &'a String {
fn to_option(self) -> Option<&'a str> {
if self.is_empty() { None } else { Some(self) }
}
}
impl ToOption<String> for u16 {
fn to_option(self) -> Option<String> {
if self == 0 {
None
} else {
Some(self.to_string())
}
}
}
impl ToOption<String> for Option<i32> {
fn to_option(self) -> Option<String> {
match self {
None | Some(0) => None,
Some(v) => Some(v.to_string()),
}
}
}
#[expect(clippy::arithmetic_side_effects)]
pub(crate) fn remove_subject_prefix(last_subject: &str) -> String {
let subject_start = if last_subject.starts_with("Chat:") {
+13 -13
View File
@@ -27,7 +27,7 @@ impl Context {
Ok(param.parse().unwrap_or_default())
},
)?;
let update = param.update_timestamp(scope, new_timestamp)?;
let update = param.update_timestamp(scope, new_timestamp);
if update {
transaction.execute(
"UPDATE contacts SET param=? WHERE id=?",
@@ -57,7 +57,7 @@ impl ChatId {
let param: String = row.get(0)?;
Ok(param.parse().unwrap_or_default())
})?;
let update = param.update_timestamp(scope, new_timestamp)?;
let update = param.update_timestamp(scope, new_timestamp);
if update {
transaction.execute(
"UPDATE chats SET param=? WHERE id=?",
@@ -73,13 +73,13 @@ impl ChatId {
impl Params {
/// Updates a param's timestamp in memory, if reasonable.
/// Returns true if the caller shall update the settings belonging to the scope.
pub(crate) fn update_timestamp(&mut self, scope: Param, new_timestamp: i64) -> Result<bool> {
pub(crate) fn update_timestamp(&mut self, scope: Param, new_timestamp: i64) -> bool {
let old_timestamp = self.get_i64(scope).unwrap_or_default();
if new_timestamp >= old_timestamp {
self.set_i64(scope, new_timestamp);
return Ok(true);
return true;
}
Ok(false)
false
}
}
@@ -96,18 +96,18 @@ mod tests {
let mut params = Params::new();
let ts = time();
assert!(params.update_timestamp(Param::LastSubject, ts)?);
assert!(params.update_timestamp(Param::LastSubject, ts)?); // same timestamp -> update
assert!(params.update_timestamp(Param::LastSubject, ts + 10)?);
assert!(!params.update_timestamp(Param::LastSubject, ts)?); // `ts` is now too old
assert!(!params.update_timestamp(Param::LastSubject, 0)?);
assert!(params.update_timestamp(Param::LastSubject, ts));
assert!(params.update_timestamp(Param::LastSubject, ts)); // same timestamp -> update
assert!(params.update_timestamp(Param::LastSubject, ts + 10));
assert!(!params.update_timestamp(Param::LastSubject, ts)); // `ts` is now too old
assert!(!params.update_timestamp(Param::LastSubject, 0));
assert_eq!(params.get_i64(Param::LastSubject).unwrap(), ts + 10);
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)?); // stay unset -> update ...
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)?); // ... also on multiple calls
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)); // stay unset -> update ...
assert!(params.update_timestamp(Param::GroupNameTimestamp, 0)); // ... also on multiple calls
assert_eq!(params.get_i64(Param::GroupNameTimestamp).unwrap(), 0);
assert!(!params.update_timestamp(Param::AvatarTimestamp, -1)?);
assert!(!params.update_timestamp(Param::AvatarTimestamp, -1));
assert_eq!(params.get_i64(Param::AvatarTimestamp), None);
Ok(())
+3 -3
View File
@@ -350,7 +350,7 @@ impl Context {
if let Some(ref document) = status_update_item.document
&& instance
.param
.update_timestamp(Param::WebxdcDocumentTimestamp, timestamp)?
.update_timestamp(Param::WebxdcDocumentTimestamp, timestamp)
{
instance.param.set(Param::WebxdcDocument, document);
param_changed = true;
@@ -359,10 +359,10 @@ impl Context {
if let Some(ref summary) = status_update_item.summary
&& instance
.param
.update_timestamp(Param::WebxdcSummaryTimestamp, timestamp)?
.update_timestamp(Param::WebxdcSummaryTimestamp, timestamp)
{
let summary = sanitize_bidi_characters(summary);
instance.param.set(Param::WebxdcSummary, summary.clone());
instance.param.set(Param::WebxdcSummary, summary);
param_changed = true;
}
+12 -14
View File
@@ -1201,30 +1201,28 @@ async fn test_get_webxdc_blob_with_subdirs() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest() -> Result<()> {
let result = parse_webxdc_manifest(r#"key = syntax error"#.as_bytes());
let result = parse_webxdc_manifest(br#"key = syntax error"#);
assert!(result.is_err());
let manifest = parse_webxdc_manifest(r#"no_name = "no name, no icon""#.as_bytes())?;
let manifest = parse_webxdc_manifest(br#"no_name = "no name, no icon""#)?;
assert_eq!(manifest.name, None);
let manifest = parse_webxdc_manifest(r#"name = "name, no icon""#.as_bytes())?;
let manifest = parse_webxdc_manifest(br#"name = "name, no icon""#)?;
assert_eq!(manifest.name, Some("name, no icon".to_string()));
let manifest = parse_webxdc_manifest(
r#"name = "foo"
icon = "bar""#
.as_bytes(),
br#"name = "foo"
icon = "bar""#,
)?;
assert_eq!(manifest.name, Some("foo".to_string()));
let manifest = parse_webxdc_manifest(
r#"name = "foz"
br#"name = "foz"
icon = "baz"
add_item = "that should be just ignored"
[section]
sth_for_the = "future""#
.as_bytes(),
sth_for_the = "future""#,
)?;
assert_eq!(manifest.name, Some("foz".to_string()));
Ok(())
@@ -1232,13 +1230,13 @@ sth_for_the = "future""#
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest_min_api() -> Result<()> {
let manifest = parse_webxdc_manifest(r#"min_api = 3"#.as_bytes())?;
let manifest = parse_webxdc_manifest(br#"min_api = 3"#)?;
assert_eq!(manifest.min_api, Some(3));
let result = parse_webxdc_manifest(r#"min_api = "1""#.as_bytes());
let result = parse_webxdc_manifest(br#"min_api = "1""#);
assert!(result.is_err());
let result = parse_webxdc_manifest(r#"min_api = 1.2"#.as_bytes());
let result = parse_webxdc_manifest(br#"min_api = 1.2"#);
assert!(result.is_err());
Ok(())
@@ -1246,10 +1244,10 @@ async fn test_parse_webxdc_manifest_min_api() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webxdc_manifest_source_code_url() -> Result<()> {
let result = parse_webxdc_manifest(r#"source_code_url = 3"#.as_bytes());
let result = parse_webxdc_manifest(br#"source_code_url = 3"#);
assert!(result.is_err());
let manifest = parse_webxdc_manifest(r#"source_code_url = "https://foo.bar""#.as_bytes())?;
let manifest = parse_webxdc_manifest(br#"source_code_url = "https://foo.bar""#)?;
assert_eq!(
manifest.source_code_url,
Some("https://foo.bar".to_string())