Compare commits

..

28 Commits

Author SHA1 Message Date
link2xt
3010d28901 fix: prevent reuse of the stream after an error
When a stream timeouts, `tokio_io_timeout::TimeoutStream`
returns an error once, but then allows to keep using
the stream, e.g. calling `poll_read()` again.

This can be dangerous if the error is ignored.
For example in case of IMAP stream,
if IMAP command is sent,
but then reading the response
times out and the error is ignored,
it is possible to send another IMAP command.
In this case leftover response
from a previous command may be read
and interpreted as the response
to the new IMAP command.

ErrorCapturingStream wraps the stream
to prevent its reuse after an error.
2025-07-19 13:44:00 +00:00
link2xt
e3973f6448 chore(release): prepare for 2.3.0 2025-07-19 11:58:13 +00:00
link2xt
7b41425fe4 fix: save peer address for LoggingStream early
Socket may lose peer address when it is disconnected from
the server side. In this case debug_assert! failed
for me when running the core in debug mode on desktop.
To avoid the case of peer_addr not being available,
we now store it when LoggingStream is created.
2025-07-19 10:58:35 +00:00
bjoern
2c7d51f98f feat: add "e2ee encrypted" info message to all e2ee chats (#7008)
this PR adds a info message "messages are end-to-end-encrypted" also for
chats created by eg. vcards. by the removal of lock icons, this is a
good place to hint for that in addition; this is also what eg. whatsapp
and others are doing

the wording itself is tweaked at
https://github.com/deltachat/deltachat-android/pull/3817 (and there is
also the rough idea to make the message a little more outstanding, by
some more dedicated colors)

~~did not test in practise, if this leads to double "e2ee info messages"
on secure join, tests look good, however.~~ EDIT: did lots of practise
tests meanwhile :)

most of the changes in this PR are about test ...

ftr, in another PR, after 2.0 reeases, there could probably quite some
code cleanup wrt set-protection, protection-disabled etc.

---------

Co-authored-by: Hocuri <hocuri@gmx.de>
2025-07-18 22:08:33 +02:00
l
a2df29515a feat: log the number of read/written bytes on IMAP stream read error (#6924) 2025-07-17 20:01:16 +00:00
link2xt
6df1d165dd feat: log when background fetch of all accounts finishes successfully 2025-07-17 15:46:46 +00:00
cliffmccarthy
e03e2d9a68 fix: List e-mail contacts in repl listcontacts command
- After the revisions to support key contacts, the 'listcontacts'
  command in the repl only lists key-contacts.  A separate flag now
  has to be passed to Contact::get_all() to list address contacts.
  This makes it difficult to run the example in README.md because we
  need to see the new contact so we can get its ID for the next
  command in the example, that creates a chat by ID.
- Revised 'listcontacts' command to make a second call to
  Contact::get_all() with the DC_GCL_ADDRESS flag, then print the
  e-mail contacts after the key contacts.
- Revised configuration example in top-level README.md to reflect
  current command output.

fixes #7011
2025-07-17 10:27:14 +02:00
iequidoo
8fc6ea19b4 feat: {ensure_and,logged}_debug_assert: Don't evaluate condition twice 2025-07-16 11:41:45 -03:00
iequidoo
c5c947e175 feat(repl): Print errors and debug logs to stderr
Follow-up to 545007aca5.
2025-07-16 11:39:56 -03:00
iequidoo
6d8dff54a7 fix: Ignore protected headers in outer message part (#6357)
Delta Chat always adds protected headers to the inner encrypted or signed message, so if a protected
header is only present in the outer part, it should be ignored because it's probably added by the
server or somebody else. The exceptions are Subject and List-ID because there are known cases when
they are only present in the outer message part.

Also treat any Chat-* headers as protected. This fixes e.g. a case when the server injects a
"Chat-Version" IMF header tricking Delta Chat into thinking that it's a chat message.

Also handle "Auto-Submitted" and "Autocrypt-Setup-Message" as protected headers on the receiver
side, this was apparently forgotten.
2025-07-16 11:39:06 -03:00
link2xt
a0f6bdffeb chore(release): prepare for 2.2.0 2025-07-14 18:43:03 +00:00
link2xt
e6fd52afff fix: always prefer the last header
Headers are normally added at the top of the message,
e.g. when forwarding new `Received` headers are
added at the top.

When headers are protected with DKIM-Signature
and oversigning is not used,
forged headers may be added on top
so headers from the top are generally less trustworthy.

This is tested with `test_take_last_header`,
but so far last header was only preferred
for known headers. This change extends
preference of the last header to all headers.
2025-07-14 14:57:52 +00:00
Nico de Haen
0142515887 api!: In ChatListItem, replace is_group and is_(out_)broadcast with chat_type property (#7003)
- removed ChatListItem.is_broadcast
- mark ChatListItem.is_group as deprecated
2025-07-14 11:16:28 +02:00
link2xt
d45ec7f34d feat: advance next UID even if connection fails while fetching
Connection sometimes fails while processing FETCH
responses. In this case `fetch_new_messages` exits early
and does not advance next expected UID even if
some messages were processed.

This results in prefetching the same messages
after reconnection and log messages
similar to
"Not moving the message ab05c85a-e191-4fd2-a951-9972bc7e167f@localhost that we have seen before.".

With this change we advance next expected UID
even if `fetch_new_messages` returns a network error.
2025-07-13 16:32:26 +00:00
iequidoo
752f45f0f0 test: Unencrypted group creation (#6927) 2025-07-13 12:59:33 -03:00
iequidoo
0299543a86 api(jsonrpc): Add CommandApi::create_group_chat_unencrypted() (#6927) 2025-07-13 12:59:33 -03:00
iequidoo
d3908d6b36 api: Add chat::create_group_ex(), deprecate create_group_chat() (#6927)
`chat::create_group_ex()` gains an `encryption: Option<ProtectionStatus>` parameter to support
unencrypted chats.
2025-07-13 12:59:33 -03:00
iequidoo
2cf979de53 feat: Donation request device message (#6913)
A donation request device message is added if >= 100 messages have been sent and delivered. The
condition is checked every 30 days since the first message is sent. The message is added only once.
2025-07-13 11:53:14 -03:00
Hocuri
f5e8c8083d test: Tune down DELTACHAT_SAVE_TMP_DB hint (#6998)
Follow-up for https://github.com/chatmail/core/pull/6992

Since we're printing the hint to stderr now, rather than stdout (as per
link2xt's suggestion), it was too noisy. Also, it was printed once for
every test account rather than once per test.

Now, it integrates nicely with rust's hint to enable a backtrace:

```
  stderr ───

    thread 'chat::chat_tests::test_broadcasts_name_and_avatar' panicked at src/chat/chat_tests.rs:2757:5:
    assertion failed: `(left == right)`

    Diff < left / right > :
    <true
    >false


    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
    note: If you want to examine the database files, set environment variable DELTACHAT_SAVE_TMP_DB=1

        FAIL [   0.265s] deltachat chat::chat_tests::test_broadcast
```
2025-07-12 12:52:04 +02:00
iequidoo
58b99f59f7 feat: Log failed debug assertions in all configurations
Add `logged_debug_assert` macro logging a warning if a condition is not satisfied, before invoking
`debug_assert!`, and use this macro where `Context` is accessible (i.e. don't change function
signatures for now).
Follow-up to 0359481ba4.
2025-07-12 07:27:55 -03:00
link2xt
402e42f858 chore(release): prepare for 2.1.0 2025-07-11 22:56:57 +00:00
link2xt
fbae0739a6 chore(cargo): update cordyceps from 0.3.2 to 0.3.4 2025-07-11 22:07:27 +00:00
iequidoo
0359481ba4 feat: ensure_and_debug_assert{,_eq,_ne} macros combining debug_assert* and anyhow::ensure (#6907)
We have some debug assertions already, but we also want the corresponding errors in the release
configuration so that it's not less reliable than non-optimized one. This doesn't change any
function signatures, only debug assertions in functions returning `Result` are replaced.

Co-authored-by: l <link2xt@testrun.org>
2025-07-11 14:59:49 -03:00
Hocuri
6406f305b8 feat: Make it possible to leave broadcast channels (#6984)
Part of #6884.
The channel owner will not be notified in any way that you left, they
will only see that there is one member less.

For the translated stock strings, this is what we agreed on in the
group:
- Add a new "Leave Channel" stock string (will need to be done in UIs)
- Reword the existing "Are you sure you want to leave this group?"
string to "Are you sure you want to leave?" (the options are "Cancel"
and "Leave Group" / "Leave Channel", so it's clear what you are leaving)
(will need to be done in the deltachat-android repo, other UIs will pick
it up automatically)
- Reword the existing "You left the group." string to "You left". (done
here, I will adapt the strings in deltachat-android, too)

I adapted DC Android by pushing
6df2740884
to https://github.com/deltachat/deltachat-android/pull/3783.

---------

Co-authored-by: l <link2xt@testrun.org>
2025-07-11 12:34:05 +00:00
Hocuri
e5e0f0cdd7 test: Add option to save database on test failure (#6992)
I had it a few times now that I wanted to examine the database in order
to debug a test failure. Setting this environment variable makes this
easy in the future.
2025-07-11 12:01:04 +00:00
B. Petersen
0bac4acdd8 docs: update showpadlock ffi 2025-07-11 13:11:01 +02:00
Alireza Sadraii
ce5697c5f7 feat: add account ordering functionality (#6993)
New public API `set_accounts_order` allows setting the order of accounts.

The account order is stored as a list of account IDs in `accounts.toml`
under a new `accounts_order: Vec<u32>` field.
2025-07-10 22:59:27 +00:00
ivn
22258f7269 fix!: Use Viewtype::File for messages with invalid images, images of unknown size, images > 50 Mpx (#6825)
BREAKING CHANGE: messages with invalid images, images of unknown size,
huge images, will have Viewtype::File

After changing the logic of Viewtype selection, I had to fix 3 old tests
that used invalid Base64 image data.

Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
2025-07-10 18:11:55 -03:00
65 changed files with 1488 additions and 410 deletions

View File

@@ -1,5 +1,71 @@
# Changelog
## [2.3.0] - 2025-07-19
### Features / Changes
- Add "e2ee encrypted" info message to all e2ee chats ([#7008](https://github.com/chatmail/core/pull/7008)).
- repl: Print errors and debug logs to stderr.
- `{ensure_and,logged}_debug_assert`: Don't evaluate condition twice.
- Log when background fetch of all accounts finishes successfully.
- Log the number of read/written bytes on IMAP stream read error ([#6924](https://github.com/chatmail/core/pull/6924)).
### Fixes
- Ignore protected headers in outer message part ([#6357](https://github.com/chatmail/core/pull/6357)).
- List e-mail contacts in repl listcontacts command.
- Save peer address for LoggingStream early.
## [2.2.0] - 2025-07-14
### API-Changes
- Add chat::create_group_ex(), deprecate create_group_chat() ([#6927](https://github.com/chatmail/core/pull/6927)).
- jsonrpc: Add CommandApi::create_group_chat_unencrypted() ([#6927](https://github.com/chatmail/core/pull/6927)).
- [**breaking**] In ChatListItem, replace is_group and is_(out_)broadcast with chat_type property ([#7003](https://github.com/chatmail/core/pull/7003)).
### Features / Changes
- Log failed debug assertions in all configurations.
- Donation request device message ([#6913](https://github.com/chatmail/core/pull/6913)).
- Advance next UID even if connection fails while fetching.
### Fixes
- Always prefer the last header.
### Tests
- Tune down DELTACHAT_SAVE_TMP_DB hint ([#6998](https://github.com/chatmail/core/pull/6998)).
- Unencrypted group creation ([#6927](https://github.com/chatmail/core/pull/6927)).
## [2.1.0] - 2025-07-11
### Features / Changes
- Add account ordering functionality ([#6993](https://github.com/chatmail/core/pull/6993)).
- feat: Make it possible to leave broadcast channels ([#6984](https://github.com/chatmail/core/pull/6984))
- Migrations: Use tools::Time to measure time for logging.
- Log emitted logging events with `tracing`.
- Ensure_and_debug_assert{,_eq,_ne} macros combining `debug_assert*` and anyhow::ensure ([#6907](https://github.com/chatmail/core/pull/6907)).
### Fixes
- Use Viewtype::File for messages with invalid images, images of unknown size, images > 50 Mpx ([#6825](https://github.com/chatmail/core/pull/6825)).
- Don't apply chat name and avatar changes from non-members.
### Documentation
- Update showpadlock ffi.
### Miscellaneous Tasks
- cargo: Update cordyceps from 0.3.2 to 0.3.4.
### Tests
- Add option to save database on test failure ([#6992](https://github.com/chatmail/core/pull/6992)).
## [2.0.0] - 2025-07-09
This release changes the way the core handles contact keys.
@@ -6426,3 +6492,6 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[1.159.5]: https://github.com/chatmail/core/compare/v1.159.4..v1.159.5
[1.160.0]: https://github.com/chatmail/core/compare/v1.159.5..v1.160.0
[2.0.0]: https://github.com/chatmail/core/compare/v1.160.0..v2.0.0
[2.1.0]: https://github.com/chatmail/core/compare/v2.0.0..v2.1.0
[2.2.0]: https://github.com/chatmail/core/compare/v2.1.0..v2.2.0
[2.3.0]: https://github.com/chatmail/core/compare/v2.2.0..v2.3.0

57
Cargo.lock generated
View File

@@ -958,11 +958,11 @@ checksum = "fb4a24b1aaf0fd0ce8b45161144d6f42cd91677fd5940fd431183eb023b3a2b8"
[[package]]
name = "cordyceps"
version = "0.3.2"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec10f0a762d93c4498d2e97a333805cb6250d60bead623f71d8034f9a4152ba3"
checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a"
dependencies = [
"loom 0.5.6",
"loom",
"tracing",
]
@@ -1285,7 +1285,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.0.0"
version = "2.3.0"
dependencies = [
"anyhow",
"async-broadcast",
@@ -1395,7 +1395,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.0.0"
version = "2.3.0"
dependencies = [
"anyhow",
"async-channel 2.3.1",
@@ -1417,7 +1417,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "2.0.0"
version = "2.3.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1433,7 +1433,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.0.0"
version = "2.3.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1462,7 +1462,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.0.0"
version = "2.3.0"
dependencies = [
"anyhow",
"deltachat",
@@ -2205,19 +2205,6 @@ dependencies = [
"slab",
]
[[package]]
name = "generator"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e"
dependencies = [
"cc",
"libc",
"log",
"rustversion",
"windows 0.48.0",
]
[[package]]
name = "generator"
version = "0.8.4"
@@ -3340,19 +3327,6 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "loom"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5"
dependencies = [
"cfg-if",
"generator 0.7.5",
"scoped-tls",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "loom"
version = "0.7.2"
@@ -3360,7 +3334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
dependencies = [
"cfg-if",
"generator 0.8.4",
"generator",
"scoped-tls",
"tracing",
"tracing-subscriber",
@@ -3477,7 +3451,7 @@ dependencies = [
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"loom 0.7.2",
"loom",
"parking_lot",
"portable-atomic",
"rustc_version",
@@ -3852,7 +3826,7 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
dependencies = [
"proc-macro-crate 3.2.0",
"proc-macro-crate 2.0.0",
"proc-macro2",
"quote",
"syn 2.0.104",
@@ -6750,15 +6724,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows"
version = "0.52.0"

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.0.0"
version = "2.3.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.85"

View File

@@ -84,26 +84,29 @@ Create a contact:
```
> addcontact yourfriends@email.org
Command executed successfully.
```
List contacts:
```
> listcontacts
Contact#10: <name unset> <yourfriends@email.org>
Contact#1: Me √√ <your@email.org>
Contact#Contact#Self: Me √ <your@email.org>
1 key contacts.
Contact#Contact#10: yourfriends@email.org <yourfriends@email.org>
1 address contacts.
```
Create a chat with your friend and send a message:
```
> createchat 10
Single#10 created successfully.
> chat 10
Single#10: yourfriends@email.org [yourfriends@email.org]
Single#Chat#12 created successfully.
> chat 12
Selecting chat Chat#12
Single#Chat#12: yourfriends@email.org [yourfriends@email.org] Icon: profile-db-blobs/4138c52e5bc1c576cda7dd44d088c07.png
0 messages.
81.252µs to create this list, 123.625µs to mark all messages as noticed.
> send hi
Message sent.
```
List messages when inside a chat:

View File

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

View File

@@ -4303,11 +4303,16 @@ int dc_msg_get_duration (const dc_msg_t* msg);
/**
* Check if a padlock should be shown beside the message.
* Check if message was correctly encrypted and signed.
*
* Historically, UIs showed a small padlock on the message then.
* Today, the UIs should instead
* show a small email-icon on the message if the message is not encrypted or signed,
* and nothing otherwise.
*
* @memberof dc_msg_t
* @param msg The message object.
* @return 1=padlock should be shown beside message, 0=do not show a padlock beside the message.
* @return 1=message correctly encrypted and signed, no need to show anything; 0=show email-icon beside the message.
*/
int dc_msg_get_showpadlock (const dc_msg_t* msg);
@@ -4530,12 +4535,12 @@ int dc_msg_is_info (const dc_msg_t* msg);
* - DC_INFO_MEMBER_ADDED_TO_GROUP (4) - "Member CONTACT added by OTHER_CONTACT"
* - DC_INFO_MEMBER_REMOVED_FROM_GROUP (5) - "Member CONTACT removed by OTHER_CONTACT"
* - DC_INFO_EPHEMERAL_TIMER_CHANGED (10) - "Disappearing messages CHANGED_TO by CONTACT"
* - DC_INFO_PROTECTION_ENABLED (11) - Info-message for "Chat is now protected"
* - DC_INFO_PROTECTION_DISABLED (12) - Info-message for "Chat is no longer protected"
* - DC_INFO_PROTECTION_ENABLED (11) - Info-message for "Chat is protected"
* - DC_INFO_INVALID_UNENCRYPTED_MAIL (13) - Info-message for "Provider requires end-to-end encryption which is not setup yet",
* the UI should change the corresponding string using #DC_STR_INVALID_UNENCRYPTED_MAIL
* and also offer a way to fix the encryption, eg. by a button offering a QR scan
* - DC_INFO_WEBXDC_INFO_MESSAGE (32) - Info-message created by webxdc app sending `update.info`
* - DC_INFO_CHAT_E2EE (50) - Info-message for "Chat is end-to-end-encrypted"
*
* For the messages that refer to a CONTACT,
* dc_msg_get_info_contact_id() returns the contact ID.
@@ -4588,9 +4593,10 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
#define DC_INFO_LOCATION_ONLY 9
#define DC_INFO_EPHEMERAL_TIMER_CHANGED 10
#define DC_INFO_PROTECTION_ENABLED 11
#define DC_INFO_PROTECTION_DISABLED 12
#define DC_INFO_PROTECTION_DISABLED 12 // deprecated 2025-07
#define DC_INFO_INVALID_UNENCRYPTED_MAIL 13
#define DC_INFO_WEBXDC_INFO_MESSAGE 32
#define DC_INFO_CHAT_E2EE 50
/**
@@ -6893,9 +6899,7 @@ void dc_event_unref(dc_event_t* event);
/// Used in summaries.
#define DC_STR_GIF 23
/// "Encrypted message"
///
/// Used in subjects of outgoing messages.
/// @deprecated 2025-07, this string is no longer needed.
#define DC_STR_ENCRYPTEDMSG 24
/// "End-to-end encryption available."
@@ -7419,7 +7423,7 @@ void dc_event_unref(dc_event_t* event);
/// Used in status messages.
#define DC_STR_REMOVE_MEMBER_BY_OTHER 131
/// "You left the group."
/// "You left."
///
/// Used in status messages.
#define DC_STR_GROUP_LEFT_BY_YOU 132
@@ -7600,7 +7604,7 @@ void dc_event_unref(dc_event_t* event);
/// Used as a device message after a successful backup transfer.
#define DC_STR_BACKUP_TRANSFER_MSG_BODY 163
/// "Messages are guaranteed to be end-to-end encrypted from now on."
/// "Messages are end-to-end encrypted."
///
/// Used in info messages.
#define DC_STR_CHAT_PROTECTION_ENABLED 170
@@ -7608,6 +7612,7 @@ void dc_event_unref(dc_event_t* event);
/// "%1$s sent a message from another device."
///
/// Used in info messages.
/// @deprecated 2025-07
#define DC_STR_CHAT_PROTECTION_DISABLED 171
/// "Others will only see this group after you sent a first message."
@@ -7662,6 +7667,9 @@ void dc_event_unref(dc_event_t* event);
/// @deprecated 2025-06-05
#define DC_STR_SECUREJOIN_TAKES_LONGER 192
/// "❤️ Seems you're enjoying Delta Chat!"… (donation request device message)
#define DC_STR_DONATION_REQUEST 193
/// "Contact". Deprecated, currently unused.
#define DC_STR_CONTACT 200

View File

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

View File

@@ -224,6 +224,14 @@ impl CommandApi {
self.accounts.read().await.get_selected_account_id()
}
/// Set the order of accounts.
/// The provided list should contain all account IDs in the desired order.
/// If an account ID is missing from the list, it will be appended at the end.
/// If the list contains non-existent account IDs, they will be ignored.
async fn set_accounts_order(&self, order: Vec<u32>) -> Result<()> {
self.accounts.write().await.set_accounts_order(order).await
}
/// Get a list of all configured accounts.
async fn get_all_accounts(&self) -> Result<Vec<Account>> {
let mut accounts = Vec::new();
@@ -945,7 +953,7 @@ impl CommandApi {
Ok(contacts.iter().map(|id| id.to_u32()).collect::<Vec<u32>>())
}
/// Create a new group chat.
/// Create a new encrypted group chat (with key-contacts).
///
/// After creation,
/// the group has one member with the ID DC_CONTACT_ID_SELF
@@ -963,14 +971,24 @@ impl CommandApi {
///
/// @param protect If set to 1 the function creates group with protection initially enabled.
/// Only verified members are allowed in these groups
/// and end-to-end-encryption is always enabled.
async fn create_group_chat(&self, account_id: u32, name: String, protect: bool) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
let protect = match protect {
true => ProtectionStatus::Protected,
false => ProtectionStatus::Unprotected,
};
chat::create_group_chat(&ctx, protect, &name)
chat::create_group_ex(&ctx, Some(protect), &name)
.await
.map(|id| id.to_u32())
}
/// Create a new unencrypted group chat.
///
/// Same as [`Self::create_group_chat`], but the chat is unencrypted and can only have
/// address-contacts.
async fn create_group_chat_unencrypted(&self, account_id: u32, name: String) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
chat::create_group_ex(&ctx, None, &name)
.await
.map(|id| id.to_u32())
}

View File

@@ -23,6 +23,7 @@ pub enum ChatListItemFetchResult {
name: String,
avatar_path: Option<String>,
color: String,
chat_type: u32,
last_updated: Option<i64>,
summary_text1: String,
summary_text2: String,
@@ -54,6 +55,7 @@ pub enum ChatListItemFetchResult {
///
/// See also `is_key_contact` on `Contact`.
is_encrypted: bool,
/// deprecated 2025-07, use chat_type instead
is_group: bool,
fresh_message_counter: usize,
is_self_talk: bool,
@@ -64,10 +66,6 @@ pub enum ChatListItemFetchResult {
is_pinned: bool,
is_muted: bool,
is_contact_request: bool,
/// Deprecated 2025-07, alias for is_out_broadcast
is_broadcast: bool,
/// true if the chat type is OutBroadcast
is_out_broadcast: bool,
/// contact id if this is a dm chat (for view profile entry in context menu)
dm_chat_contact: Option<u32>,
was_seen_recently: bool,
@@ -157,6 +155,7 @@ pub(crate) async fn get_chat_list_item_by_id(
name: chat.get_name().to_owned(),
avatar_path,
color,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
last_updated,
summary_text1,
summary_text2,
@@ -174,8 +173,6 @@ pub(crate) async fn get_chat_list_item_by_id(
is_pinned: visibility == ChatVisibility::Pinned,
is_muted: chat.is_muted(),
is_contact_request: chat.is_contact_request(),
is_broadcast: chat.get_type() == Chattype::OutBroadcast,
is_out_broadcast: chat.get_type() == Chattype::OutBroadcast,
dm_chat_contact,
was_seen_recently,
last_message_type: message_type,

View File

@@ -416,6 +416,9 @@ pub enum SystemMessageType {
/// Chat ephemeral message timer is changed.
EphemeralTimerChanged,
// Chat is e2ee
ChatE2ee,
// Chat protection state changed
ChatProtectionEnabled,
ChatProtectionDisabled,
@@ -450,6 +453,7 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
SystemMessage::LocationStreamingEnabled => SystemMessageType::LocationStreamingEnabled,
SystemMessage::LocationOnly => SystemMessageType::LocationOnly,
SystemMessage::EphemeralTimerChanged => SystemMessageType::EphemeralTimerChanged,
SystemMessage::ChatE2ee => SystemMessageType::ChatE2ee,
SystemMessage::ChatProtectionEnabled => SystemMessageType::ChatProtectionEnabled,
SystemMessage::ChatProtectionDisabled => SystemMessageType::ChatProtectionDisabled,
SystemMessage::MultiDeviceSync => SystemMessageType::MultiDeviceSync,

View File

@@ -54,5 +54,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.0.0"
"version": "2.3.0"
}

View File

@@ -95,8 +95,10 @@ describe("online tests", function () {
false,
);
expect(messageList).have.length(1);
const message = await dc.rpc.getMessage(accountId2, messageList[0]);
// There are 2 messages in the chat:
// 'Messages are end-to-end encrypted' (info message) and 'Hello'
expect(messageList).have.length(2);
const message = await dc.rpc.getMessage(accountId2, messageList[1]);
expect(message.text).equal("Hello");
expect(message.showPadlock).equal(true);
});

View File

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

View File

@@ -87,7 +87,7 @@ async fn poke_eml_file(context: &Context, filename: &Path) -> Result<()> {
let data = read_file(context, filename).await?;
if let Err(err) = receive_imf(context, &data, false).await {
println!("receive_imf errored: {err:?}");
eprintln!("receive_imf errored: {err:?}");
}
Ok(())
}
@@ -621,7 +621,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Location streaming enabled.");
}
println!("{cnt} chats");
println!("{time_needed:?} to create this list");
eprintln!("{time_needed:?} to create this list");
}
"start-realtime" => {
if arg1.is_empty() {
@@ -731,7 +731,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
chat::marknoticed_chat(&context, sel_chat.get_id()).await?;
let time_noticed_needed = time_noticed_start.elapsed().unwrap_or_default();
println!(
eprintln!(
"{time_needed:?} to create this list, {time_noticed_needed:?} to mark all messages as noticed."
);
}
@@ -985,7 +985,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
},
query,
);
println!("{time_needed:?} to create this list");
eprintln!("{time_needed:?} to create this list");
}
"draft" => {
ensure!(sel_chat.is_some(), "No chat selected.");
@@ -1151,7 +1151,10 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"listcontacts" | "contacts" => {
let contacts = Contact::get_all(&context, DC_GCL_ADD_SELF, Some(arg1)).await?;
log_contactlist(&context, &contacts).await?;
println!("{} contacts.", contacts.len());
println!("{} key contacts.", contacts.len());
let addrcontacts = Contact::get_all(&context, DC_GCL_ADDRESS, Some(arg1)).await?;
log_contactlist(&context, &addrcontacts).await?;
println!("{} address contacts.", addrcontacts.len());
}
"addcontact" => {
ensure!(!arg1.is_empty(), "Arguments [<name>] <addr> expected.");
@@ -1224,7 +1227,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
ensure!(!arg1.is_empty(), "Argument <qr-content> missing.");
match set_config_from_qr(&context, arg1).await {
Ok(()) => println!("Config set from QR code, you can now call 'configure'"),
Err(err) => println!("Cannot set config from QR code: {err:?}"),
Err(err) => eprintln!("Cannot set config from QR code: {err:?}"),
}
}
"createqrsvg" => {

View File

@@ -311,7 +311,7 @@ impl Validator for DcHelper {}
async fn start(args: Vec<String>) -> Result<(), Error> {
if args.len() < 2 {
println!("Error: Bad arguments, expected [db-name].");
eprintln!("Error: Bad arguments, expected [db-name].");
bail!("No db-name specified");
}
let context = ContextBuilder::new(args[1].clone().into())
@@ -366,7 +366,7 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
false
}
Err(err) => {
println!("Error: {err:#}");
eprintln!("Error: {err:#}");
true
}
}
@@ -381,7 +381,7 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
break;
}
Err(err) => {
println!("Error: {err:#}");
eprintln!("Error: {err:#}");
break;
}
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.0.0"
version = "2.3.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",

View File

@@ -13,6 +13,12 @@ from . import Account, AttrDict, Bot, Chat, Client, DeltaChat, EventType, Messag
from ._utils import futuremethod
from .rpc import Rpc
E2EE_INFO_MSGS = 1
"""
The number of info messages added to new e2ee chats.
Currently this is "End-to-end encryption available".
"""
class ACFactory:
"""Test account factory."""

View File

@@ -36,6 +36,9 @@ def test_one_account_send_bcc_setting(acfactory, log, direct_imap):
assert ac1.get_config("bcc_self") == "1"
# Second client receives only second message, but not the first.
ev_msg = ac1_clone.wait_for_event(EventType.MSGS_CHANGED)
assert ac1_clone.get_message_by_id(ev_msg.msg_id).get_snapshot().text == "Messages are end-to-end encrypted."
ev_msg = ac1_clone.wait_for_event(EventType.MSGS_CHANGED)
assert ac1_clone.get_message_by_id(ev_msg.msg_id).get_snapshot().text == msg_out.get_snapshot().text

View File

@@ -12,6 +12,7 @@ import pytest
from deltachat_rpc_client import Contact, EventType, Message, events
from deltachat_rpc_client.const import ChatType, DownloadState, MessageState
from deltachat_rpc_client.pytestplugin import E2EE_INFO_MSGS
from deltachat_rpc_client.rpc import JsonRpcError
@@ -457,8 +458,12 @@ def test_wait_next_messages(acfactory) -> None:
alice_chat_bot.send_text("Hello!")
next_messages = next_messages_task.result()
assert len(next_messages) == 1
snapshot = next_messages[0].get_snapshot()
if len(next_messages) == E2EE_INFO_MSGS:
next_messages += bot.wait_next_messages()
assert len(next_messages) == 1 + E2EE_INFO_MSGS
snapshot = next_messages[0 + E2EE_INFO_MSGS].get_snapshot()
assert snapshot.text == "Hello!"

View File

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

View File

@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "2.0.0"
"version": "2.3.0"
}

View File

@@ -25,16 +25,15 @@ skip = [
{ name = "derive_more-impl", version = "1.0.0" },
{ name = "derive_more", version = "1.0.0" },
{ name = "event-listener", version = "2.5.3" },
{ name = "generator", version = "0.7.5" },
{ name = "getrandom", version = "0.2.12" },
{ name = "hashbrown", version = "0.14.5" },
{ name = "heck", version = "0.4.1" },
{ name = "http", version = "0.2.12" },
{ name = "linux-raw-sys", version = "0.4.14" },
{ name = "loom", version = "0.5.6" },
{ name = "lru", version = "0.12.3" },
{ name = "netlink-packet-route", version = "0.17.1" },
{ name = "nom", version = "7.1.3" },
{ name = "proc-macro-crate", version = "2.0.0" },
{ name = "rand_chacha", version = "0.3.1" },
{ name = "rand_core", version = "0.6.4" },
{ name = "rand", version = "0.8.5" },
@@ -50,6 +49,7 @@ skip = [
{ name = "syn", version = "1.0.109" },
{ name = "thiserror-impl", version = "1.0.69" },
{ name = "thiserror", version = "1.0.69" },
{ name = "toml_edit", version = "0.20.7" },
{ name = "wasi", version = "0.11.0+wasi-snapshot-preview1" },
{ name = "windows" },
{ name = "windows_aarch64_gnullvm" },
@@ -67,6 +67,7 @@ skip = [
{ name = "windows_x86_64_gnu" },
{ name = "windows_x86_64_gnullvm" },
{ name = "windows_x86_64_msvc" },
{ name = "winnow", version = "0.5.40" },
{ name = "zerocopy", version = "0.7.32" },
]

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.0.0"
version = "2.3.0"
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
readme = "README.rst"
requires-python = ">=3.8"

View File

@@ -20,6 +20,12 @@ import deltachat
from . import Account, account_hookimpl, const, get_core_info
from .events import FFIEventLogger, FFIEventTracker
E2EE_INFO_MSGS = 1
"""
The number of info messages added to new e2ee chats.
Currently this is "End-to-end encryption available".
"""
def pytest_addoption(parser):
group = parser.getgroup("deltachat testplugin options")
@@ -606,7 +612,7 @@ class ACFactory:
ev = ac2._evtracker.get_matching("DC_EVENT_MSGS_CHANGED")
msg = ac2.get_message_by_id(ev.data2)
assert msg is not None
assert msg.text == "Messages are guaranteed to be end-to-end encrypted from now on."
assert msg.text == "Messages are end-to-end encrypted."
msg = ac2._evtracker.wait_next_incoming_message()
assert msg is not None
assert "Member Me " in msg.text and " added by " in msg.text

View File

@@ -133,8 +133,7 @@ def test_qr_verified_group_and_chatting(acfactory, lp):
assert "added" in msg.text.lower()
assert any(
m.is_system_message() and m.text == "Messages are guaranteed to be end-to-end encrypted from now on."
for m in msg.chat.get_messages()
m.is_system_message() and m.text == "Messages are end-to-end encrypted." for m in msg.chat.get_messages()
)
lp.sec("ac1: send message")
msg_out = chat1.send_text("hello")
@@ -338,7 +337,7 @@ def test_use_new_verified_group_after_going_online(acfactory, data, tmp_path, lp
assert contact.addr == ac1.get_config("addr")
chat2 = msg_in.chat
assert chat2.is_protected()
assert chat2.get_messages()[0].text == "Messages are guaranteed to be end-to-end encrypted from now on."
assert chat2.get_messages()[0].text == "Messages are end-to-end encrypted."
assert open(contact.get_profile_image(), "rb").read() == open(avatar_path, "rb").read()
lp.sec("ac2_offl: sending message")
@@ -412,7 +411,7 @@ def test_verified_group_vs_delete_server_after(acfactory, tmp_path, lp):
ev = ac2_offl._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
msg_in = ac2_offl.get_message_by_id(ev.data2)
assert msg_in.is_system_message()
assert msg_in.text == "Messages are guaranteed to be end-to-end encrypted from now on."
assert msg_in.text == "Messages are end-to-end encrypted."
# We need to consume one event that has data2=0
ev = ac2_offl._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")

View File

@@ -10,6 +10,7 @@ from imap_tools import AND, U
import deltachat as dc
from deltachat import account_hookimpl, Message
from deltachat.tracker import ImexTracker
from deltachat.testplugin import E2EE_INFO_MSGS
def test_basic_imap_api(acfactory, tmp_path):
@@ -408,6 +409,10 @@ def test_forward_messages(acfactory, lp):
msg_out = chat.send_text("message2")
lp.sec("ac2: wait for receive")
ev = ac2._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
msg_in = ac2.get_message_by_id(ev.data2)
assert msg_in.text == "Messages are end-to-end encrypted."
ev = ac2._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
assert ev.data2 == msg_out.id
msg_in = ac2.get_message_by_id(msg_out.id)
@@ -622,6 +627,11 @@ def test_moved_markseen(acfactory):
with ac2.direct_imap.idle() as idle2:
ac2.start_io()
ev = ac2._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
msg = ac2.get_message_by_id(ev.data2)
assert msg.text == "Messages are end-to-end encrypted."
ev = ac2._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
msg = ac2.get_message_by_id(ev.data2)
@@ -738,7 +748,7 @@ def test_mdn_asymmetric(acfactory, lp):
lp.sec("sending text message from ac1 to ac2")
msg_out = chat.send_text("message1")
assert len(chat.get_messages()) == 1
assert len(chat.get_messages()) == 1 + E2EE_INFO_MSGS
lp.sec("disable ac1 MDNs")
ac1.set_config("mdns_enabled", "0")
@@ -746,7 +756,7 @@ def test_mdn_asymmetric(acfactory, lp):
lp.sec("wait for ac2 to receive message")
msg = ac2._evtracker.wait_next_incoming_message()
assert len(msg.chat.get_messages()) == 1
assert len(msg.chat.get_messages()) == 1 + E2EE_INFO_MSGS
lp.sec("ac2: mark incoming message as seen")
ac2.mark_seen_messages([msg])
@@ -755,7 +765,7 @@ def test_mdn_asymmetric(acfactory, lp):
# MDN should be moved even though MDNs are already disabled
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
assert len(chat.get_messages()) == 1
assert len(chat.get_messages()) == 1 + E2EE_INFO_MSGS
# Wait for the message to be marked as seen on IMAP.
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder DeltaChat as seen.")
@@ -1123,6 +1133,11 @@ def test_send_and_receive_image(acfactory, lp, data):
assert m == msg_out
lp.sec("wait for ac2 to receive message")
ev = ac2._evtracker.get_matching("DC_EVENT_MSGS_CHANGED|DC_EVENT_INCOMING_MSG")
msg_in = ac2.get_message_by_id(ev.data2)
assert msg_in.text == "Messages are end-to-end encrypted."
ev = ac2._evtracker.get_matching("DC_EVENT_MSGS_CHANGED|DC_EVENT_INCOMING_MSG")
assert ev.data2 == msg_out.id
msg_in = ac2.get_message_by_id(msg_out.id)
@@ -1158,10 +1173,10 @@ def test_import_export_online_all(acfactory, tmp_path, data, lp):
assert contact2.addr == some1_addr
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 3
assert messages[0].text == "msg1"
assert messages[1].filemime == "image/png"
assert os.stat(messages[1].filename).st_size == os.stat(original_image_path).st_size
assert len(messages) == 3 + E2EE_INFO_MSGS
assert messages[0 + E2EE_INFO_MSGS].text == "msg1"
assert messages[1 + E2EE_INFO_MSGS].filemime == "image/png"
assert os.stat(messages[1 + E2EE_INFO_MSGS].filename).st_size == os.stat(original_image_path).st_size
ac.set_config("displayname", "new displayname")
assert ac.get_config("displayname") == "new displayname"
@@ -1414,8 +1429,8 @@ def test_connectivity(acfactory, lp):
ac1.maybe_network()
ac1._evtracker.wait_for_connectivity(dc.const.DC_CONNECTIVITY_CONNECTED)
msgs = ac1.create_chat(ac2).get_messages()
assert len(msgs) == 1
assert msgs[0].text == "Hi"
assert len(msgs) == 1 + E2EE_INFO_MSGS
assert msgs[0 + E2EE_INFO_MSGS].text == "Hi"
lp.sec("Test that the connectivity changes to WORKING while new messages are fetched")
@@ -1425,8 +1440,8 @@ def test_connectivity(acfactory, lp):
ac1._evtracker.wait_for_connectivity_change(dc.const.DC_CONNECTIVITY_WORKING, dc.const.DC_CONNECTIVITY_CONNECTED)
msgs = ac1.create_chat(ac2).get_messages()
assert len(msgs) == 2
assert msgs[1].text == "Hi 2"
assert len(msgs) == 2 + E2EE_INFO_MSGS
assert msgs[1 + E2EE_INFO_MSGS].text == "Hi 2"
def test_fetch_deleted_msg(acfactory, lp):

View File

@@ -6,6 +6,7 @@ import pytest
import deltachat as dc
from deltachat.tracker import ImexFailed
from deltachat import Account, Message
from deltachat.testplugin import E2EE_INFO_MSGS
class TestOfflineAccountBasic:
@@ -461,9 +462,9 @@ class TestOfflineChat:
assert contact2.addr == ac_contact.get_config("addr")
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2
assert messages[0].text == "msg1"
assert os.path.exists(messages[1].filename)
assert len(messages) == 2 + E2EE_INFO_MSGS
assert messages[0 + E2EE_INFO_MSGS].text == "msg1"
assert os.path.exists(messages[1 + E2EE_INFO_MSGS].filename)
def test_import_export_on_encrypted_acct(self, acfactory, tmp_path):
passphrase1 = "passphrase1"
@@ -500,9 +501,9 @@ class TestOfflineChat:
contact2_addr = contact2.addr
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2
assert messages[0].text == "msg1"
assert os.path.exists(messages[1].filename)
assert len(messages) == 2 + E2EE_INFO_MSGS
assert messages[0 + E2EE_INFO_MSGS].text == "msg1"
assert os.path.exists(messages[1 + E2EE_INFO_MSGS].filename)
ac2.shutdown()
@@ -517,9 +518,9 @@ class TestOfflineChat:
assert contact2.addr == contact2_addr
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2
assert messages[0].text == "msg1"
assert os.path.exists(messages[1].filename)
assert len(messages) == 2 + E2EE_INFO_MSGS
assert messages[0 + E2EE_INFO_MSGS].text == "msg1"
assert os.path.exists(messages[1 + E2EE_INFO_MSGS].filename)
def test_import_export_with_passphrase(self, acfactory, tmp_path):
passphrase = "test_passphrase"
@@ -557,9 +558,9 @@ class TestOfflineChat:
assert contact2.addr == ac_contact.get_config("addr")
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2
assert messages[0].text == "msg1"
assert os.path.exists(messages[1].filename)
assert len(messages) == 2 + E2EE_INFO_MSGS
assert messages[0 + E2EE_INFO_MSGS].text == "msg1"
assert os.path.exists(messages[1 + E2EE_INFO_MSGS].filename)
def test_import_encrypted_bak_into_encrypted_acct(self, acfactory, tmp_path):
"""
@@ -603,9 +604,9 @@ class TestOfflineChat:
assert contact2.addr == ac_contact.get_config("addr")
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2
assert messages[0].text == "msg1"
assert os.path.exists(messages[1].filename)
assert len(messages) == 2 + E2EE_INFO_MSGS
assert messages[0 + E2EE_INFO_MSGS].text == "msg1"
assert os.path.exists(messages[1 + E2EE_INFO_MSGS].filename)
ac2.shutdown()
@@ -620,9 +621,9 @@ class TestOfflineChat:
assert contact2.addr == ac_contact.get_config("addr")
chat2 = contact2.create_chat()
messages = chat2.get_messages()
assert len(messages) == 2
assert messages[0].text == "msg1"
assert os.path.exists(messages[1].filename)
assert len(messages) == 2 + E2EE_INFO_MSGS
assert messages[0 + E2EE_INFO_MSGS].text == "msg1"
assert os.path.exists(messages[1 + E2EE_INFO_MSGS].filename)
def test_set_get_draft(self, chat1):
msg1 = Message.new_empty(chat1.account, "text")

View File

@@ -1 +1 @@
2025-07-09
2025-07-19

View File

@@ -1,6 +1,6 @@
//! # Account manager module.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::path::{Path, PathBuf};
@@ -270,9 +270,51 @@ impl Accounts {
}
}
/// Get a list of all account ids.
/// Gets a list of all account ids in the user-configured order.
pub fn get_all(&self) -> Vec<u32> {
self.accounts.keys().copied().collect()
let mut ordered_ids = Vec::new();
let mut all_ids: BTreeSet<u32> = self.accounts.keys().copied().collect();
// First, add accounts in the configured order
for &id in &self.config.inner.accounts_order {
if all_ids.remove(&id) {
ordered_ids.push(id);
}
}
// Then add any accounts not in the order list (newly added accounts)
for id in all_ids {
ordered_ids.push(id);
}
ordered_ids
}
/// Sets the order of accounts.
///
/// The provided list should contain all account IDs in the desired order.
/// If an account ID is missing from the list, it will be appended at the end.
/// If the list contains non-existent account IDs, they will be ignored.
pub async fn set_accounts_order(&mut self, order: Vec<u32>) -> Result<()> {
let existing_ids: BTreeSet<u32> = self.accounts.keys().copied().collect();
// Filter out non-existent account IDs
let mut filtered_order: Vec<u32> = order
.into_iter()
.filter(|id| existing_ids.contains(id))
.collect();
// Add any missing account IDs at the end
for &id in &existing_ids {
if !filtered_order.contains(&id) {
filtered_order.push(id);
}
}
self.config.inner.accounts_order = filtered_order;
self.config.sync().await?;
self.emit_event(EventType::AccountsChanged);
Ok(())
}
/// Starts background tasks such as IMAP and SMTP loops for all accounts.
@@ -311,11 +353,11 @@ impl Accounts {
/// This is an auxiliary function and not part of public API.
/// Use [Accounts::background_fetch] instead.
async fn background_fetch_no_timeout(accounts: Vec<Context>, events: Events) {
let n_accounts = accounts.len();
events.emit(Event {
id: 0,
typ: EventType::Info(format!(
"Starting background fetch for {} accounts.",
accounts.len()
"Starting background fetch for {n_accounts} accounts."
)),
});
let mut set = JoinSet::new();
@@ -327,6 +369,12 @@ impl Accounts {
});
}
set.join_all().await;
events.emit(Event {
id: 0,
typ: EventType::Info(format!(
"Finished background fetch for {n_accounts} accounts."
)),
});
}
/// Auxiliary function for [Accounts::background_fetch].
@@ -415,6 +463,10 @@ struct InnerConfig {
pub selected_account: u32,
pub next_id: u32,
pub accounts: Vec<AccountConfig>,
/// Ordered list of account IDs, representing the user's preferred order.
/// If an account ID is not in this list, it will be appended at the end.
#[serde(default)]
pub accounts_order: Vec<u32>,
}
impl Drop for Config {
@@ -481,6 +533,7 @@ impl Config {
accounts: Vec::new(),
selected_account: 0,
next_id: 1,
accounts_order: Vec::new(),
};
if !lock {
let cfg = Self {
@@ -613,6 +666,10 @@ impl Config {
uuid,
});
self.inner.next_id += 1;
// Add new account to the end of the order list
self.inner.accounts_order.push(id);
id
};
@@ -634,6 +691,10 @@ impl Config {
// remove account from the configs
self.inner.accounts.remove(idx);
}
// Remove from order list as well
self.inner.accounts_order.retain(|&x| x != id);
if self.inner.selected_account == id {
// reset selected account
self.inner.selected_account = self

View File

@@ -33,6 +33,7 @@ use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers};
use crate::events::EventType;
use crate::location;
use crate::log::{LogExt, error, info, warn};
use crate::logged_debug_assert;
use crate::message::{self, Message, MessageState, MsgId, Viewtype};
use crate::mimefactory::MimeFactory;
use crate::mimeparser::SystemMessage;
@@ -348,6 +349,8 @@ impl ChatId {
chat_id
.add_protection_msg(context, ProtectionStatus::Protected, None, timestamp)
.await?;
} else {
chat_id.maybe_add_encrypted_msg(context, timestamp).await?;
}
info!(
@@ -603,6 +606,42 @@ impl ChatId {
Ok(())
}
/// Adds message "Messages are end-to-end encrypted" if appropriate.
///
/// This function is rather slow because it does a lot of database queries,
/// but this is fine because it is only called on chat creation.
async fn maybe_add_encrypted_msg(self, context: &Context, timestamp_sort: i64) -> Result<()> {
let chat = Chat::load_from_db(context, self).await?;
// as secure-join adds its own message on success (after some other messasges),
// we do not want to add "Messages are end-to-end encrypted" on chat creation.
// we detect secure join by `can_send` (for Bob, scanner side) and by `blocked` (for Alice, inviter side) below.
if !chat.is_encrypted(context).await?
|| self <= DC_CHAT_ID_LAST_SPECIAL
|| chat.is_device_talk()
|| chat.is_self_talk()
|| (!chat.can_send(context).await? && !chat.is_contact_request())
|| chat.blocked == Blocked::Yes
{
return Ok(());
}
let text = stock_str::messages_e2e_encrypted(context).await;
add_info_msg_with_cmd(
context,
self,
&text,
SystemMessage::ChatE2ee,
timestamp_sort,
None,
None,
None,
None,
)
.await?;
Ok(())
}
/// Sets protection and adds a message.
///
/// `timestamp_sort` is used as the timestamp of the added message
@@ -1339,14 +1378,18 @@ impl ChatId {
let mut ret = stock_str::e2e_available(context).await + "\n";
for contact_id in get_chat_contacts(context, self)
for &contact_id in get_chat_contacts(context, self)
.await?
.iter()
.filter(|&contact_id| !contact_id.is_special())
{
let contact = Contact::get_by_id(context, *contact_id).await?;
let contact = Contact::get_by_id(context, contact_id).await?;
let addr = contact.get_addr();
debug_assert!(contact.is_key_contact());
logged_debug_assert!(
context,
contact.is_key_contact(),
"get_encryption_info: contact {contact_id} is not a key-contact."
);
let fingerprint = contact
.fingerprint()
.context("Contact does not have a fingerprint in encrypted chat")?;
@@ -2668,6 +2711,10 @@ impl ChatIdBlocked {
smeared_time,
)
.await?;
} else {
chat_id
.maybe_add_encrypted_msg(context, smeared_time)
.await?;
}
Ok(Self {
@@ -2905,10 +2952,12 @@ async fn prepare_send_msg(
// If the chat is a contact request, let the user accept it later.
msg.param.get_cmd() == SystemMessage::SecurejoinMessage
}
// Allow to send "Member removed" messages so we can leave the group.
// Allow to send "Member removed" messages so we can leave the group/broadcast.
// Necessary checks should be made anyway before removing contact
// from the chat.
CantSendReason::NotAMember => msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup,
CantSendReason::NotAMember | CantSendReason::InBroadcast => {
msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup
}
CantSendReason::MissingKey => msg
.param
.get_bool(Param::ForcePlaintext)
@@ -2960,6 +3009,9 @@ async fn prepare_send_msg(
let row_ids = create_send_msg_jobs(context, msg)
.await
.context("Failed to create send jobs")?;
if !row_ids.is_empty() {
donation_request_maybe(context).await.log_err(context).ok();
}
Ok(row_ids)
}
@@ -3204,6 +3256,31 @@ pub async fn send_videochat_invitation(context: &Context, chat_id: ChatId) -> Re
send_msg(context, chat_id, &mut msg).await
}
async fn donation_request_maybe(context: &Context) -> Result<()> {
let secs_between_checks = 30 * 24 * 60 * 60;
let now = time();
let ts = context
.get_config_i64(Config::DonationRequestNextCheck)
.await?;
if ts > now {
return Ok(());
}
let msg_cnt = context.sql.count(
"SELECT COUNT(*) FROM msgs WHERE state>=? AND hidden=0",
(MessageState::OutDelivered,),
);
let ts = if ts == 0 || msg_cnt.await? < 100 {
now.saturating_add(secs_between_checks)
} else {
let mut msg = Message::new_text(stock_str::donation_request(context).await);
add_device_msg(context, None, Some(&mut msg)).await?;
i64::MAX
};
context
.set_config_internal(Config::DonationRequestNextCheck, Some(&ts.to_string()))
.await
}
/// Chat message list request options.
#[derive(Debug)]
pub struct MessageListOptions {
@@ -3622,15 +3699,31 @@ pub async fn get_past_chat_contacts(context: &Context, chat_id: ChatId) -> Resul
}
/// Creates a group chat with a given `name`.
/// Deprecated on 2025-06-21, use `create_group_ex()`.
pub async fn create_group_chat(
context: &Context,
protect: ProtectionStatus,
chat_name: &str,
name: &str,
) -> Result<ChatId> {
let chat_name = sanitize_single_line(chat_name);
create_group_ex(context, Some(protect), name).await
}
/// Creates a group chat.
///
/// * `encryption` - If `Some`, the chat is encrypted (with key-contacts) and can be protected.
/// * `name` - Chat name.
pub async fn create_group_ex(
context: &Context,
encryption: Option<ProtectionStatus>,
name: &str,
) -> Result<ChatId> {
let chat_name = sanitize_single_line(name);
ensure!(!chat_name.is_empty(), "Invalid chat name");
let grpid = create_id();
let grpid = match encryption {
Some(_) => create_id(),
None => String::new(),
};
let timestamp = create_smeared_timestamp(context);
let row_id = context
@@ -3650,7 +3743,8 @@ pub async fn create_group_chat(
chatlist_events::emit_chatlist_changed(context);
chatlist_events::emit_chatlist_item_changed(context, chat_id);
if protect == ProtectionStatus::Protected {
if encryption == Some(ProtectionStatus::Protected) {
let protect = ProtectionStatus::Protected;
chat_id
.set_protection_for_timestamp_sort(context, protect, timestamp, None)
.await?;
@@ -4079,8 +4173,6 @@ pub async fn remove_contact_from_chat(
"Cannot remove special contact"
);
let mut msg = Message::new(Viewtype::default());
let chat = Chat::load_from_db(context, chat_id).await?;
if chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast {
if !chat.is_self_in_chat(context).await? {
@@ -4110,19 +4202,10 @@ pub async fn remove_contact_from_chat(
// in case of the database becoming inconsistent due to a bug.
if let Some(contact) = Contact::get_by_id_optional(context, contact_id).await? {
if chat.typ == Chattype::Group && chat.is_promoted() {
msg.viewtype = Viewtype::Text;
if contact_id == ContactId::SELF {
msg.text = stock_str::msg_group_left_local(context, ContactId::SELF).await;
} else {
msg.text =
stock_str::msg_del_member_local(context, contact_id, ContactId::SELF)
.await;
}
msg.param.set_cmd(SystemMessage::MemberRemovedFromGroup);
msg.param.set(Param::Arg, contact.get_addr().to_lowercase());
msg.param
.set(Param::ContactAddedRemoved, contact.id.to_u32() as i32);
let res = send_msg(context, chat_id, &mut msg).await;
let addr = contact.get_addr();
let res = send_member_removal_msg(context, chat_id, contact_id, addr).await;
if contact_id == ContactId::SELF {
res?;
set_group_explicitly_left(context, &chat.grpid).await?;
@@ -4141,6 +4224,11 @@ pub async fn remove_contact_from_chat(
chat.sync_contacts(context).await.log_err(context).ok();
}
}
} else if chat.typ == Chattype::InBroadcast && contact_id == ContactId::SELF {
// For incoming broadcast channels, it's not possible to remove members,
// but it's possible to leave:
let self_addr = context.get_primary_self_addr().await?;
send_member_removal_msg(context, chat_id, contact_id, &self_addr).await?;
} else {
bail!("Cannot remove members from non-group chats.");
}
@@ -4148,6 +4236,28 @@ pub async fn remove_contact_from_chat(
Ok(())
}
async fn send_member_removal_msg(
context: &Context,
chat_id: ChatId,
contact_id: ContactId,
addr: &str,
) -> Result<MsgId> {
let mut msg = Message::new(Viewtype::Text);
if contact_id == ContactId::SELF {
msg.text = stock_str::msg_group_left_local(context, ContactId::SELF).await;
} else {
msg.text = stock_str::msg_del_member_local(context, contact_id, ContactId::SELF).await;
}
msg.param.set_cmd(SystemMessage::MemberRemovedFromGroup);
msg.param.set(Param::Arg, addr.to_lowercase());
msg.param
.set(Param::ContactAddedRemoved, contact_id.to_u32());
send_msg(context, chat_id, &mut msg).await
}
async fn set_group_explicitly_left(context: &Context, grpid: &str) -> Result<()> {
if !is_group_explicitly_left(context, grpid).await? {
context

View File

@@ -5,10 +5,10 @@ use crate::ephemeral::Timer;
use crate::headerdef::HeaderDef;
use crate::imex::{ImexMode, has_backup, imex};
use crate::message::{MessengerMessage, delete_msgs};
use crate::mimeparser;
use crate::mimeparser::{self, MimeMessage};
use crate::receive_imf::receive_imf;
use crate::test_utils::{
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, E2EE_INFO_MSGS, TestContext, TestContextManager,
TimeShiftFalsePositiveNote, sync,
};
use pretty_assertions::assert_eq;
@@ -374,7 +374,10 @@ async fn test_member_add_remove() -> Result<()> {
// Alice leaves the chat.
remove_contact_from_chat(&alice, alice_chat_id, ContactId::SELF).await?;
let sent = alice.pop_sent_msg().await;
assert_eq!(sent.load_from_db().await.get_text(), "You left the group.");
assert_eq!(
sent.load_from_db().await.get_text(),
stock_str::msg_group_left_local(&alice, ContactId::SELF).await
);
Ok(())
}
@@ -2101,7 +2104,7 @@ async fn test_forward_basic() -> Result<()> {
forward_msgs(&bob, &[msg.id], bob_chat.get_id()).await?;
let forwarded_msg = bob.pop_sent_msg().await;
assert_eq!(bob_chat.id.get_msg_cnt(&bob).await?, 2);
assert_eq!(bob_chat.id.get_msg_cnt(&bob).await?, E2EE_INFO_MSGS + 2);
assert_ne!(
forwarded_msg.load_from_db().await.rfc724_mid,
msg.rfc724_mid,
@@ -2129,7 +2132,7 @@ async fn test_forward_info_msg() -> Result<()> {
assert!(msg1.get_text().contains("bob@example.net"));
let chat_id2 = ChatId::create_for_contact(alice, bob_id).await?;
assert_eq!(get_chat_msgs(alice, chat_id2).await?.len(), 0);
assert_eq!(get_chat_msgs(alice, chat_id2).await?.len(), E2EE_INFO_MSGS);
forward_msgs(alice, &[msg1.id], chat_id2).await?;
let msg2 = alice.get_last_msg_in(chat_id2).await;
assert!(!msg2.is_info()); // forwarded info-messages lose their info-state
@@ -2515,22 +2518,34 @@ async fn test_resend_own_message() -> Result<()> {
let sent1_ts_sent = msg.timestamp_sent;
assert_eq!(msg.get_text(), "alice->bob");
assert_eq!(get_chat_contacts(&bob, msg.chat_id).await?.len(), 2);
assert_eq!(get_chat_msgs(&bob, msg.chat_id).await?.len(), 1);
assert_eq!(
get_chat_msgs(&bob, msg.chat_id).await?.len(),
E2EE_INFO_MSGS + 1
);
bob.recv_msg(&sent2).await;
assert_eq!(get_chat_contacts(&bob, msg.chat_id).await?.len(), 3);
assert_eq!(get_chat_msgs(&bob, msg.chat_id).await?.len(), 2);
assert_eq!(
get_chat_msgs(&bob, msg.chat_id).await?.len(),
E2EE_INFO_MSGS + 2
);
let received = bob.recv_msg_opt(&sent3).await;
// No message should actually be added since we already know this message:
assert!(received.is_none());
assert_eq!(get_chat_contacts(&bob, msg.chat_id).await?.len(), 3);
assert_eq!(get_chat_msgs(&bob, msg.chat_id).await?.len(), 2);
assert_eq!(
get_chat_msgs(&bob, msg.chat_id).await?.len(),
E2EE_INFO_MSGS + 2
);
// Fiona does not receive the first message, however, due to resending, she has a similar view as Alice and Bob
fiona.recv_msg(&sent2).await;
let msg = fiona.recv_msg(&sent3).await;
assert_eq!(msg.get_text(), "alice->bob");
assert_eq!(get_chat_contacts(&fiona, msg.chat_id).await?.len(), 3);
assert_eq!(get_chat_msgs(&fiona, msg.chat_id).await?.len(), 2);
assert_eq!(
get_chat_msgs(&fiona, msg.chat_id).await?.len(),
E2EE_INFO_MSGS + 2
);
let msg_from = Contact::get_by_id(&fiona, msg.get_from_id()).await?;
assert_eq!(msg_from.get_addr(), "alice@example.org");
assert!(sent1_ts_sent < msg.timestamp_sent);
@@ -2931,6 +2946,108 @@ async fn test_broadcast_channel_protected_listid() -> Result<()> {
Ok(())
}
/// Test that if Bob leaves a broadcast channel,
/// Alice (the channel owner) won't see him as a member anymore,
/// but won't be notified about this in any way.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_leave_broadcast() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
tcm.section("Alice creates broadcast channel with Bob.");
let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?;
let bob_contact = alice.add_or_lookup_contact(bob).await.id;
add_contact_to_chat(alice, alice_chat_id, bob_contact).await?;
tcm.section("Alice sends first message to broadcast.");
let sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
let bob_msg = bob.recv_msg(&sent_msg).await;
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 1);
// Clear events so that we can later check
// that the 'Broadcast channel left' message didn't trigger IncomingMsg:
alice.evtracker.clear_events();
// Shift the time so that we can later check the "Broadcast channel left" message's timestamp:
SystemTime::shift(Duration::from_secs(60));
tcm.section("Bob leaves the broadcast channel.");
let bob_chat_id = bob_msg.chat_id;
bob_chat_id.accept(bob).await?;
remove_contact_from_chat(bob, bob_chat_id, ContactId::SELF).await?;
let leave_msg = bob.pop_sent_msg().await;
alice.recv_msg_trash(&leave_msg).await;
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 0);
alice.emit_event(EventType::Test);
alice
.evtracker
.get_matching(|ev| match ev {
EventType::Test => true,
EventType::IncomingMsg { .. } => {
panic!("'Broadcast channel left' message should be silent")
}
EventType::MsgsNoticed(..) => {
panic!("'Broadcast channel left' message shouldn't clear notifications")
}
EventType::MsgsChanged { .. } => {
panic!("Broadcast channels should be left silently, without any message");
}
_ => false,
})
.await;
Ok(())
}
/// Tests that if Bob leaves a broadcast channel with one device,
/// the other device shows a correct info message "You left.".
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_leave_broadcast_multidevice() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob0 = &tcm.bob().await;
let bob1 = &tcm.bob().await;
tcm.section("Alice creates broadcast channel with Bob.");
let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?;
let bob_contact = alice.add_or_lookup_contact(bob0).await.id;
add_contact_to_chat(alice, alice_chat_id, bob_contact).await?;
tcm.section("Alice sends first message to broadcast.");
let sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
let bob0_hello = bob0.recv_msg(&sent_msg).await;
let bob1_hello = bob1.recv_msg(&sent_msg).await;
tcm.section("Bob leaves the broadcast channel with his first device.");
let bob_chat_id = bob0_hello.chat_id;
bob_chat_id.accept(bob0).await?;
remove_contact_from_chat(bob0, bob_chat_id, ContactId::SELF).await?;
let leave_msg = bob0.pop_sent_msg().await;
let parsed = MimeMessage::from_bytes(bob1, leave_msg.payload().as_bytes(), None).await?;
assert_eq!(
parsed.parts[0].msg,
stock_str::msg_group_left_remote(bob0).await
);
let rcvd = bob1.recv_msg(&leave_msg).await;
assert_eq!(rcvd.chat_id, bob1_hello.chat_id);
assert!(rcvd.is_info());
assert_eq!(rcvd.get_info_type(), SystemMessage::MemberRemovedFromGroup);
assert_eq!(
rcvd.text,
stock_str::msg_group_left_local(bob1, ContactId::SELF).await
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_for_contact_with_blocked() -> Result<()> {
let t = TestContext::new().await;
@@ -4349,13 +4466,13 @@ async fn test_receive_edit_request_after_removal() -> Result<()> {
let bob_msg = bob.recv_msg(&sent1).await;
let bob_chat_id = bob_msg.chat_id;
assert_eq!(bob_msg.text, "zext me in delra.cat");
assert_eq!(bob_chat_id.get_msg_cnt(bob).await?, 1);
assert_eq!(bob_chat_id.get_msg_cnt(bob).await?, E2EE_INFO_MSGS + 1);
delete_msgs(bob, &[bob_msg.id]).await?;
assert_eq!(bob_chat_id.get_msg_cnt(bob).await?, 0);
assert_eq!(bob_chat_id.get_msg_cnt(bob).await?, E2EE_INFO_MSGS);
bob.recv_msg_trash(&sent2).await;
assert_eq!(bob_chat_id.get_msg_cnt(bob).await?, 0);
assert_eq!(bob_chat_id.get_msg_cnt(bob).await?, E2EE_INFO_MSGS);
Ok(())
}
@@ -4444,28 +4561,34 @@ async fn test_send_delete_request() -> Result<()> {
// Alice sends a message, then sends a deletion request
let sent1 = alice.send_text(alice_chat.id, "wtf").await;
let alice_msg = sent1.load_from_db().await;
assert_eq!(alice_chat.id.get_msg_cnt(alice).await?, 2);
assert_eq!(alice_chat.id.get_msg_cnt(alice).await?, E2EE_INFO_MSGS + 2);
message::delete_msgs_ex(alice, &[alice_msg.id], true).await?;
let sent2 = alice.pop_sent_msg().await;
assert_eq!(alice_chat.id.get_msg_cnt(alice).await?, 1);
assert_eq!(alice_chat.id.get_msg_cnt(alice).await?, E2EE_INFO_MSGS + 1);
// Bob receives both messages and has nothing the end
let bob_msg = bob.recv_msg(&sent1).await;
assert_eq!(bob_msg.text, "wtf");
assert_eq!(bob_msg.chat_id.get_msg_cnt(bob).await?, 2);
assert_eq!(bob_msg.chat_id.get_msg_cnt(bob).await?, E2EE_INFO_MSGS + 2);
bob.recv_msg_opt(&sent2).await;
assert_eq!(bob_msg.chat_id.get_msg_cnt(bob).await?, 1);
assert_eq!(bob_msg.chat_id.get_msg_cnt(bob).await?, E2EE_INFO_MSGS + 1);
// Alice has another device, and there is also nothing at the end
let alice2 = &tcm.alice().await;
alice2.recv_msg(&sent0).await;
let alice2_msg = alice2.recv_msg(&sent1).await;
assert_eq!(alice2_msg.chat_id.get_msg_cnt(alice2).await?, 2);
assert_eq!(
alice2_msg.chat_id.get_msg_cnt(alice2).await?,
E2EE_INFO_MSGS + 2
);
alice2.recv_msg_opt(&sent2).await;
assert_eq!(alice2_msg.chat_id.get_msg_cnt(alice2).await?, 1);
assert_eq!(
alice2_msg.chat_id.get_msg_cnt(alice2).await?,
E2EE_INFO_MSGS + 1
);
Ok(())
}
@@ -4593,6 +4716,32 @@ async fn test_no_key_contacts_in_adhoc_chats() -> Result<()> {
Ok(())
}
/// Tests that key-contacts cannot be added to an unencrypted (ad hoc) group and the group and
/// messages report that they are unencrypted.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_unencrypted_group_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
let chat_id = create_group_ex(alice, None, "Group chat").await?;
let bob_key_contact_id = alice.add_or_lookup_contact_id(bob).await;
let charlie_address_contact_id = alice.add_or_lookup_address_contact_id(charlie).await;
let res = add_contact_to_chat(alice, chat_id, bob_key_contact_id).await;
assert!(res.is_err());
add_contact_to_chat(alice, chat_id, charlie_address_contact_id).await?;
let chat = Chat::load_from_db(alice, chat_id).await?;
assert!(!chat.is_encrypted(alice).await?);
let sent_msg = alice.send_text(chat_id, "Hello").await;
let msg = Message::load_from_db(alice, sent_msg.sender_msg_id).await?;
assert!(!msg.get_showpadlock());
Ok(())
}
/// Tests that avatar cannot be set in ad hoc groups.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_avatar_in_adhoc_chats() -> Result<()> {

View File

@@ -369,6 +369,9 @@ pub enum Config {
#[strum(props(default = "0"))]
DisableIdle,
/// Timestamp of the next check for donation request need.
DonationRequestNextCheck,
/// Defines the max. size (in bytes) of messages downloaded automatically.
/// 0 = no limit.
#[strum(props(default = "0"))]

View File

@@ -37,7 +37,7 @@ use crate::mimeparser::AvatarAction;
use crate::param::{Param, Params};
use crate::sync::{self, Sync::*};
use crate::tools::{SystemTime, duration_to_str, get_abs_path, time};
use crate::{chat, chatlist_events, stock_str};
use crate::{chat, chatlist_events, ensure_and_debug_assert_ne, stock_str};
/// Time during which a contact is considered as seen recently.
const SEEN_RECENTLY_SECONDS: i64 = 600;
@@ -1922,9 +1922,10 @@ pub(crate) async fn mark_contact_id_as_verified(
contact_id: ContactId,
verifier_id: ContactId,
) -> Result<()> {
debug_assert_ne!(
contact_id, verifier_id,
"Contact cannot be verified by self"
ensure_and_debug_assert_ne!(
contact_id,
verifier_id,
"Contact cannot be verified by self",
);
context
.sql

View File

@@ -27,6 +27,7 @@ use crate::events::{Event, EventEmitter, EventType, Events};
use crate::imap::{FolderMeaning, Imap, ServerMetadata};
use crate::key::{load_self_secret_key, self_fingerprint};
use crate::log::{info, warn};
use crate::logged_debug_assert;
use crate::login_param::{ConfiguredLoginParam, EnteredLoginParam};
use crate::message::{self, Message, MessageState, MsgId};
use crate::param::{Param, Params};
@@ -660,8 +661,16 @@ impl Context {
/// or [`Self::emit_msgs_changed_without_msg_id`] should be used
/// instead of this function.
pub fn emit_msgs_changed(&self, chat_id: ChatId, msg_id: MsgId) {
debug_assert!(!chat_id.is_unset());
debug_assert!(!msg_id.is_unset());
logged_debug_assert!(
self,
!chat_id.is_unset(),
"emit_msgs_changed: chat_id is unset."
);
logged_debug_assert!(
self,
!msg_id.is_unset(),
"emit_msgs_changed: msg_id is unset."
);
self.emit_event(EventType::MsgsChanged { chat_id, msg_id });
chatlist_events::emit_chatlist_changed(self);
@@ -670,7 +679,11 @@ impl Context {
/// Emits a MsgsChanged event with specified chat and without message id.
pub fn emit_msgs_changed_without_msg_id(&self, chat_id: ChatId) {
debug_assert!(!chat_id.is_unset());
logged_debug_assert!(
self,
!chat_id.is_unset(),
"emit_msgs_changed_without_msg_id: chat_id is unset."
);
self.emit_event(EventType::MsgsChanged {
chat_id,
@@ -1041,6 +1054,12 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"donation_request_next_check",
self.get_config_i64(Config::DonationRequestNextCheck)
.await?
.to_string(),
);
res.insert(
"first_key_contacts_msg_id",
self.sql

View File

@@ -8,7 +8,7 @@ use crate::chatlist::Chatlist;
use crate::constants::Chattype;
use crate::mimeparser::SystemMessage;
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, get_chat_msg};
use crate::test_utils::{E2EE_INFO_MSGS, TestContext, get_chat_msg};
use crate::tools::{SystemTime, create_outgoing_rfc724_mid};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -571,7 +571,7 @@ async fn test_get_next_msgs() -> Result<()> {
let alice_chat = alice.create_chat(&bob).await;
assert!(alice.get_next_msgs().await?.is_empty());
assert_eq!(alice.get_next_msgs().await?.len(), E2EE_INFO_MSGS);
assert!(bob.get_next_msgs().await?.is_empty());
let sent_msg = alice.send_text(alice_chat.id, "Hi Bob").await;

View File

@@ -178,7 +178,8 @@ mod tests {
let bob = TestContext::new_bob().await;
receive_imf(&bob, attachment_mime, false).await?;
let msg = bob.get_last_msg().await;
assert_eq!(msg.text, "Hello from Thunderbird!");
// Subject should be prepended because the attachment doesn't have "Chat-Version".
assert_eq!(msg.text, "Hello, Bob! Hello from Thunderbird!");
Ok(())
}

View File

@@ -213,17 +213,18 @@ impl Session {
let mut uid_message_ids: BTreeMap<u32, String> = BTreeMap::new();
uid_message_ids.insert(uid, rfc724_mid);
let (last_uid, _received) = self
.fetch_many_msgs(
context,
folder,
uidvalidity,
vec![uid],
&uid_message_ids,
false,
)
.await?;
if last_uid.is_none() {
let (sender, receiver) = async_channel::unbounded();
self.fetch_many_msgs(
context,
folder,
uidvalidity,
vec![uid],
&uid_message_ids,
false,
sender,
)
.await?;
if receiver.recv().await.is_err() {
bail!("Failed to fetch UID {uid}");
}
Ok(())
@@ -276,7 +277,7 @@ mod tests {
use crate::chat::{get_chat_msgs, send_msg};
use crate::ephemeral::Timer;
use crate::receive_imf::receive_imf_from_inbox;
use crate::test_utils::TestContext;
use crate::test_utils::{E2EE_INFO_MSGS, TestContext};
#[test]
fn test_downloadstate_values() {
@@ -458,7 +459,10 @@ mod tests {
.await?;
let msg = bob.get_last_msg().await;
let chat_id = msg.chat_id;
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), 1);
assert_eq!(
get_chat_msgs(&bob, chat_id).await?.len(),
E2EE_INFO_MSGS + 1
);
assert_eq!(msg.download_state(), DownloadState::Available);
// downloading the status update afterwards expands to nothing and moves the placeholder to trash-chat
@@ -471,7 +475,7 @@ mod tests {
None,
)
.await?;
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), 0);
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), E2EE_INFO_MSGS);
assert!(
Message::load_from_db_optional(&bob, msg.id)
.await?

View File

@@ -14,7 +14,7 @@ use std::{
};
use anyhow::{Context as _, Result, bail, ensure, format_err};
use async_channel::Receiver;
use async_channel::{self, Receiver, Sender};
use async_imap::types::{Fetch, Flag, Name, NameAttribute, UnsolicitedResponse};
use deltachat_contact_tools::ContactAddress;
use futures::{FutureExt as _, StreamExt, TryStreamExt};
@@ -562,7 +562,7 @@ impl Imap {
let read_cnt = msgs.len();
let download_limit = context.download_limit().await?;
let mut uids_fetch = Vec::<(_, bool /* partially? */)>::with_capacity(msgs.len() + 1);
let mut uids_fetch = Vec::<(u32, bool /* partially? */)>::with_capacity(msgs.len() + 1);
let mut uid_message_ids = BTreeMap::new();
let mut largest_uid_skipped = None;
let delete_target = context.get_delete_msgs_target().await?;
@@ -695,51 +695,72 @@ impl Imap {
self.connectivity.set_working(context).await;
}
// Actually download messages.
let mut largest_uid_fetched: u32 = 0;
let mut received_msgs = Vec::with_capacity(uids_fetch.len());
let mut uids_fetch_in_batch = Vec::with_capacity(max(uids_fetch.len(), 1));
let mut fetch_partially = false;
uids_fetch.push((0, !uids_fetch.last().unwrap_or(&(0, false)).1));
for (uid, fp) in uids_fetch {
if fp != fetch_partially {
let (largest_uid_fetched_in_batch, received_msgs_in_batch) = session
.fetch_many_msgs(
context,
folder,
uid_validity,
uids_fetch_in_batch.split_off(0),
&uid_message_ids,
fetch_partially,
)
.await
.context("fetch_many_msgs")?;
received_msgs.extend(received_msgs_in_batch);
largest_uid_fetched = max(
largest_uid_fetched,
largest_uid_fetched_in_batch.unwrap_or(0),
);
fetch_partially = fp;
}
uids_fetch_in_batch.push(uid);
}
let (sender, receiver) = async_channel::unbounded();
// Advance uid_next to the maximum of the largest known UID plus 1
// and mailbox UIDNEXT.
// Largest known UID is normally less than UIDNEXT,
// but a message may have arrived between determining UIDNEXT
// and executing the FETCH command.
let mut received_msgs = Vec::with_capacity(uids_fetch.len());
let mailbox_uid_next = session
.selected_mailbox
.as_ref()
.with_context(|| format!("Expected {folder:?} to be selected"))?
.uid_next
.unwrap_or_default();
let new_uid_next = max(
max(largest_uid_fetched, largest_uid_skipped.unwrap_or(0)) + 1,
mailbox_uid_next,
);
let update_uids_future = async {
let mut largest_uid_fetched: u32 = 0;
while let Ok((uid, received_msg_opt)) = receiver.recv().await {
largest_uid_fetched = max(largest_uid_fetched, uid);
if let Some(received_msg) = received_msg_opt {
received_msgs.push(received_msg)
}
}
largest_uid_fetched
};
let actually_download_messages_future = async move {
let mut uids_fetch_in_batch = Vec::with_capacity(max(uids_fetch.len(), 1));
let mut fetch_partially = false;
uids_fetch.push((0, !uids_fetch.last().unwrap_or(&(0, false)).1));
for (uid, fp) in uids_fetch {
if fp != fetch_partially {
session
.fetch_many_msgs(
context,
folder,
uid_validity,
uids_fetch_in_batch.split_off(0),
&uid_message_ids,
fetch_partially,
sender.clone(),
)
.await
.context("fetch_many_msgs")?;
fetch_partially = fp;
}
uids_fetch_in_batch.push(uid);
}
anyhow::Ok(())
};
let (largest_uid_fetched, fetch_res) =
tokio::join!(update_uids_future, actually_download_messages_future);
// Advance uid_next to the largest fetched UID plus 1.
//
// This may be larger than `mailbox_uid_next`
// if the message has arrived after selecting mailbox
// and determining its UIDNEXT and before prefetch.
let mut new_uid_next = largest_uid_fetched + 1;
if fetch_res.is_ok() {
// If we have successfully fetched all messages we planned during prefetch,
// then we have covered at least the range between old UIDNEXT
// and UIDNEXT of the mailbox at the time of selecting it.
new_uid_next = max(new_uid_next, mailbox_uid_next);
new_uid_next = max(new_uid_next, largest_uid_skipped.unwrap_or(0) + 1);
}
if new_uid_next > old_uid_next {
set_uid_next(context, folder, new_uid_next).await?;
}
@@ -752,6 +773,10 @@ impl Imap {
chat::mark_old_messages_as_noticed(context, received_msgs).await?;
// Now fail if fetching failed, so we will
// establish a new session if this one is broken.
fetch_res?;
Ok(read_cnt > 0)
}
@@ -1300,9 +1325,19 @@ impl Session {
/// Fetches a list of messages by server UID.
///
/// Returns the last UID fetched successfully and the info about each downloaded message.
/// Sends pairs of UID and info about each downloaded message to the provided channel.
/// Received message info is optional because UID may be ignored
/// if the message has a `\Deleted` flag.
///
/// The channel is used to return the results because the function may fail
/// due to network errors before it finishes fetching all the messages.
/// In this case caller still may want to process all the results
/// received over the channel and persist last seen UID in the database
/// before bubbling up the failure.
///
/// If the message is incorrect or there is a failure to write a message to the database,
/// it is skipped and the error is logged.
#[expect(clippy::too_many_arguments)]
pub(crate) async fn fetch_many_msgs(
&mut self,
context: &Context,
@@ -1311,12 +1346,10 @@ impl Session {
request_uids: Vec<u32>,
uid_message_ids: &BTreeMap<u32, String>,
fetch_partially: bool,
) -> Result<(Option<u32>, Vec<ReceivedMsg>)> {
let mut last_uid = None;
let mut received_msgs = Vec::new();
received_msgs_channel: Sender<(u32, Option<ReceivedMsg>)>,
) -> Result<()> {
if request_uids.is_empty() {
return Ok((last_uid, received_msgs));
return Ok(());
}
for (request_uids, set) in build_sequence_sets(&request_uids)? {
@@ -1402,7 +1435,7 @@ impl Session {
if is_deleted {
info!(context, "Not processing deleted msg {}.", request_uid);
last_uid = Some(request_uid);
received_msgs_channel.send((request_uid, None)).await?;
continue;
}
@@ -1413,7 +1446,7 @@ impl Session {
context,
"Not processing message {} without a BODY.", request_uid
);
last_uid = Some(request_uid);
received_msgs_channel.send((request_uid, None)).await?;
continue;
};
@@ -1445,15 +1478,15 @@ impl Session {
.await
{
Ok(received_msg) => {
if let Some(m) = received_msg {
received_msgs.push(m);
}
received_msgs_channel
.send((request_uid, received_msg))
.await?;
}
Err(err) => {
warn!(context, "receive_imf error: {:#}.", err);
received_msgs_channel.send((request_uid, None)).await?;
}
};
last_uid = Some(request_uid)
}
// If we don't process the whole response, IMAP client is left in a broken state where
@@ -1477,7 +1510,7 @@ impl Session {
}
}
Ok((last_uid, received_msgs))
Ok(())
}
/// Retrieves server metadata if it is supported.

View File

@@ -8,15 +8,13 @@ use tokio::io::BufWriter;
use super::capabilities::Capabilities;
use crate::context::Context;
use crate::log::{info, warn};
use crate::log::{LoggingStream, info, warn};
use crate::login_param::{ConnectionCandidate, ConnectionSecurity};
use crate::net::dns::{lookup_host_with_cache, update_connect_timestamp};
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionStream;
use crate::net::tls::wrap_tls;
use crate::net::{
connect_tcp_inner, connect_tls_inner, run_connection_attempts, update_connection_history,
};
use crate::net::{connect_tcp_inner, run_connection_attempts, update_connection_history};
use crate::tools::time;
#[derive(Debug)]
@@ -126,12 +124,12 @@ impl Client {
);
let res = match security {
ConnectionSecurity::Tls => {
Client::connect_secure(resolved_addr, host, strict_tls).await
Client::connect_secure(context, resolved_addr, host, strict_tls).await
}
ConnectionSecurity::Starttls => {
Client::connect_starttls(resolved_addr, host, strict_tls).await
Client::connect_starttls(context, resolved_addr, host, strict_tls).await
}
ConnectionSecurity::Plain => Client::connect_insecure(resolved_addr).await,
ConnectionSecurity::Plain => Client::connect_insecure(context, resolved_addr).await,
};
match res {
Ok(client) => {
@@ -202,8 +200,17 @@ impl Client {
}
}
async fn connect_secure(addr: SocketAddr, hostname: &str, strict_tls: bool) -> Result<Self> {
let tls_stream = connect_tls_inner(addr, hostname, strict_tls, alpn(addr.port())).await?;
async fn connect_secure(
context: &Context,
addr: SocketAddr,
hostname: &str,
strict_tls: bool,
) -> Result<Self> {
let tcp_stream = connect_tcp_inner(addr).await?;
let account_id = context.get_id();
let events = context.events.clone();
let logging_stream = LoggingStream::new(tcp_stream, account_id, events)?;
let tls_stream = wrap_tls(strict_tls, hostname, alpn(addr.port()), logging_stream).await?;
let buffered_stream = BufWriter::new(tls_stream);
let session_stream: Box<dyn SessionStream> = Box::new(buffered_stream);
let mut client = Client::new(session_stream);
@@ -214,9 +221,12 @@ impl Client {
Ok(client)
}
async fn connect_insecure(addr: SocketAddr) -> Result<Self> {
async fn connect_insecure(context: &Context, addr: SocketAddr) -> Result<Self> {
let tcp_stream = connect_tcp_inner(addr).await?;
let buffered_stream = BufWriter::new(tcp_stream);
let account_id = context.get_id();
let events = context.events.clone();
let logging_stream = LoggingStream::new(tcp_stream, account_id, events)?;
let buffered_stream = BufWriter::new(logging_stream);
let session_stream: Box<dyn SessionStream> = Box::new(buffered_stream);
let mut client = Client::new(session_stream);
let _greeting = client
@@ -226,9 +236,18 @@ impl Client {
Ok(client)
}
async fn connect_starttls(addr: SocketAddr, host: &str, strict_tls: bool) -> Result<Self> {
async fn connect_starttls(
context: &Context,
addr: SocketAddr,
host: &str,
strict_tls: bool,
) -> Result<Self> {
let tcp_stream = connect_tcp_inner(addr).await?;
let account_id = context.get_id();
let events = context.events.clone();
let tcp_stream = LoggingStream::new(tcp_stream, account_id, events)?;
// Run STARTTLS command and convert the client back into a stream.
let buffered_tcp_stream = BufWriter::new(tcp_stream);
let mut client = async_imap::Client::new(buffered_tcp_stream);
@@ -246,7 +265,6 @@ impl Client {
let tls_stream = wrap_tls(strict_tls, host, &[], tcp_stream)
.await
.context("STARTTLS upgrade failed")?;
let buffered_stream = BufWriter::new(tls_stream);
let session_stream: Box<dyn SessionStream> = Box::new(buffered_stream);
let client = Client::new(session_stream);

View File

@@ -4,6 +4,10 @@
use crate::context::Context;
mod stream;
pub(crate) use stream::LoggingStream;
macro_rules! info {
($ctx:expr, $msg:expr) => {
info!($ctx, $msg,)

161
src/log/stream.rs Normal file
View File

@@ -0,0 +1,161 @@
//! Stream that logs errors as events.
//!
//! This stream can be used to wrap IMAP,
//! SMTP and HTTP streams so errors
//! that occur are logged before
//! they are processed.
use std::net::SocketAddr;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use anyhow::{Context as _, Result};
use pin_project::pin_project;
use crate::events::{Event, EventType, Events};
use crate::net::session::SessionStream;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
#[derive(Debug)]
struct Metrics {
/// Total number of bytes read.
pub total_read: usize,
/// Total number of bytes written.
pub total_written: usize,
}
impl Metrics {
fn new() -> Self {
Self {
total_read: 0,
total_written: 0,
}
}
}
/// Stream that logs errors to the event channel.
#[derive(Debug)]
#[pin_project]
pub(crate) struct LoggingStream<S: SessionStream> {
#[pin]
inner: S,
/// Account ID for logging.
account_id: u32,
/// Event channel.
events: Events,
/// Metrics for this stream.
metrics: Metrics,
/// Peer address at the time of creation.
///
/// Socket may become disconnected later,
/// so we save it when `LoggingStream` is created.
peer_addr: SocketAddr,
}
impl<S: SessionStream> LoggingStream<S> {
pub fn new(inner: S, account_id: u32, events: Events) -> Result<Self> {
let peer_addr: SocketAddr = inner
.peer_addr()
.context("Attempt to create LoggingStream over an unconnected stream")?;
Ok(Self {
inner,
account_id,
events,
metrics: Metrics::new(),
peer_addr,
})
}
}
impl<S: SessionStream> AsyncRead for LoggingStream<S> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
let this = self.project();
let old_remaining = buf.remaining();
let res = this.inner.poll_read(cx, buf);
if let Poll::Ready(Err(ref err)) = res {
let peer_addr = this.peer_addr;
let log_message = format!(
"Read error on stream {peer_addr:?} after reading {} and writing {} bytes: {err}.",
this.metrics.total_read, this.metrics.total_written
);
this.events.emit(Event {
id: *this.account_id,
typ: EventType::Warning(log_message),
});
}
let n = old_remaining - buf.remaining();
this.metrics.total_read = this.metrics.total_read.saturating_add(n);
res
}
}
impl<S: SessionStream> AsyncWrite for LoggingStream<S> {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
let this = self.project();
let res = this.inner.poll_write(cx, buf);
if let Poll::Ready(Ok(n)) = res {
this.metrics.total_written = this.metrics.total_written.saturating_add(n);
}
res
}
fn poll_flush(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> Poll<std::io::Result<()>> {
self.project().inner.poll_flush(cx)
}
fn poll_shutdown(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> Poll<std::io::Result<()>> {
self.project().inner.poll_shutdown(cx)
}
fn poll_write_vectored(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[std::io::IoSlice<'_>],
) -> Poll<std::io::Result<usize>> {
let this = self.project();
let res = this.inner.poll_write_vectored(cx, bufs);
if let Poll::Ready(Ok(n)) = res {
this.metrics.total_written = this.metrics.total_written.saturating_add(n);
}
res
}
fn is_write_vectored(&self) -> bool {
self.inner.is_write_vectored()
}
}
impl<S: SessionStream> SessionStream for LoggingStream<S> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.inner.set_read_timeout(timeout)
}
fn peer_addr(&self) -> Result<SocketAddr> {
self.inner.peer_addr()
}
}

View File

@@ -963,6 +963,7 @@ impl Message {
| SystemMessage::SecurejoinMessage
| SystemMessage::LocationStreamingEnabled
| SystemMessage::LocationOnly
| SystemMessage::ChatE2ee
| SystemMessage::ChatProtectionEnabled
| SystemMessage::ChatProtectionDisabled
| SystemMessage::InvalidUnencryptedMail

View File

@@ -7,7 +7,7 @@ use crate::config::Config;
use crate::reaction::send_reaction;
use crate::receive_imf::receive_imf;
use crate::test_utils;
use crate::test_utils::{TestContext, TestContextManager};
use crate::test_utils::{E2EE_INFO_MSGS, TestContext, TestContextManager};
#[test]
fn test_guess_msgtype_from_suffix() {
@@ -347,7 +347,7 @@ async fn test_markseen_msgs() -> Result<()> {
let chats = Chatlist::try_load(&bob, 0, None, None).await?;
assert_eq!(chats.len(), 1);
let msgs = chat::get_chat_msgs(&bob, bob_chat_id).await?;
assert_eq!(msgs.len(), 2);
assert_eq!(msgs.len(), E2EE_INFO_MSGS + 2);
assert_eq!(bob.get_fresh_msgs().await?.len(), 0);
// that has no effect in contact request
@@ -358,7 +358,7 @@ async fn test_markseen_msgs() -> Result<()> {
assert_eq!(bob_chat.blocked, Blocked::Request);
let msgs = chat::get_chat_msgs(&bob, bob_chat_id).await?;
assert_eq!(msgs.len(), 2);
assert_eq!(msgs.len(), E2EE_INFO_MSGS + 2);
bob_chat_id.accept(&bob).await.unwrap();
// bob sends to alice,
@@ -761,19 +761,22 @@ async fn test_delete_msgs_sync() -> Result<()> {
// Alice sends a messsage and receives it on the other device
let sent1 = alice.send_text(alice_chat_id, "foo").await;
assert_eq!(alice_chat_id.get_msg_cnt(alice).await?, 1);
assert_eq!(alice_chat_id.get_msg_cnt(alice).await?, E2EE_INFO_MSGS + 1);
let msg = alice2.recv_msg(&sent1).await;
let alice2_chat_id = msg.chat_id;
assert_eq!(alice2.get_last_msg_in(alice2_chat_id).await.id, msg.id);
assert_eq!(alice2_chat_id.get_msg_cnt(alice2).await?, 1);
assert_eq!(
alice2_chat_id.get_msg_cnt(alice2).await?,
E2EE_INFO_MSGS + 1
);
// Alice deletes the message; this should happen on both devices as well
delete_msgs(alice, &[sent1.sender_msg_id]).await?;
assert_eq!(alice_chat_id.get_msg_cnt(alice).await?, 0);
assert_eq!(alice_chat_id.get_msg_cnt(alice).await?, E2EE_INFO_MSGS);
test_utils::sync(alice, alice2).await;
assert_eq!(alice2_chat_id.get_msg_cnt(alice2).await?, 0);
assert_eq!(alice2_chat_id.get_msg_cnt(alice2).await?, E2EE_INFO_MSGS);
Ok(())
}

View File

@@ -20,6 +20,7 @@ use crate::constants::{Chattype, DC_FROM_HANDSHAKE};
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::e2ee::EncryptHelper;
use crate::ensure_and_debug_assert;
use crate::ephemeral::Timer as EphemeralTimer;
use crate::key::self_fingerprint;
use crate::key::{DcKey, SignedPublicKey};
@@ -308,7 +309,7 @@ impl MimeFactory {
} else if id == ContactId::SELF {
member_fingerprints.push(self_fingerprint.to_string());
} else {
debug_assert!(member_fingerprints.is_empty(), "If some past member is a key-contact, all other past members should be key-contacts too");
ensure_and_debug_assert!(member_fingerprints.is_empty(), "If some past member is a key-contact, all other past members should be key-contacts too");
}
}
member_timestamps.push(add_timestamp);
@@ -359,7 +360,7 @@ impl MimeFactory {
// if we are leaving the group.
past_member_fingerprints.push(self_fingerprint.to_string());
} else {
debug_assert!(past_member_fingerprints.is_empty(), "If some past member is a key-contact, all other past members should be key-contacts too");
ensure_and_debug_assert!(past_member_fingerprints.is_empty(), "If some past member is a key-contact, all other past members should be key-contacts too");
}
}
}
@@ -367,8 +368,14 @@ impl MimeFactory {
}
}
debug_assert!(member_timestamps.len() >= to.len());
debug_assert!(member_fingerprints.is_empty() || member_fingerprints.len() >= to.len());
ensure_and_debug_assert!(
member_timestamps.len() >= to.len(),
"member_timestamps.len() ({}) < to.len() ({})",
member_timestamps.len(), to.len());
ensure_and_debug_assert!(
member_fingerprints.is_empty() || member_fingerprints.len() >= to.len(),
"member_fingerprints.len() ({}) < to.len() ({})",
member_fingerprints.len(), to.len());
if to.len() > 1 {
if let Some(position) = to.iter().position(|(_, x)| x == &from_addr) {
@@ -445,9 +452,13 @@ impl MimeFactory {
};
let attach_selfavatar = Self::should_attach_selfavatar(context, &msg).await;
debug_assert!(
ensure_and_debug_assert!(
member_timestamps.is_empty()
|| to.len() + past_members.len() == member_timestamps.len()
|| to.len() + past_members.len() == member_timestamps.len(),
"to.len() ({}) + past_members.len() ({}) != member_timestamps.len() ({})",
to.len(),
past_members.len(),
member_timestamps.len(),
);
let factory = MimeFactory {
@@ -668,9 +679,13 @@ impl MimeFactory {
));
}
debug_assert!(
ensure_and_debug_assert!(
self.member_timestamps.is_empty()
|| to.len() + past_members.len() == self.member_timestamps.len()
|| to.len() + past_members.len() == self.member_timestamps.len(),
"to.len() ({}) + past_members.len() ({}) != self.member_timestamps.len() ({})",
to.len(),
past_members.len(),
self.member_timestamps.len(),
);
if to.is_empty() {
to.push(hidden_recipients());
@@ -789,7 +804,7 @@ impl MimeFactory {
}
if let Loaded::Message { chat, .. } = &self.loaded {
if chat.typ == Chattype::OutBroadcast {
if chat.typ == Chattype::OutBroadcast || chat.typ == Chattype::InBroadcast {
headers.push((
"List-ID",
mail_builder::headers::text::Text::new(format!(
@@ -1319,7 +1334,10 @@ impl MimeFactory {
}
}
if chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast {
if chat.typ == Chattype::Group
|| chat.typ == Chattype::OutBroadcast
|| chat.typ == Chattype::InBroadcast
{
headers.push((
"Chat-Group-Name",
mail_builder::headers::text::Text::new(chat.name.to_string()).into(),

View File

@@ -181,10 +181,10 @@ pub enum SystemMessage {
/// Chat ephemeral message timer is changed.
EphemeralTimerChanged = 10,
/// "Messages are guaranteed to be end-to-end encrypted from now on."
/// "Messages are end-to-end encrypted."
ChatProtectionEnabled = 11,
/// "%1$s sent a message from another device."
/// "%1$s sent a message from another device.", deprecated 2025-07
ChatProtectionDisabled = 12,
/// Message can't be sent because of `Invalid unencrypted mail to <>`
@@ -213,6 +213,9 @@ pub enum SystemMessage {
/// This message contains a users iroh node address.
IrohNodeAddr = 40,
/// "Messages are end-to-end encrypted."
ChatE2ee = 50,
}
const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup";
@@ -246,6 +249,7 @@ impl MimeMessage {
MimeMessage::merge_headers(
context,
&mut headers,
&mut headers_removed,
&mut recipients,
&mut past_members,
&mut from,
@@ -273,6 +277,7 @@ impl MimeMessage {
MimeMessage::merge_headers(
context,
&mut headers,
&mut headers_removed,
&mut recipients,
&mut past_members,
&mut from,
@@ -446,26 +451,11 @@ impl MimeMessage {
});
if let (Ok(mail), true) = (mail, is_encrypted) {
if !signatures.is_empty() {
// Remove unsigned opportunistically protected headers from messages considered
// Autocrypt-encrypted / displayed with padlock.
// For "Subject" see <https://github.com/deltachat/deltachat-core-rust/issues/1790>.
for h in [
HeaderDef::Subject,
HeaderDef::ChatGroupId,
HeaderDef::ChatGroupName,
HeaderDef::ChatGroupNameChanged,
HeaderDef::ChatGroupNameTimestamp,
HeaderDef::ChatGroupAvatar,
HeaderDef::ChatGroupMemberRemoved,
HeaderDef::ChatGroupMemberAdded,
HeaderDef::ChatGroupMemberTimestamps,
HeaderDef::ChatGroupPastMembers,
HeaderDef::ChatDelete,
HeaderDef::ChatEdit,
HeaderDef::ChatUserAvatar,
] {
remove_header(&mut headers, h.get_headername(), &mut headers_removed);
}
// Unsigned "Subject" mustn't be prepended to messages shown as encrypted
// (<https://github.com/deltachat/deltachat-core-rust/issues/1790>).
// Other headers are removed by `MimeMessage::merge_headers()` except for "List-ID".
remove_header(&mut headers, "subject", &mut headers_removed);
remove_header(&mut headers, "list-id", &mut headers_removed);
}
// let known protected headers from the decrypted
@@ -478,6 +468,7 @@ impl MimeMessage {
MimeMessage::merge_headers(
context,
&mut headers,
&mut headers_removed,
&mut recipients,
&mut past_members,
&mut inner_from,
@@ -1558,6 +1549,7 @@ impl MimeMessage {
fn merge_headers(
context: &Context,
headers: &mut HashMap<String, String>,
headers_removed: &mut HashSet<String>,
recipients: &mut Vec<SingleInfo>,
past_members: &mut Vec<SingleInfo>,
from: &mut Option<SingleInfo>,
@@ -1565,23 +1557,25 @@ impl MimeMessage {
chat_disposition_notification_to: &mut Option<SingleInfo>,
fields: &[mailparse::MailHeader<'_>],
) {
headers.retain(|k, _| {
!is_protected(k) || {
headers_removed.insert(k.to_string());
false
}
});
for field in fields {
// lowercasing all headers is technically not correct, but makes things work better
let key = field.get_key().to_lowercase();
if !headers.contains_key(&key) || // key already exists, only overwrite known types (protected headers)
is_known(&key) || key.starts_with("chat-")
{
if key == HeaderDef::ChatDispositionNotificationTo.get_headername() {
match addrparse_header(field) {
Ok(addrlist) => {
*chat_disposition_notification_to = addrlist.extract_single_info();
}
Err(e) => warn!(context, "Could not read {} address: {}", key, e),
if key == HeaderDef::ChatDispositionNotificationTo.get_headername() {
match addrparse_header(field) {
Ok(addrlist) => {
*chat_disposition_notification_to = addrlist.extract_single_info();
}
} else {
let value = field.get_value();
headers.insert(key.to_string(), value);
Err(e) => warn!(context, "Could not read {} address: {}", key, e),
}
} else {
let value = field.get_value();
headers.insert(key.to_string(), value);
}
}
let recipients_new = get_recipients(fields);
@@ -2009,26 +2003,30 @@ pub(crate) fn parse_message_id(ids: &str) -> Result<String> {
}
}
/// Returns true if the header overwrites outer header
/// when it comes from protected headers.
fn is_known(key: &str) -> bool {
matches!(
key,
"return-path"
| "date"
| "from"
| "sender"
| "reply-to"
| "to"
| "cc"
| "bcc"
| "message-id"
| "in-reply-to"
| "references"
| "subject"
| "secure-join"
| "list-id"
)
/// Returns whether the outer header value must be ignored if the message contains a signed (and
/// optionally encrypted) part.
///
/// NB: There are known cases when Subject and List-ID only appear in the outer headers of
/// signed-only messages. Such messages are shown as unencrypted anyway.
fn is_protected(key: &str) -> bool {
key.starts_with("chat-")
|| matches!(
key,
"return-path"
| "auto-submitted"
| "autocrypt-setup-message"
| "date"
| "from"
| "sender"
| "reply-to"
| "to"
| "cc"
| "bcc"
| "message-id"
| "in-reply-to"
| "references"
| "secure-join"
)
}
/// Returns if the header is hidden and must be ignored in the IMF section.

View File

@@ -1402,6 +1402,26 @@ async fn test_x_microsoft_original_message_id_precedence() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_extra_imf_chat_header() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let chat_id = t.get_self_chat().await.id;
chat::send_text_msg(t, chat_id, "hi!".to_string()).await?;
let sent_msg = t.pop_sent_msg().await;
// Check removal of some nonexistent "Chat-*" header to protect the code from future breakages.
let payload = sent_msg
.payload
.replace("Message-ID:", "Chat-Forty-Two: 42\r\nMessage-ID:");
let msg = MimeMessage::from_bytes(t, payload.as_bytes(), None)
.await
.unwrap();
assert!(msg.headers.contains_key("chat-version"));
assert!(!msg.headers.contains_key("chat-forty-two"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_long_in_reply_to() -> Result<()> {
let t = TestContext::new_alice().await;

View File

@@ -16,12 +16,14 @@ use crate::sql::Sql;
use crate::tools::time;
pub(crate) mod dns;
pub(crate) mod error_capturing_stream;
pub(crate) mod http;
pub(crate) mod proxy;
pub(crate) mod session;
pub(crate) mod tls;
use dns::lookup_host_with_cache;
pub(crate) use error_capturing_stream::ErrorCapturingStream;
pub use http::{Response as HttpResponse, read_url, read_url_blob};
use tls::wrap_tls;
@@ -105,7 +107,7 @@ pub(crate) async fn load_connection_timestamp(
/// to the network, which is important to reduce the latency of interactive protocols such as IMAP.
pub(crate) async fn connect_tcp_inner(
addr: SocketAddr,
) -> Result<Pin<Box<TimeoutStream<TcpStream>>>> {
) -> Result<Pin<Box<ErrorCapturingStream<TimeoutStream<TcpStream>>>>> {
let tcp_stream = timeout(TIMEOUT, TcpStream::connect(addr))
.await
.context("connection timeout")?
@@ -118,7 +120,9 @@ pub(crate) async fn connect_tcp_inner(
timeout_stream.set_write_timeout(Some(TIMEOUT));
timeout_stream.set_read_timeout(Some(TIMEOUT));
Ok(Box::pin(timeout_stream))
let error_capturing_stream = ErrorCapturingStream::new(timeout_stream);
Ok(Box::pin(error_capturing_stream))
}
/// Attempts to establish TLS connection
@@ -235,7 +239,7 @@ pub(crate) async fn connect_tcp(
host: &str,
port: u16,
load_cache: bool,
) -> Result<Pin<Box<TimeoutStream<TcpStream>>>> {
) -> Result<Pin<Box<ErrorCapturingStream<TimeoutStream<TcpStream>>>>> {
let connection_futures = lookup_host_with_cache(context, host, port, "", load_cache)
.await?
.into_iter()

View File

@@ -0,0 +1,136 @@
use std::io::IoSlice;
use std::net::SocketAddr;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use tokio::io::{self, AsyncRead, AsyncWrite, ReadBuf};
use pin_project::pin_project;
use crate::net::SessionStream;
/// Stream that remembers the first error
/// and keeps returning it afterwards.
///
/// It is needed to avoid accidentally using
/// the stream after read timeout.
#[derive(Debug)]
#[pin_project]
pub(crate) struct ErrorCapturingStream<T: AsyncRead + AsyncWrite + std::fmt::Debug> {
#[pin]
inner: T,
/// If true, the stream has already returned an error once.
///
/// All read and write operations return error in this case.
is_broken: bool,
}
impl<T: AsyncRead + AsyncWrite + std::fmt::Debug> ErrorCapturingStream<T> {
pub fn new(inner: T) -> Self {
Self {
inner,
is_broken: false,
}
}
/// Gets a reference to the underlying stream.
pub fn get_ref(&self) -> &T {
&self.inner
}
/// Gets a pinned mutable reference to the underlying stream.
pub fn get_pin_mut(self: Pin<&mut Self>) -> Pin<&mut T> {
self.project().inner
}
}
impl<T: AsyncRead + AsyncWrite + std::fmt::Debug> AsyncRead for ErrorCapturingStream<T> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf,
) -> Poll<io::Result<()>> {
let this = self.project();
if *this.is_broken {
return Poll::Ready(Err(io::Error::other("Broken stream")));
}
let res = this.inner.poll_read(cx, buf);
if let Poll::Ready(Err(_)) = res {
*this.is_broken = true;
}
res
}
}
impl<T: AsyncRead + AsyncWrite + std::fmt::Debug> AsyncWrite for ErrorCapturingStream<T> {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let this = self.project();
if *this.is_broken {
return Poll::Ready(Err(io::Error::other("Broken stream")));
}
let res = this.inner.poll_write(cx, buf);
if let Poll::Ready(Err(_)) = res {
*this.is_broken = true;
}
res
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
let this = self.project();
if *this.is_broken {
return Poll::Ready(Err(io::Error::other("Broken stream")));
}
let res = this.inner.poll_flush(cx);
if let Poll::Ready(Err(_)) = res {
*this.is_broken = true;
}
res
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
let this = self.project();
if *this.is_broken {
return Poll::Ready(Err(io::Error::other("Broken stream")));
}
let res = this.inner.poll_shutdown(cx);
if let Poll::Ready(Err(_)) = res {
*this.is_broken = true;
}
res
}
fn poll_write_vectored(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[IoSlice<'_>],
) -> Poll<io::Result<usize>> {
let this = self.project();
if *this.is_broken {
return Poll::Ready(Err(io::Error::other("Broken stream")));
}
let res = this.inner.poll_write_vectored(cx, bufs);
if let Poll::Ready(Err(_)) = res {
*this.is_broken = true;
}
res
}
fn is_write_vectored(&self) -> bool {
self.inner.is_write_vectored()
}
}
impl<T: SessionStream> SessionStream for ErrorCapturingStream<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.inner.set_read_timeout(timeout)
}
fn peer_addr(&self) -> anyhow::Result<SocketAddr> {
self.inner.peer_addr()
}
}

View File

@@ -21,9 +21,9 @@ use url::Url;
use crate::config::Config;
use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
use crate::context::Context;
use crate::net::connect_tcp;
use crate::net::session::SessionStream;
use crate::net::tls::wrap_rustls;
use crate::net::{ErrorCapturingStream, connect_tcp};
use crate::sql::Sql;
/// Default SOCKS5 port according to [RFC 1928](https://tools.ietf.org/html/rfc1928).
@@ -118,7 +118,7 @@ impl Socks5Config {
target_host: &str,
target_port: u16,
load_dns_cache: bool,
) -> Result<Socks5Stream<Pin<Box<TimeoutStream<TcpStream>>>>> {
) -> Result<Socks5Stream<Pin<Box<ErrorCapturingStream<TimeoutStream<TcpStream>>>>>> {
let tcp_stream = connect_tcp(context, &self.host, self.port, load_dns_cache)
.await
.context("Failed to connect to SOCKS5 proxy")?;

View File

@@ -1,62 +1,103 @@
use anyhow::Result;
use fast_socks5::client::Socks5Stream;
use std::net::SocketAddr;
use std::pin::Pin;
use std::time::Duration;
use tokio::io::{AsyncBufRead, AsyncRead, AsyncWrite, BufStream, BufWriter};
use tokio::net::TcpStream;
use tokio_io_timeout::TimeoutStream;
use crate::net::ErrorCapturingStream;
pub(crate) trait SessionStream:
AsyncRead + AsyncWrite + Unpin + Send + Sync + std::fmt::Debug
{
/// Change the read timeout on the session stream.
fn set_read_timeout(&mut self, timeout: Option<Duration>);
/// Returns the remote address that this stream is connected to.
fn peer_addr(&self) -> Result<SocketAddr>;
}
impl SessionStream for Box<dyn SessionStream> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.as_mut().set_read_timeout(timeout);
}
fn peer_addr(&self) -> Result<SocketAddr> {
self.as_ref().peer_addr()
}
}
impl<T: SessionStream> SessionStream for async_native_tls::TlsStream<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.get_mut().set_read_timeout(timeout);
}
fn peer_addr(&self) -> Result<SocketAddr> {
self.get_ref().peer_addr()
}
}
impl<T: SessionStream> SessionStream for tokio_rustls::client::TlsStream<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.get_mut().0.set_read_timeout(timeout);
}
fn peer_addr(&self) -> Result<SocketAddr> {
self.get_ref().0.peer_addr()
}
}
impl<T: SessionStream> SessionStream for BufStream<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.get_mut().set_read_timeout(timeout);
}
fn peer_addr(&self) -> Result<SocketAddr> {
self.get_ref().peer_addr()
}
}
impl<T: SessionStream> SessionStream for BufWriter<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.get_mut().set_read_timeout(timeout);
}
fn peer_addr(&self) -> Result<SocketAddr> {
self.get_ref().peer_addr()
}
}
impl<T: AsyncRead + AsyncWrite + Send + Sync + std::fmt::Debug> SessionStream
for Pin<Box<TimeoutStream<T>>>
{
impl SessionStream for Pin<Box<ErrorCapturingStream<TimeoutStream<TcpStream>>>> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.as_mut().set_read_timeout_pinned(timeout);
self.as_mut().get_pin_mut().set_read_timeout_pinned(timeout);
}
fn peer_addr(&self) -> Result<SocketAddr> {
Ok(self.get_ref().get_ref().peer_addr()?)
}
}
impl<T: SessionStream> SessionStream for Socks5Stream<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.get_socket_mut().set_read_timeout(timeout)
}
fn peer_addr(&self) -> Result<SocketAddr> {
self.get_socket_ref().peer_addr()
}
}
impl<T: SessionStream> SessionStream for shadowsocks::ProxyClientStream<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.get_mut().set_read_timeout(timeout)
}
fn peer_addr(&self) -> Result<SocketAddr> {
self.get_ref().peer_addr()
}
}
impl<T: SessionStream> SessionStream for async_imap::DeflateStream<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.get_mut().set_read_timeout(timeout)
}
fn peer_addr(&self) -> Result<SocketAddr> {
self.get_ref().peer_addr()
}
}
/// Session stream with a read buffer.

View File

@@ -407,6 +407,7 @@ mod tests {
use crate::message::{MessageState, delete_msgs};
use crate::receive_imf::{receive_imf, receive_imf_from_inbox};
use crate::sql::housekeeping;
use crate::test_utils::E2EE_INFO_MSGS;
use crate::test_utils::TestContext;
use crate::test_utils::TestContextManager;
use crate::tools::SystemTime;
@@ -653,13 +654,25 @@ Here's my footer -- bob@example.net"
let chat_alice = alice.create_chat(&bob).await;
let alice_msg = alice.send_text(chat_alice.id, "Hi!").await;
let bob_msg = bob.recv_msg(&alice_msg).await;
assert_eq!(get_chat_msgs(&alice, chat_alice.id).await?.len(), 1);
assert_eq!(get_chat_msgs(&bob, bob_msg.chat_id).await?.len(), 1);
assert_eq!(
get_chat_msgs(&alice, chat_alice.id).await?.len(),
E2EE_INFO_MSGS + 1
);
assert_eq!(
get_chat_msgs(&bob, bob_msg.chat_id).await?.len(),
E2EE_INFO_MSGS + 1
);
let alice_msg2 = alice.send_text(chat_alice.id, "Hi again!").await;
bob.recv_msg(&alice_msg2).await;
assert_eq!(get_chat_msgs(&alice, chat_alice.id).await?.len(), 2);
assert_eq!(get_chat_msgs(&bob, bob_msg.chat_id).await?.len(), 2);
assert_eq!(
get_chat_msgs(&alice, chat_alice.id).await?.len(),
E2EE_INFO_MSGS + 2
);
assert_eq!(
get_chat_msgs(&bob, bob_msg.chat_id).await?.len(),
E2EE_INFO_MSGS + 2
);
bob_msg.chat_id.accept(&bob).await?;
@@ -667,12 +680,18 @@ Here's my footer -- bob@example.net"
send_reaction(&bob, bob_msg.id, "👍").await.unwrap();
expect_reactions_changed_event(&bob, bob_msg.chat_id, bob_msg.id, ContactId::SELF).await?;
expect_no_unwanted_events(&bob).await;
assert_eq!(get_chat_msgs(&bob, bob_msg.chat_id).await?.len(), 2);
assert_eq!(
get_chat_msgs(&bob, bob_msg.chat_id).await?.len(),
E2EE_INFO_MSGS + 2
);
let bob_reaction_msg = bob.pop_sent_msg().await;
let alice_reaction_msg = alice.recv_msg_hidden(&bob_reaction_msg).await;
assert_eq!(alice_reaction_msg.state, MessageState::InFresh);
assert_eq!(get_chat_msgs(&alice, chat_alice.id).await?.len(), 2);
assert_eq!(
get_chat_msgs(&alice, chat_alice.id).await?.len(),
E2EE_INFO_MSGS + 2
);
let reactions = get_msg_reactions(&alice, alice_msg.sender_msg_id).await?;
assert_eq!(reactions.to_string(), "👍1");

View File

@@ -14,7 +14,9 @@ use mailparse::SingleInfo;
use num_traits::FromPrimitive;
use regex::Regex;
use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus};
use crate::chat::{
self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, remove_from_chat_contacts_table,
};
use crate::config::Config;
use crate::constants::{Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX, ShowEmails};
use crate::contact::{Contact, ContactId, Origin, mark_contact_id_as_verified};
@@ -29,6 +31,7 @@ use crate::key::self_fingerprint_opt;
use crate::key::{DcKey, Fingerprint, SignedPublicKey};
use crate::log::LogExt;
use crate::log::{info, warn};
use crate::logged_debug_assert;
use crate::message::{
self, Message, MessageState, MessengerMessage, MsgId, Viewtype, rfc724_mid_exists,
};
@@ -42,7 +45,7 @@ use crate::simplify;
use crate::stock_str;
use crate::sync::Sync::*;
use crate::tools::{self, buf_compress, remove_subject_prefix};
use crate::{chatlist_events, location};
use crate::{chatlist_events, ensure_and_debug_assert, ensure_and_debug_assert_eq, location};
use crate::{contact, imap};
/// This is the struct that is returned after receiving one email (aka MIME message).
@@ -1454,7 +1457,10 @@ async fn do_chat_assignment(
false => None,
};
if let Some(chat) = chat {
debug_assert!(chat.typ == Chattype::Single);
ensure_and_debug_assert!(
chat.typ == Chattype::Single,
"Chat {chat_id} is not Single",
);
let mut new_protection = match verified_encryption {
VerifiedEncryption::Verified => ProtectionStatus::Protected,
VerifiedEncryption::NotVerified(_) => ProtectionStatus::Unprotected,
@@ -1687,7 +1693,9 @@ async fn add_parts(
_ if chat.id.is_special() => GroupChangesInfo::default(),
Chattype::Single => GroupChangesInfo::default(),
Chattype::Mailinglist => GroupChangesInfo::default(),
Chattype::OutBroadcast => GroupChangesInfo::default(),
Chattype::OutBroadcast => {
apply_out_broadcast_changes(context, mime_parser, &mut chat, from_id).await?
}
Chattype::Group => {
apply_group_changes(
context,
@@ -1701,7 +1709,7 @@ async fn add_parts(
.await?
}
Chattype::InBroadcast => {
apply_broadcast_changes(context, mime_parser, &mut chat, from_id).await?
apply_in_broadcast_changes(context, mime_parser, &mut chat, from_id).await?
}
};
@@ -2137,7 +2145,7 @@ RETURNING id
// afterwards insert additional parts.
replace_msg_id = None;
debug_assert!(!row_id.is_special());
ensure_and_debug_assert!(!row_id.is_special(), "Rowid {row_id} is special");
created_db_entries.push(row_id);
}
@@ -2400,7 +2408,11 @@ async fn lookup_chat_by_reply(
// lookup by reply should never be needed
// as we can directly assign the message to the chat
// by its group ID.
debug_assert!(mime_parser.get_chat_group_id().is_none() || !mime_parser.was_encrypted());
ensure_and_debug_assert!(
mime_parser.get_chat_group_id().is_none() || !mime_parser.was_encrypted(),
"Encrypted message has group ID {}",
mime_parser.get_chat_group_id().unwrap_or_default(),
);
// Try to assign message to the same chat as the parent message.
let Some(parent_chat_id) = ChatId::lookup_by_message(parent) else {
@@ -3438,7 +3450,30 @@ async fn apply_mailinglist_changes(
Ok(())
}
async fn apply_broadcast_changes(
async fn apply_out_broadcast_changes(
context: &Context,
mime_parser: &MimeMessage,
chat: &mut Chat,
from_id: ContactId,
) -> Result<GroupChangesInfo> {
ensure!(chat.typ == Chattype::OutBroadcast);
if let Some(_removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) {
// The sender of the message left the broadcast channel
remove_from_chat_contacts_table(context, chat.id, from_id).await?;
return Ok(GroupChangesInfo {
better_msg: Some("".to_string()),
added_removed_id: None,
silent: true,
extra_msgs: vec![],
});
}
Ok(GroupChangesInfo::default())
}
async fn apply_in_broadcast_changes(
context: &Context,
mime_parser: &MimeMessage,
chat: &mut Chat,
@@ -3459,6 +3494,15 @@ async fn apply_broadcast_changes(
)
.await?;
if let Some(_removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) {
// The only member added/removed message that is ever sent is "I left.",
// so, this is the only case we need to handle here
if from_id == ContactId::SELF {
better_msg
.get_or_insert(stock_str::msg_group_left_local(context, ContactId::SELF).await);
}
}
if send_event_chat_modified {
context.emit_event(EventType::ChatModified(chat.id));
chatlist_events::emit_chatlist_item_changed(context, chat.id);
@@ -3727,7 +3771,7 @@ async fn add_or_lookup_key_contacts_by_address_list(
}
}
debug_assert_eq!(contact_ids.len(), address_list.len());
ensure_and_debug_assert_eq!(contact_ids.len(), address_list.len(),);
Ok(contact_ids)
}
@@ -3797,7 +3841,11 @@ async fn lookup_key_contact_by_fingerprint(
context: &Context,
fingerprint: &str,
) -> Result<Option<ContactId>> {
debug_assert!(!fingerprint.is_empty());
logged_debug_assert!(
context,
!fingerprint.is_empty(),
"lookup_key_contact_by_fingerprint: fingerprint is empty."
);
if fingerprint.is_empty() {
// Avoid accidentally looking up a non-key-contact.
return Ok(None);
@@ -3881,7 +3929,7 @@ async fn lookup_key_contacts_by_address_list(
contact_ids.push(contact_id);
}
}
debug_assert_eq!(address_list.len(), contact_ids.len());
ensure_and_debug_assert_eq!(address_list.len(), contact_ids.len(),);
Ok(contact_ids)
}

View File

@@ -15,8 +15,9 @@ use crate::download::MIN_DOWNLOAD_LIMIT;
use crate::imap::prefetch_should_download;
use crate::imex::{ImexMode, imex};
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::mark_as_verified;
use crate::test_utils::{TestContext, TestContextManager, get_chat_msg};
use crate::test_utils::{
E2EE_INFO_MSGS, TestContext, TestContextManager, get_chat_msg, mark_as_verified,
};
use crate::tools::{SystemTime, time};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -133,7 +134,7 @@ async fn test_adhoc_group_outgoing_show_accepted_contact_unaccepted() -> Result<
let chats = Chatlist::try_load(bob, 0, None, None).await?;
assert_eq!(chats.len(), 1);
let chat_id = chats.get_chat_id(0)?;
assert_eq!(chat_id.get_msg_cnt(bob).await?, 1);
assert_eq!(chat_id.get_msg_cnt(bob).await?, E2EE_INFO_MSGS + 1);
Ok(())
}
@@ -3682,6 +3683,24 @@ async fn test_unsigned_chat_group_hdr() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ignore_protected_headers_in_outer_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id;
send_text_msg(bob, bob_chat_id, "hi all!".to_string()).await?;
let mut sent_msg = bob.pop_sent_msg().await;
sent_msg.payload = sent_msg.payload.replace(
"Chat-Version:",
"Auto-Submitted: auto-generated\r\nChat-Version:",
);
alice.recv_msg(&sent_msg).await;
let ab_contact = alice.add_or_lookup_contact(bob).await;
assert!(!ab_contact.is_bot());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sync_member_list_on_rejoin() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -4392,7 +4411,7 @@ async fn test_create_group_with_big_msg() -> Result<()> {
// The big message must go away from the 1:1 chat.
let msgs = chat::get_chat_msgs(&alice, ab_chat_id).await?;
assert!(msgs.is_empty());
assert_eq!(msgs.len(), E2EE_INFO_MSGS);
Ok(())
}

View File

@@ -16,6 +16,7 @@ use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::key::{DcKey, Fingerprint, load_self_public_key};
use crate::log::{error, info, warn};
use crate::logged_debug_assert;
use crate::message::{Message, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::param::Param;
@@ -32,9 +33,10 @@ use qrinvite::QrInvite;
use crate::token::Namespace;
fn inviter_progress(context: &Context, contact_id: ContactId, progress: usize) {
debug_assert!(
logged_debug_assert!(
context,
progress <= 1000,
"value in range 0..1000 expected with: 0=error, 1..999=progress, 1000=success"
"inviter_progress: contact {contact_id}, progress={progress}, but value in range 0..1000 expected with: 0=error, 1..999=progress, 1000=success."
);
context.emit_event(EventType::SecurejoinInviterProgress {
contact_id,

View File

@@ -6,7 +6,7 @@ use crate::chatlist::Chatlist;
use crate::constants::Chattype;
use crate::key::self_fingerprint;
use crate::receive_imf::receive_imf;
use crate::stock_str::{self, chat_protection_enabled};
use crate::stock_str::{self, messages_e2e_encrypted};
use crate::test_utils::{
TestContext, TestContextManager, TimeShiftFalsePositiveNote, get_chat_msg,
};
@@ -246,7 +246,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
let chat = alice.get_chat(&bob).await;
let msg = get_chat_msg(&alice, chat.get_id(), 0, 1).await;
assert!(msg.is_info());
let expected_text = chat_protection_enabled(&alice).await;
let expected_text = messages_e2e_encrypted(&alice).await;
assert_eq!(msg.get_text(), expected_text);
if case == SetupContactCase::CheckProtectionTimestamp {
assert_eq!(msg.timestamp_sort, vc_request_with_auth_ts_sent + 1);
@@ -296,7 +296,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await);
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), chat_protection_enabled(&bob).await);
assert_eq!(msg.get_text(), messages_e2e_encrypted(&bob).await);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -540,7 +540,7 @@ async fn test_secure_join() -> Result<()> {
// - You added member bob@example.net
let msg = get_chat_msg(&alice, alice_chatid, 0, 2).await;
assert!(msg.is_info());
let expected_text = chat_protection_enabled(&alice).await;
let expected_text = messages_e2e_encrypted(&alice).await;
assert_eq!(msg.get_text(), expected_text);
}

File diff suppressed because one or more lines are too long

View File

@@ -65,9 +65,6 @@ pub enum StockMessage {
#[strum(props(fallback = "GIF"))]
Gif = 23,
#[strum(props(fallback = "Encrypted message"))]
EncryptedMsg = 24,
#[strum(props(fallback = "End-to-end encryption available"))]
E2eAvailable = 25,
@@ -285,7 +282,7 @@ pub enum StockMessage {
#[strum(props(fallback = "Member %1$s removed by %2$s."))]
MsgDelMemberBy = 131,
#[strum(props(fallback = "You left the group."))]
#[strum(props(fallback = "You left."))]
MsgYouLeftGroup = 132,
#[strum(props(fallback = "Group left by %1$s."))]
@@ -380,9 +377,10 @@ pub enum StockMessage {
#[strum(props(fallback = "I left the group."))]
MsgILeftGroup = 166,
#[strum(props(fallback = "Messages are guaranteed to be end-to-end encrypted from now on."))]
#[strum(props(fallback = "Messages are end-to-end encrypted."))]
ChatProtectionEnabled = 170,
// deprecated 2025-07
#[strum(props(fallback = "%1$s sent a message from another device."))]
ChatProtectionDisabled = 171,
@@ -413,6 +411,16 @@ pub enum StockMessage {
#[strum(props(fallback = "Establishing guaranteed end-to-end encryption, please wait…"))]
SecurejoinWait = 190,
#[strum(props(fallback = "❤️ Seems you're enjoying Delta Chat!
Please consider donating to help that Delta Chat stays free for everyone.
While Delta Chat is free to use and open source, development costs money.
Help keeping us to keep Delta Chat independent and make it more awesome in the future.
https://delta.chat/donate"))]
DonationRequest = 193,
}
impl StockMessage {
@@ -685,7 +693,7 @@ pub(crate) async fn msg_group_left_remote(context: &Context) -> String {
translated(context, StockMessage::MsgILeftGroup).await
}
/// Stock string: `You left the group.` or `Group left by %1$s.`.
/// Stock string: `You left.` or `Group left by %1$s.`.
pub(crate) async fn msg_group_left_local(context: &Context, by_contact: ContactId) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouLeftGroup).await
@@ -785,6 +793,11 @@ pub(crate) async fn securejoin_wait(context: &Context) -> String {
translated(context, StockMessage::SecurejoinWait).await
}
/// Stock string: `❤️ Seems you're enjoying Delta Chat!`…
pub(crate) async fn donation_request(context: &Context) -> String {
translated(context, StockMessage::DonationRequest).await
}
/// Stock string: `Scan to chat with %1$s`.
pub(crate) async fn setup_contact_qr_description(
context: &Context,
@@ -1016,8 +1029,8 @@ pub(crate) async fn error_no_network(context: &Context) -> String {
translated(context, StockMessage::ErrorNoNetwork).await
}
/// Stock string: `Messages are guaranteed to be end-to-end encrypted from now on.`
pub(crate) async fn chat_protection_enabled(context: &Context) -> String {
/// Stock string: `Messages are end-to-end encrypted.`
pub(crate) async fn messages_e2e_encrypted(context: &Context) -> String {
translated(context, StockMessage::ChatProtectionEnabled).await
}
@@ -1288,7 +1301,7 @@ impl Context {
"[Error] No contact_id given".to_string()
}
}
ProtectionStatus::Protected => chat_protection_enabled(self).await,
ProtectionStatus::Protected => messages_e2e_encrypted(self).await,
}
}

View File

@@ -2,6 +2,7 @@
//!
//! This private module is only compiled for test runs.
use std::collections::{BTreeMap, HashSet};
use std::env::current_dir;
use std::fmt::Write;
use std::ops::{Deref, DerefMut};
use std::panic;
@@ -42,6 +43,10 @@ use crate::securejoin::{get_securejoin_qr, join_securejoin};
use crate::stock_str::StockStrings;
use crate::tools::time;
/// The number of info messages added to new e2ee chats.
/// Currently this is "End-to-end encryption available", string `E2eAvailable`.
pub const E2EE_INFO_MSGS: usize = 1;
#[allow(non_upper_case_globals)]
pub const AVATAR_900x900_BYTES: &[u8] = include_bytes!("../test-data/image/avatar900x900.png");
@@ -753,7 +758,7 @@ impl TestContext {
pub async fn add_or_lookup_address_contact(&self, other: &TestContext) -> Contact {
let contact_id = self.add_or_lookup_address_contact_id(other).await;
let contact = Contact::get_by_id(&self.ctx, contact_id).await.unwrap();
debug_assert_eq!(contact.is_key_contact(), false);
assert_eq!(contact.is_key_contact(), false);
contact
}
@@ -1063,6 +1068,25 @@ impl Drop for TestContext {
// Print the chats if runtime still exists.
handle.block_on(async move {
self.print_chats().await;
// If you set this to true, and a test fails,
// the sql databases will be saved into the current working directory
// so that you can examine them.
if std::env::var("DELTACHAT_SAVE_TMP_DB").is_ok() {
let _: u32 = self
.sql
.query_get_value("PRAGMA wal_checkpoint;", ())
.await
.unwrap()
.unwrap();
let from = self.get_dbfile();
let target = current_dir()
.unwrap()
.join(format!("test-account-{}.db", self.name()));
tokio::fs::copy(from, &target).await.unwrap();
eprintln!("Copied database from {from:?} to {target:?}\n");
}
});
}
});
@@ -1146,6 +1170,11 @@ impl Drop for InnerLogSink {
while let Ok(event) = self.events.try_recv() {
print_logevent(&event);
}
if std::env::var("DELTACHAT_SAVE_TMP_DB").is_err() {
eprintln!(
"note: If you want to examine the database files, set environment variable DELTACHAT_SAVE_TMP_DB=1"
)
}
}
}

View File

@@ -15,7 +15,9 @@ use crate::mimeparser::SystemMessage;
use crate::receive_imf::receive_imf;
use crate::securejoin::{get_securejoin_qr, join_securejoin};
use crate::stock_str;
use crate::test_utils::{TestContext, TestContextManager, get_chat_msg, mark_as_verified};
use crate::test_utils::{
E2EE_INFO_MSGS, TestContext, TestContextManager, get_chat_msg, mark_as_verified,
};
use crate::tools::SystemTime;
use crate::{e2ee, message};
@@ -132,7 +134,7 @@ async fn test_create_verified_oneonone_chat() -> Result<()> {
assert!(chat.is_protected());
let msg = get_chat_msg(&alice, chat.id, 0, 1).await;
let expected_text = stock_str::chat_protection_enabled(&alice).await;
let expected_text = stock_str::messages_e2e_encrypted(&alice).await;
assert_eq!(msg.text, expected_text);
}
@@ -142,7 +144,7 @@ async fn test_create_verified_oneonone_chat() -> Result<()> {
assert!(chat.is_protected());
let msg0 = get_chat_msg(&fiona, chat.id, 0, 1).await;
let expected_text = stock_str::chat_protection_enabled(&fiona).await;
let expected_text = stock_str::messages_e2e_encrypted(&fiona).await;
assert_eq!(msg0.text, expected_text);
}
@@ -162,7 +164,7 @@ async fn test_create_verified_oneonone_chat() -> Result<()> {
let chat = alice.get_chat(&fiona_new).await;
assert!(!chat.is_protected());
let msg = get_chat_msg(&alice, chat.id, 0, 1).await;
let msg = get_chat_msg(&alice, chat.id, 1, E2EE_INFO_MSGS + 1).await;
assert_eq!(msg.text, "I have a new device");
// After recreating the chat, it should still be unprotected
@@ -268,7 +270,7 @@ async fn test_degrade_verified_oneonone_chat() -> Result<()> {
.await?;
let msg0 = get_chat_msg(&alice, alice_chat.id, 0, 1).await;
let enabled = stock_str::chat_protection_enabled(&alice).await;
let enabled = stock_str::messages_e2e_encrypted(&alice).await;
assert_eq!(msg0.text, enabled);
assert_eq!(msg0.param.get_cmd(), SystemMessage::ChatProtectionEnabled);

View File

@@ -763,5 +763,57 @@ pub(crate) fn inc_and_check<T: PrimInt + AddAssign + std::fmt::Debug>(
Ok(())
}
/// Returns early with an error if a condition is not satisfied.
/// In non-optimized builds, panics instead if so.
#[macro_export]
macro_rules! ensure_and_debug_assert {
($cond:expr, $($arg:tt)*) => {
let cond_val = $cond;
debug_assert!(cond_val, $($arg)*);
anyhow::ensure!(cond_val, $($arg)*);
};
}
/// Returns early with an error on two expressions inequality.
/// In non-optimized builds, panics instead if so.
#[macro_export]
macro_rules! ensure_and_debug_assert_eq {
($left:expr, $right:expr, $($arg:tt)*) => {
match (&$left, &$right) {
(left_val, right_val) => {
debug_assert_eq!(left_val, right_val, $($arg)*);
anyhow::ensure!(left_val == right_val, $($arg)*);
}
}
};
}
/// Returns early with an error on two expressions equality.
/// In non-optimized builds, panics instead if so.
#[macro_export]
macro_rules! ensure_and_debug_assert_ne {
($left:expr, $right:expr, $($arg:tt)*) => {
match (&$left, &$right) {
(left_val, right_val) => {
debug_assert_ne!(left_val, right_val, $($arg)*);
anyhow::ensure!(left_val != right_val, $($arg)*);
}
}
};
}
/// Logs a warning if a condition is not satisfied.
/// In non-optimized builds, panics also if so.
#[macro_export]
macro_rules! logged_debug_assert {
($ctx:expr, $cond:expr, $($arg:tt)*) => {
let cond_val = $cond;
if !cond_val {
warn!($ctx, $($arg)*);
}
debug_assert!(cond_val, $($arg)*);
};
}
#[cfg(test)]
mod tools_tests;

View File

@@ -13,7 +13,7 @@ use crate::config::Config;
use crate::download::DownloadState;
use crate::ephemeral;
use crate::receive_imf::{receive_imf, receive_imf_from_inbox};
use crate::test_utils::{TestContext, TestContextManager};
use crate::test_utils::{E2EE_INFO_MSGS, TestContext, TestContextManager};
use crate::tools::{self, SystemTime};
use crate::{message, sql};
@@ -250,7 +250,7 @@ async fn test_resend_webxdc_instance_and_info() -> Result<()> {
);
let bob_grp = bob_instance.chat_id;
assert_eq!(bob.get_last_msg_in(bob_grp).await.id, bob_instance.id);
assert_eq!(bob_grp.get_msg_cnt(&bob).await?, 1);
assert_eq!(bob_grp.get_msg_cnt(&bob).await?, E2EE_INFO_MSGS + 1);
Ok(())
}
@@ -869,14 +869,14 @@ async fn test_send_big_webxdc_status_update() -> Result<()> {
let sent2 = &alice.pop_sent_msg().await;
let alice_update = sent2.load_from_db().await;
assert_eq!(alice_update.text, BODY_DESCR.to_string());
assert_eq!(alice_chat.id.get_msg_cnt(&alice).await?, 1);
assert_eq!(alice_chat.id.get_msg_cnt(&alice).await?, E2EE_INFO_MSGS + 1);
// Bob receives the instance.
let bob_instance = bob.recv_msg(sent1).await;
let bob_chat_id = bob_instance.chat_id;
assert_eq!(bob_instance.rfc724_mid, alice_instance.rfc724_mid);
assert_eq!(bob_instance.viewtype, Viewtype::Webxdc);
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, 1);
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, E2EE_INFO_MSGS + 1);
// Bob receives the status updates.
bob.recv_msg_trash(sent2).await;
@@ -896,7 +896,7 @@ async fn test_send_big_webxdc_status_update() -> Result<()> {
r#"[{"payload":{"foo":"bar2"},"serial":2,"max_serial":3},
{"payload":{"foo":"bar3"},"serial":3,"max_serial":3}]"#
);
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, 1);
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, E2EE_INFO_MSGS + 1);
Ok(())
}
@@ -1485,7 +1485,7 @@ async fn test_webxdc_info_msg() -> Result<()> {
let alice_chat = alice.create_chat(&bob).await;
let alice_instance = send_webxdc_instance(&alice, alice_chat.id).await?;
let sent1 = &alice.pop_sent_msg().await;
assert_eq!(alice_chat.id.get_msg_cnt(&alice).await?, 1);
assert_eq!(alice_chat.id.get_msg_cnt(&alice).await?, E2EE_INFO_MSGS + 1);
alice
.send_webxdc_status_update(
@@ -1495,7 +1495,7 @@ async fn test_webxdc_info_msg() -> Result<()> {
.await?;
alice.flush_status_updates().await?;
let sent2 = &alice.pop_sent_msg().await;
assert_eq!(alice_chat.id.get_msg_cnt(&alice).await?, 2);
assert_eq!(alice_chat.id.get_msg_cnt(&alice).await?, E2EE_INFO_MSGS + 2);
let info_msg = alice.get_last_msg().await;
assert!(info_msg.is_info());
assert_eq!(info_msg.get_info_type(), SystemMessage::WebxdcInfoMessage);
@@ -1517,7 +1517,7 @@ async fn test_webxdc_info_msg() -> Result<()> {
let bob_instance = bob.recv_msg(sent1).await;
let bob_chat_id = bob_instance.chat_id;
bob.recv_msg_trash(sent2).await;
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, 2);
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, E2EE_INFO_MSGS + 2);
let info_msg = bob.get_last_msg().await;
assert!(info_msg.is_info());
assert_eq!(info_msg.get_info_type(), SystemMessage::WebxdcInfoMessage);
@@ -1536,7 +1536,10 @@ async fn test_webxdc_info_msg() -> Result<()> {
let alice2_instance = alice2.recv_msg(sent1).await;
let alice2_chat_id = alice2_instance.chat_id;
alice2.recv_msg_trash(sent2).await;
assert_eq!(alice2_chat_id.get_msg_cnt(&alice2).await?, 2);
assert_eq!(
alice2_chat_id.get_msg_cnt(&alice2).await?,
E2EE_INFO_MSGS + 2
);
let info_msg = alice2.get_last_msg().await;
assert!(info_msg.is_info());
assert_eq!(info_msg.get_info_type(), SystemMessage::WebxdcInfoMessage);
@@ -1572,13 +1575,13 @@ async fn test_webxdc_info_msg_cleanup_series() -> Result<()> {
.await?;
alice.flush_status_updates().await?;
let sent2 = &alice.pop_sent_msg().await;
assert_eq!(alice_chat.id.get_msg_cnt(&alice).await?, 2);
assert_eq!(alice_chat.id.get_msg_cnt(&alice).await?, E2EE_INFO_MSGS + 2);
alice
.send_webxdc_status_update(alice_instance.id, r#"{"info":"i2", "payload":2}"#)
.await?;
alice.flush_status_updates().await?;
let sent3 = &alice.pop_sent_msg().await;
assert_eq!(alice_chat.id.get_msg_cnt(&alice).await?, 2);
assert_eq!(alice_chat.id.get_msg_cnt(&alice).await?, E2EE_INFO_MSGS + 2);
let info_msg = alice.get_last_msg().await;
assert_eq!(info_msg.get_text(), "i2");
@@ -1586,9 +1589,9 @@ async fn test_webxdc_info_msg_cleanup_series() -> Result<()> {
let bob_instance = bob.recv_msg(sent1).await;
let bob_chat_id = bob_instance.chat_id;
bob.recv_msg_trash(sent2).await;
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, 2);
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, E2EE_INFO_MSGS + 2);
bob.recv_msg_trash(sent3).await;
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, 2);
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, E2EE_INFO_MSGS + 2);
let info_msg = bob.get_last_msg().await;
assert_eq!(info_msg.get_text(), "i2");

View File

@@ -1,7 +1,8 @@
Group#Chat#10: Group chat [3 member(s)]
--------------------------------------------------------------------------------
Msg#10🔒: (Contact#Contact#10): Hi! I created a group. [FRESH]
Msg#11🔒: Me (Contact#Contact#Self): You left the group. [INFO] √
Msg#12🔒: (Contact#Contact#10): Member charlie@example.net added by alice@example.org. [FRESH][INFO]
Msg#13🔒: (Contact#Contact#10): What a silence! [FRESH]
Msg#10: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
Msg#11🔒: (Contact#Contact#10): Hi! I created a group. [FRESH]
Msg#12🔒: Me (Contact#Contact#Self): You left. [INFO]
Msg#13🔒: (Contact#Contact#10): Member charlie@example.net added by alice@example.org. [FRESH][INFO]
Msg#14🔒: (Contact#Contact#10): What a silence! [FRESH]
--------------------------------------------------------------------------------

View File

@@ -1,5 +1,5 @@
Single#Chat#10: bob@example.net [KEY bob@example.net] 🛡️
--------------------------------------------------------------------------------
Msg#10: info (Contact#Contact#Info): Messages are guaranteed to be end-to-end encrypted from now on. [NOTICED][INFO 🛡️]
Msg#10: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO 🛡️]
Msg#11🔒: Me (Contact#Contact#Self): Test This is encrypted, signed, and has an Autocrypt Header without prefer-encrypt=mutual. √
--------------------------------------------------------------------------------

View File

@@ -1,6 +1,6 @@
Single#Chat#10: bob@example.net [KEY bob@example.net] 🛡️
--------------------------------------------------------------------------------
Msg#10: info (Contact#Contact#Info): Messages are guaranteed to be end-to-end encrypted from now on. [NOTICED][INFO 🛡️]
Msg#10: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO 🛡️]
Msg#11🔒: (Contact#Contact#10): Heyho from DC [FRESH]
Msg#13🔒: Me (Contact#Contact#Self): Sending with DC again √
--------------------------------------------------------------------------------

View File

@@ -4,6 +4,6 @@ Msg#11: info (Contact#Contact#Info): alice@example.org invited you to join this
Waiting for the device of alice@example.org to reply… [NOTICED][INFO]
Msg#13: info (Contact#Contact#Info): alice@example.org replied, waiting for being added to the group… [NOTICED][INFO]
Msg#17: info (Contact#Contact#Info): Messages are guaranteed to be end-to-end encrypted from now on. [NOTICED][INFO 🛡️]
Msg#17: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO 🛡️]
Msg#18🔒: (Contact#Contact#10): Member Me added by alice@example.org. [FRESH][INFO]
--------------------------------------------------------------------------------