Compare commits

..

218 Commits

Author SHA1 Message Date
link2xt
cd69192f66 test: member added in channel has display name 2026-03-20 08:55:07 +01:00
missytake
e30d833c94 docs: add shadowsocks spec to standards.md 2026-03-20 02:44:43 +00:00
dependabot[bot]
16668b45e9 chore(cargo): bump sdp from 0.10.0 to 0.17.1
Bumps [sdp](https://github.com/webrtc-rs/webrtc) from 0.10.0 to 0.17.1.
- [Release notes](https://github.com/webrtc-rs/webrtc/releases)
- [Commits](https://github.com/webrtc-rs/webrtc/compare/v0.10.0...v0.17.1)

---
updated-dependencies:
- dependency-name: sdp
  dependency-version: 0.17.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-20 02:41:11 +00:00
Hocuri
b148be2618 chore: bump version to 2.47.0-dev 2026-03-19 11:02:47 +01:00
Hocuri
191e6c2821 chore(release): prepare for 2.46.0 2026-03-19 10:58:42 +01:00
link2xt
7b700591f4 chore: add constant_time_eq 0.3.1 to deny.toml 2026-03-19 02:16:37 +00:00
dependabot[bot]
98f03743c6 chore(cargo): bump blake3 from 1.8.2 to 1.8.3
Bumps [blake3](https://github.com/BLAKE3-team/BLAKE3) from 1.8.2 to 1.8.3.
- [Release notes](https://github.com/BLAKE3-team/BLAKE3/releases)
- [Commits](https://github.com/BLAKE3-team/BLAKE3/compare/1.8.2...1.8.3)

---
updated-dependencies:
- dependency-name: blake3
  dependency-version: 1.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 02:16:37 +00:00
link2xt
bcaf1284e2 feat(tls): do not verify TLS certificates for hostnames starting with _ 2026-03-18 17:51:03 +00:00
Hocuri
fba4e63961 api: Rename Transport to TransportListEntry (#8009)
Follow-up to https://github.com/chatmail/core/pull/7994/, in order to
prevent clashes with other things that are called `Transport`, and in
order to make the struct name more greppable
2026-03-18 16:17:53 +01:00
Hocuri
810dab12dc api: Add list_transports_ex() and set_transport_unpublished() functions
Closes https://github.com/chatmail/core/issues/7980.

Unpublished transports are not advertised to contacts, and self-sent messages are not sent there, so that we don't cause extra messages to the corresponding inbox, but can still receive messages from contacts who don't know the new relay addresses yet.

- This adds `list_transports_ex()` and `set_transport_unpublished()` JsonRPC functions
- By default, transports are published, but when updating, all existing transports except for the primary one become unpublished in order not to break existing users that followed https://delta.chat/legacy-move
- It is not possible to unpublish the primary transport, and setting a transport as primary automatically sets it to published

An alternative would be to change the existing list_transports API rather than adding a new one list_transports_ex. But to be honest, I don't mind the _ex prefix that much, and I am wary about compatibility issues. But maybe it would be fine; see b08ba4bb8 for how this would look.
2026-03-18 12:14:56 +01:00
Hocuri
c0cc2ae816 refactor: Move transport_tests to their own file 2026-03-18 12:14:56 +01:00
dependabot[bot]
528305e12b chore(deps): bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-18 02:17:21 +00:00
dependabot[bot]
6e0586058d chore(cargo): bump astral-tokio-tar from 0.5.6 to 0.6.0
Bumps [astral-tokio-tar](https://github.com/astral-sh/tokio-tar) from 0.5.6 to 0.6.0.
- [Release notes](https://github.com/astral-sh/tokio-tar/releases)
- [Changelog](https://github.com/astral-sh/tokio-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/tokio-tar/compare/v0.5.6...v0.6.0)

---
updated-dependencies:
- dependency-name: astral-tokio-tar
  dependency-version: 0.6.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-17 22:30:32 +00:00
link2xt
296ed6d74a api!: remove functions for sending and receiving Autocrypt Setup Message 2026-03-17 20:10:59 +00:00
link2xt
8116460f14 feat: enable anonymous OpenPGP key IDs
This was disabled for interoperability in
098084b9a7,
enabling it back now.
2026-03-17 20:08:38 +00:00
link2xt
52f4293bc5 feat: decode dcaccount:// URLs and error out on empty URLs early
The problem was reported at
<https://support.delta.chat/t/could-not-find-dns-resolutions-for-imap-993-when-adding-a-relay/4907>

iOS typically transforms `:` into `://`,
we already handle this in `dclogin` URLs,
so handle it for `dcaccount` as well.
2026-03-17 20:08:24 +00:00
link2xt
cff0192e38 refactor: import tokio_rustls::rustls 2026-03-17 19:10:18 +00:00
link2xt
6f17a86903 refactor: use re-exported rustls::pki_types 2026-03-17 19:10:18 +00:00
dependabot[bot]
4eb77d5a83 chore(deps): bump zizmorcore/zizmor-action from 0.5.0 to 0.5.2
Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.5.0 to 0.5.2.
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](0dce2577a4...71321a20a9)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-17 18:00:27 +00:00
link2xt
e06372c954 fix: count recipients by Intended Recipient Fingerprints
Fixes <https://github.com/chatmail/core/issues/7987>
2026-03-17 00:32:33 +00:00
B. Petersen
50cd2514cd test markfresh_chat()
the tests were initially generated by AI and then reworked.
2026-03-16 21:00:32 +01:00
biörn
ba00251572 Update deltachat-ffi/deltachat.h
Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
2026-03-16 21:00:32 +01:00
B. Petersen
e690186236 feat: mark messages as "fresh"
this adds an api to make the newest incoming message of a chat as "fresh",
so that UI can offer a "mark chat unread" option as usual for messengers
(eg. swipe right on iOS toggels between "read" and "unread").

"mark unread" is one of the most requested missing features,
used by many ppl to organize their every day messenger usage -
tho "pinning" and "saved messages" are similar,
it seems to be missed often.

we follow a very simple approach here
and just reset the state to `MessageState::InFresh`.
this does not introduce new states or flows.

therefore, chats without any incoming message cannot be marked as fresh.
in practise, this is probably not really an issue,
as the "mark fresh" is usually used to undo a "mark noticed" operation -
and then you have incoming message.
also, most status messages as "all messages are e2ee" count as incoming.

to avoid double sending of MDN,
we remove `Param::WantsMdn` once the MDN is scheduled.
in case MDN are used for syncing, MDN is still sent as before.

many other messenger show a "badge without number",
if we want that as well,
we can always track the "manually set as fresh" state in a parameter.
but for now, it is fine without and showing a "1", which alsso makes sense as badges may be summed up.

there is an iOS pr that uses this new feature,
jsonrpc is left out until api is settled.

also out of scope is synchronisation -
main reason is that "mark noticed" is not synced as well, so we avoid an imbalance here.
both, "mark noticed" as well as "mark fresh" should be synced however,
as soon as this feature is merged.
2026-03-16 21:00:32 +01:00
link2xt
e14151d6cc fix: fsync() the rename() of accounts.toml 2026-03-16 17:09:57 +00:00
link2xt
c6cdccdb97 fix: call sync_all() instead of sync_data() when writing accounts.toml 2026-03-16 17:09:57 +00:00
link2xt
822a99ea9c fix: do not send MDNs for hidden messages
Hidden messages are marked as seen
when chat is marked as noticed.
MDNs to such messages should not be sent
as this notifies the hidden message sender
that the chat was opened.

The issue discovered by Frank Seifferth.
2026-03-15 20:54:50 +00:00
WofWca
bf02785a36 feat: add IncomingCallAccepted.from_this_device 2026-03-14 22:21:46 +04:00
iequidoo
01b2aa0f66 fix: Mark call message as seen when accepting/declining a call (#7842) 2026-03-14 13:46:25 -03:00
iequidoo
fb46c34b55 test: Shift time even more in flaky test_sync_broadcast_and_send_message
As of now, alice1 makes 3 more calls of create_smeared_timestamp() than alice2 does, so we need to
shift time by 3s to fix the test.
2026-03-14 16:20:46 +01:00
link2xt
9393753190 chore: bump version to 2.46.0-dev 2026-03-14 02:58:19 +00:00
link2xt
d9056fd187 chore(release): prepare for 2.45.0 2026-03-14 02:23:25 +00:00
link2xt
7b17b1f8b8 test: set some address for test context in decrypt_bytes()
This is needed to create pseudo transport, otherwise
public key generation fails.
2026-03-14 02:23:25 +00:00
Hocuri
d8d7f12af0 fix: Domain separation between securejoin auth tokens and broadcast channel secrets (#7981)
Can be reviewed commit-by-commit.

This fixes another silly thing you can do with securejoinv3: show Bob a
QR code with auth token that is a broadcast channel secret of a known
channel, then never respond. Bob will decrypt messages from the channel
and drop them because they are sent by the "wrong" sender.

This can be avoided with domain separation, instead of
encrypting/decrypting securejoinv3 messages directly with auth token,
encrypt/decrypt them with `securejoin/<auth token>` as the secret or
even `securejoinv3/<alice's fingerprint>/<auth token>`. For existing
broadcast channels we cannot do this, but for securejoinv3 that is not
released yet this looks like an improvement that avoids at least this
problem.

Credits to link2xt for noticing the problem.

This also adds Alice's fingerprint to the auth tokens, which
was pretty easy to do. I find it hard to develop an intuition for
whether this is important, or whether we will be annoyed by it in the
future.

**Note:** This means that QR code scans will not work if one of the chat
partners uses a self-compiled core between c724e2981 and merging this PR
here. This is fine; we will just have to tell the other developers to
update their self-compiled cores.
2026-03-13 22:01:19 +01:00
link2xt
0150d38ddd fix: update add_timestamp when the transport is selected as primary 2026-03-13 15:02:08 +00:00
link2xt
11b6a108f5 feat: merge OpenPGP certificates and distribute relays in them
We put all relay addresses as a notation subpacket
in the direct key signature to distribute the relay addresses.
2026-03-13 15:02:08 +00:00
link2xt
54858361a9 feat: mutex to prevent fetching from multiple IMAP servers at the same time 2026-03-13 15:02:08 +00:00
link2xt
6a705a3ef6 fix: update device chats at the end of configuration 2026-03-13 15:02:08 +00:00
link2xt
a23e41ea6d fix: fix debug assert message incorrectly talking about past members in the current member branch 2026-03-13 15:02:08 +00:00
link2xt
bdca3e5c09 refactor: order self addresses by addition timestamp
This way the order does not change when
primary address is changed.
2026-03-13 15:02:08 +00:00
link2xt
a61a25f139 fix(deltachat_rpc_client): make @futuremethod decorator keep method metadata
Without this change methods decorated with `@futuremethod`
do not appear in the documentation.
2026-03-13 03:17:44 +00:00
Hocuri
5404e683eb fix: Drop messages encrypted with the wrong symmetric secret (#7963)
The tests were originally generated with AI and then reworked.

Follow-up to https://github.com/chatmail/core/pull/7754 (c724e29)

This prevents the following attack:

/// Eve is subscribed to a channel and wants to know whether Alice is also subscribed to it.
/// To achieve this, Eve sends a message to Alice
/// encrypted with the symmetric secret of this broadcast channel.
///
/// If Alice sends an answer (or read receipt),
/// then Eve knows that Alice is in the broadcast channel.
///
/// A similar attack would be possible with auth tokens
/// that are also used to symmetrically encrypt messages.
///
/// To prevent this, a message that was unexpectedly
/// encrypted with a symmetric secret must be dropped.
2026-03-12 18:59:19 +00:00
B. Petersen
80acc9d467 docs: use correct define for 'description changed' info message 2026-03-11 23:41:38 +01:00
B. Petersen
3c5af7a559 fix: use correct string for encryption info
encryption info needs a dedicated string for "Messages are end-to-end encrypted"
as the UI will add more infomation to the info messages,
smth. as "Tap for more information".

an alternative fix would have been to let the UI render the info-message
differently, but adding another string to core causes less friction.
2026-03-11 15:03:07 +01:00
dependabot[bot]
f7e9973fb4 chore(cargo): bump quinn-proto from 0.11.9 to 0.11.14
Bumps [quinn-proto](https://github.com/quinn-rs/quinn) from 0.11.9 to 0.11.14.
- [Release notes](https://github.com/quinn-rs/quinn/releases)
- [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.9...quinn-proto-0.11.14)

---
updated-dependencies:
- dependency-name: quinn-proto
  dependency-version: 0.11.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-11 11:55:04 +00:00
Jagoda Estera Ślązak
c0a3d77301 fix: Correct channel system messages (#7959)
Previously channels used the same system messages
as groups, which can be confusing.

Fixes #7951

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-03-11 07:56:30 +00:00
iequidoo
9891c2a531 fix: Add "member added" messages to OutBroadcast when executing SetPgpContacts sync message (#7952)
If one of broadcast owner's devices didn't add a new subscriber for any reason, e.g. because of
missing SecureJoin messages, this device shall add "member added" messages when syncing the member
list from the `SetPgpContacts` message.
2026-03-10 22:03:04 -03:00
iequidoo
f85c625799 test: Work around test_sync_broadcast_and_send_message flakiness
The test sometimes fails because of wrong message ordering for bob:
     [...]
     Waiting for the device of alice@example.org to reply… [NOTICED][INFO]
    <Msg#2010🔒:  (Contact#Contact#2001): hi [FRESH]
     Msg#2008🔒:  (Contact#Contact#2001): You joined the channel. [FRESH][INFO]
    >Msg#2010🔒:  (Contact#Contact#2001): hi [FRESH]
     Msg#2011🔒:  (Contact#Contact#2001): Member Me removed by alice@example.org. [FRESH][INFO]

This adds `SystemTime::shift(Duration::from_secs(1))` as a workaround.

Co-authored-by: Hocuri <hocuri@gmx.de>
2026-03-10 10:00:54 -03:00
link2xt
b30f93a57d ci: update Rust to 1.94.0 2026-03-10 09:21:24 +00:00
d2weber
a95bf77868 fix(ffi): don't steal Arc in dc_jsonrpc_init (#7962)
dc_jsonrpc_init called Arc::from_raw on the account_manager pointer, which took ownership of the caller's refcount. When the local Arc dropped at the end of the function, the refcount was decremented, leaving the C side's pointer with a stolen refcount. This caused a use-after-free race between dc_accounts_unref and dc_jsonrpc_unref at shutdown.

Wrap in ManuallyDrop to prevent the implicit drop, keeping the caller's refcount intact.

Regression introduced in #7662.
2026-03-07 10:14:01 +01:00
dependabot[bot]
d26fa715b5 chore(cargo): bump strum_macros from 0.27.2 to 0.28.0
Bumps [strum_macros](https://github.com/Peternator7/strum) from 0.27.2 to 0.28.0.
- [Release notes](https://github.com/Peternator7/strum/releases)
- [Changelog](https://github.com/Peternator7/strum/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Peternator7/strum/compare/v0.27.2...v0.28.0)

---
updated-dependencies:
- dependency-name: strum_macros
  dependency-version: 0.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...
2026-03-06 17:30:16 -03:00
dependabot[bot]
1b43aac356 chore(cargo): bump strum from 0.27.2 to 0.28.0
Bumps [strum](https://github.com/Peternator7/strum) from 0.27.2 to 0.28.0.
- [Release notes](https://github.com/Peternator7/strum/releases)
- [Changelog](https://github.com/Peternator7/strum/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Peternator7/strum/compare/v0.27.2...v0.28.0)

---
updated-dependencies:
- dependency-name: strum
  dependency-version: 0.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...
2026-03-06 13:26:51 -03:00
link2xt
53acfaa054 fix: add mutex around wal_checkpoint()
Documentation comment explains how it prevents the deadlock.
2026-03-06 09:35:12 +00:00
link2xt
874e38c146 refactor: move WAL checkpointing into sql::pool submodule
This change is mainly to avoid exposing the write lock outside the pool module.
To avoid deadlocks, outside code should work only with the pooled connections
and use no more than one connection per thread.
2026-03-06 09:35:12 +00:00
link2xt
cce8e3bc5a fix: do not run more than one housekeeping at a time
With multiple transports there are multiple inbox loops on the same profile `Context`. 
They tend to start running housekeeping at the same time, e.g. when deleting
a message with an attachment, and then `remove_unused_files()`
tries to remove the same files that are already deleted by another thread
and logs errors.
2026-03-06 09:34:20 +00:00
Hocuri
1e20055523 feat: Don't send unencrypted Auto-Submitted header (#7938)
Cherry-picked 8c09ca3

Follow-up to https://github.com/chatmail/core/pull/7935
2026-03-06 10:29:17 +01:00
Jagoda Estera Ślązak
abb93cd79d fix: Set proper placeholder texts for system messages (#7953)
Don't use first-person form in placeholder texts,
as these can be misleading when broadcasted to group.
Additionally ensures that broadcasted system messages
are not localized to not leak locally-set language 
to the group chat.

Fixes #7930

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-03-05 14:56:23 +00:00
link2xt
5f84be718a ci: update zizmor workflow to use zizmorcore/zizmor-action 2026-03-05 12:10:32 +00:00
link2xt
d1c3a679a0 ci: allow non-hash references for actions/* and dependabot/* 2026-03-05 12:10:32 +00:00
iequidoo
0c4e32363e fix: Make broadcast owner and subscriber hidden contacts for each other (#7856) 2026-03-05 08:56:53 -03:00
link2xt
89b5675b83 fix: percent-decode the address in dclogin:// URLs 2026-03-05 10:03:09 +00:00
link2xt
8ff8ba7416 refactor: use super::* in qr::dclogin_scheme 2026-03-05 10:03:09 +00:00
Hocuri
e3a7d555a8 docs: Fix documentation for membership change stock strings (#7944) 2026-03-05 09:39:38 +00:00
Nico de Haen
964bbad53e api: add createQrSvg to jsonrpc (#7949) 2026-03-04 23:07:23 +01:00
Hocuri
a1eb376131 feat: Don't send unencrypted In-Reply-To and References headers (#7935) 2026-03-04 17:31:54 +01:00
iequidoo
3c4ce17f1e feat: Remove QR code tokens sync compatibility code
Remove compatibility code needed for Core <= v1.143, Core 1.144 was released on 2024-09-21.
2026-03-03 13:57:13 -03:00
Jagoda Estera Ślązak
0622289420 fix(vcard): Improve property value escaping (#7931)
Implements property value escaping according to RFC6350 section 3.4.
<https://www.rfc-editor.org/rfc/rfc6350.html#section-3.4>

Fixes: #7893
2026-03-03 13:45:32 +01:00
Hocuri
c928015f20 fix: Use the correct chat description stock string again (#7939)
Fix https://github.com/chatmail/core/issues/7933

Apparently I was inattentive when reviewing
https://github.com/chatmail/core/pull/7870/; there even was a test that
tested that the incorrect description is used XD

Thanks for noticing @r10s!
2026-03-03 11:36:34 +00:00
Francisco Castro
b10acd194e Add support to gif stickers (#7941)
Minimal change lets the desktop client select gif files placed in the
stickers folders.
2026-03-03 12:28:52 +01:00
Hocuri
b94792706a feat: Don't depend on cleartext Chat-Version, In-Reply-To, and References headers for prefetch_should_download (#7932)
Don't depend on these 3 cleartext headers for the question whether we
download a message.

This PR will waste a bit of bandwidth for people who use the legacy
show_emails option; apart from that, there is no user-visible change
yet. It's a preparation for being able to remove these headers, in order
to further reduce unencrypted metadata.

Removing In-Reply-To and References will be easy; removing Chat-Version
must happen at least one release after the PR here is released, so that
people don't miss messages. Also, maybe some nerds depend on the
Chat-Version header for server-side filtering of messages, but we shall
have this discussion at some other time.

For the question whether a message should be moved, we do still depend
on them; this will be fixed with
https://github.com/chatmail/core/pull/7780.

When both this PR and #7780 are merged, we can stop requesting
Chat-Version header during prefetch.
2026-03-03 10:12:02 +01:00
Hocuri
bfae2296b7 test: Fix flaky test_qr_securejoin_broadcast (#7937)
I assume that the problem was that sometimes, alice2 or fiona doesn't
accept alice's smeared timestamp, because `calc_sort_timestamp()`
doesn't allow the timestamp of a received message to be in the future. I
tried this patch:

```diff
diff --cc src/chat.rs
index 9565437cf,9565437cf..a2e4f97d0
--- a/src/chat.rs
+++ b/src/chat.rs
@@@ -46,6 -46,6 +46,7 @@@ use crate::receive_imf::ReceivedMsg
  use crate::smtp::{self, send_msg_to_smtp};
  use crate::stock_str;
  use crate::sync::{self, Sync::*, SyncData};
++use crate::timesmearing::MAX_SECONDS_TO_LEND_FROM_FUTURE;
  use crate::tools::{
      IsNoneOrEmpty, SystemTime, buf_compress, create_broadcast_secret, create_id,
      create_outgoing_rfc724_mid, create_smeared_timestamp, create_smeared_timestamps, get_abs_path,
@@@ -1212,7 -1212,7 +1213,11 @@@ SELECT id, rfc724_mid, pre_rfc724_mid, 
          received: bool,
          incoming: bool,
      ) -> Result<i64> {
--        let mut sort_timestamp = cmp::min(message_timestamp, smeared_time(context));
++        let mut sort_timestamp = cmp::min(
++            message_timestamp,
++            // Add MAX_SECONDS_TO_LEND_FROM_FUTURE in order to allow other senders to do timesmearing, too:
++            smeared_time(context) + MAX_SECONDS_TO_LEND_FROM_FUTURE,
++        );
  
          let last_msg_time: Option<i64> = if always_sort_to_bottom {
              // get newest message for this chat
```

...maybe this patch makes sense anyways, but you still get the problem
that the message sent by alice2 (i.e. the add-fiona message) will have
an earlier timestamp than the message sent by alice, because alice
already sent more messages, and therefore has more timesmearing-seconds.

It's unsure it makes sense to modify calc_sort_timestamp() this way because if some chat member has the clock in the future (even unintentionally), their fresh messages will be sorted to the bottom relatively to others' fresh messages. Maybe it's even better to limit the message timestamp ("Date") by the current system time there.

To really fix the problem, we could send a serial number together with the timestamp, that distinguishes two messages sent in the same second. But since we haven't gotten complaints about message ordering since some time, let's just leave things as they are.

Since all this timesmearing is a bit best-effort right now, I decided to
instead just make the test more relaxed.
2026-03-03 10:08:56 +01:00
dependabot[bot]
e7625ca231 chore(cargo): bump proptest from 1.9.0 to 1.10.0
Bumps [proptest](https://github.com/proptest-rs/proptest) from 1.9.0 to 1.10.0.
- [Release notes](https://github.com/proptest-rs/proptest/releases)
- [Changelog](https://github.com/proptest-rs/proptest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/proptest-rs/proptest/compare/v1.9.0...v1.10.0)

---
updated-dependencies:
- dependency-name: proptest
  dependency-version: 1.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 00:40:39 -03:00
Hocuri
ab08a47298 fix: Handle the case that the user starts a securejoin, and then deletes the contact (#7883)
fix https://github.com/chatmail/core/issues/7880

depends on #7754 (merged)

With this change, a securejoin message is just ignored if the contact
was deleted in the meantime; apparently the user is not interested in
the securejoin process anymore if they deleted the contact.

But other, parallel securejoin processes must not be affected; the test
also tests this.
2026-03-02 22:11:05 +01:00
link2xt
b85fa84a37 test: remove arbitrary timeouts from test_4_lowlevel.py
They randomly fail just because CI is sometimes slow.
2026-03-02 18:33:08 +00:00
link2xt
ccd3caf4a7 fix: set is_chatmail during initial configuration
This was initially done in the IMAP loop
to set is_chatmail for existing users.
They should all have the setting configured
by now unless they install some very old backup.

Setting during the configuration is needed
for Delta Chat Desktop because it caches the value
internally:
<https://github.com/deltachat/deltachat-desktop/issues/6068>
2026-03-02 16:49:17 +00:00
link2xt
5f248954dc feat: advertise SEIPDv2 feature for new keys
SEIPDv2 is supported, but adding feature flag
to keys is not enabled by default in rPGP.
2026-03-02 16:42:33 +00:00
link2xt
a6c7958739 fix: do not trash pre-message if it is received twice 2026-03-02 16:39:17 +00:00
Hocuri
c724e2981c feat: Securejoin v3, encrypt all securejoin messages (#7754)
Close https://github.com/chatmail/core/issues/7396. Before reviewing,
you should read the issue description of
https://github.com/chatmail/core/issues/7396.
I recommend to review with hidden whitespace changes.

TODO:
- [x] Implement the new protocol
- [x] Make Rust tests pass
- [x] Make Python tests pass
- [x] Test it manually on a phone
- [x] Print the sent messages, and check that they look how they should:
[test_secure_join_group_with_mime_printed.txt](https://github.com/user-attachments/files/24800556/test_secure_join_group.txt)
- [x] Fix bug: If Alice has a second device, then Bob's chat won't be
shown yet on that second device. Also, Bob's contact isn't shown in her
contact list. As soon as either party writes something into the chat,
the that shows up and everything is fine. All of this is still a way
better UX than in WhatsApp, where Bob always has to write first 😂
Still, I should fix that.
- This is actually caused by a larger bug: AUTH tokens aren't synced if
there is no corresponding INVITE token.
  - Fixed by 6b658a0e0
- [x] Either make a new `auth_tokens` table with a proper UNIQUE bound,
or put a UNIQUE bound on the `tokens` table
- [x] Benchmarking
- [x] TODOs in the code, maybe change naming of the new functions
- [x] Write test for interop with older DC (esp. that the original
securejoin runs if you remove the &v=3 param)
- [x] From a cryptography perspective, is it fine that vc-request is
encrypted with AUTH, rather than a separate secret (like INVITE)?
- [x] Make sure that QR codes without INVITE work, so that we can remove
it eventually
- [x] Self-review, and comment on some of my code changes to explain
what they do
- [x] ~~Maybe use a new table rather than reusing AUTH token.~~ See
https://github.com/chatmail/core/pull/7754#discussion_r2728544725
- [ ] Update documentation; I'll do that in a separate PR. All necessary
information is in the https://github.com/chatmail/core/issues/7396 issue
description
- [ ] Update tests and other code to use the new names (e.g.
`request-pubkey` rather than `request` and `pubkey` rather than
`auth-required`); I'll do that in a follow-up PR

**Backwards compatibility:**
Everything works seamlessly in my tests. If both devices are updated,
then the new protocol is used; otherwise, the old protocol is used. If
there is a not-yet-updated second device, it will correctly observe the
protocol, and mark the chat partner as verified.

Note that I removed the `Auto-Submitted: auto-replied` header from
securejoin messages. We don't need it ourselves, it's a cleartext header
that leaks too much information, and I can't see any reason to have it.

---------

Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
2026-03-02 16:37:14 +00:00
dependabot[bot]
ffd9f80f8b chore(cargo): bump syn from 2.0.114 to 2.0.117
Bumps [syn](https://github.com/dtolnay/syn) from 2.0.114 to 2.0.117.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.114...2.0.117)

---
updated-dependencies:
- dependency-name: syn
  dependency-version: 2.0.117
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 06:30:52 -03:00
dependabot[bot]
42cb9fe890 chore(cargo): bump anyhow from 1.0.100 to 1.0.102
Bumps [anyhow](https://github.com/dtolnay/anyhow) from 1.0.100 to 1.0.102.
- [Release notes](https://github.com/dtolnay/anyhow/releases)
- [Commits](https://github.com/dtolnay/anyhow/compare/1.0.100...1.0.102)

---
updated-dependencies:
- dependency-name: anyhow
  dependency-version: 1.0.102
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 06:17:48 -03:00
dependabot[bot]
914486cb32 chore(cargo): bump hyper-util from 0.1.19 to 0.1.20
Bumps [hyper-util](https://github.com/hyperium/hyper-util) from 0.1.19 to 0.1.20.
- [Release notes](https://github.com/hyperium/hyper-util/releases)
- [Changelog](https://github.com/hyperium/hyper-util/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper-util/compare/v0.1.19...v0.1.20)

---
updated-dependencies:
- dependency-name: hyper-util
  dependency-version: 0.1.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 02:32:00 +00:00
dependabot[bot]
526b3b0271 chore(cargo): bump regex from 1.12.2 to 1.12.3
Bumps [regex](https://github.com/rust-lang/regex) from 1.12.2 to 1.12.3.
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.12.2...1.12.3)

---
updated-dependencies:
- dependency-name: regex
  dependency-version: 1.12.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 02:31:47 +00:00
dependabot[bot]
1c439b5ef4 chore(cargo): bump async-imap from 0.11.1 to 0.11.2
Bumps [async-imap](https://github.com/async-email/async-imap) from 0.11.1 to 0.11.2.
- [Changelog](https://github.com/chatmail/async-imap/blob/main/CHANGELOG.md)
- [Commits](https://github.com/async-email/async-imap/compare/v0.11.1...v0.11.2)

---
updated-dependencies:
- dependency-name: async-imap
  dependency-version: 0.11.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 02:11:13 +00:00
dependabot[bot]
f97c75f146 chore(cargo): bump tempfile from 3.24.0 to 3.25.0
Bumps [tempfile](https://github.com/Stebalien/tempfile) from 3.24.0 to 3.25.0.
- [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stebalien/tempfile/commits)

---
updated-dependencies:
- dependency-name: tempfile
  dependency-version: 3.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 01:26:52 +00:00
dependabot[bot]
76a36a35bf chore(cargo): bump criterion from 0.8.1 to 0.8.2
Bumps [criterion](https://github.com/criterion-rs/criterion.rs) from 0.8.1 to 0.8.2.
- [Release notes](https://github.com/criterion-rs/criterion.rs/releases)
- [Changelog](https://github.com/criterion-rs/criterion.rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/criterion-rs/criterion.rs/compare/criterion-v0.8.1...criterion-v0.8.2)

---
updated-dependencies:
- dependency-name: criterion
  dependency-version: 0.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 00:58:02 +00:00
dependabot[bot]
dc4249a2ff chore(cargo): bump quick-xml from 0.39.0 to 0.39.2
Bumps [quick-xml](https://github.com/tafia/quick-xml) from 0.39.0 to 0.39.2.
- [Release notes](https://github.com/tafia/quick-xml/releases)
- [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md)
- [Commits](https://github.com/tafia/quick-xml/compare/v0.39.0...v0.39.2)

---
updated-dependencies:
- dependency-name: quick-xml
  dependency-version: 0.39.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 00:48:51 +00:00
dependabot[bot]
957c0b7c56 chore(cargo): bump futures from 0.3.31 to 0.3.32
Bumps [futures](https://github.com/rust-lang/futures-rs) from 0.3.31 to 0.3.32.
- [Release notes](https://github.com/rust-lang/futures-rs/releases)
- [Changelog](https://github.com/rust-lang/futures-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/futures-rs/compare/0.3.31...0.3.32)

---
updated-dependencies:
- dependency-name: futures
  dependency-version: 0.3.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 00:48:08 +00:00
link2xt
8df9b9e4d9 refactor(pgp): do not use legacy key ID except for IssuerKeyId subpacket 2026-02-28 16:27:00 +00:00
link2xt
692e1019b0 refactor: remove KeyPair type
There is no need to store copy of public key 
next to the secret key because public key is a subset of the secret key
and can be obtained by using SignedSecretKey.public_key()
or SignedSecretKey.to_public_key().
2026-02-28 16:27:00 +00:00
link2xt
2511b03726 docs: update store_self_keypair() documentation
Since migration 107 there is no addr column in `keypairs` table.
2026-02-28 16:27:00 +00:00
link2xt
c39651a8d4 feat: do not read own public key from the database
We can always derive it from the secret key.
2026-02-28 16:27:00 +00:00
link2xt
8230336936 refactor: un-resultify KeyPair::new()
It never fails.  Clippy did not complain, likely because the function is marked as public.
2026-02-28 16:27:00 +00:00
link2xt
e1e8407905 chore: bump version to 2.44.0-dev 2026-02-27 01:16:34 +00:00
link2xt
ffce0dfc9a chore(release): prepare for 2.44.0 2026-02-27 01:13:18 +00:00
dependabot[bot]
e2eec2f1f8 chore(deps): bump cachix/install-nix-action from 31.9.0 to 31.9.1
Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.9.0 to 31.9.1.
- [Release notes](https://github.com/cachix/install-nix-action/releases)
- [Changelog](https://github.com/cachix/install-nix-action/blob/master/RELEASE.md)
- [Commits](4e002c8ec8...2126ae7fc5)

---
updated-dependencies:
- dependency-name: cachix/install-nix-action
  dependency-version: 31.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-26 17:27:20 +00:00
link2xt
072c0061ee refactor: do not chain Autocrypt key verification to parsing
.and_then() and Ok() are unnecessary here.
2026-02-26 17:26:17 +00:00
holger krekel
cb783ffc12 feat(rpc): add startup health-check and propagate server errors
Rpc.start() now calls get_system_info() after launching the server
to verify it started successfully. If the server exits early (e.g.
due to an invalid accounts directory), the core error message from
stderr is captured and included in the raised JsonRpcError.

The reader_loop now unblocks pending RPC requests when the server
closes stdout, so callers never hang on a dead server.

Export JsonRpcError from the deltachat_rpc_client package.

Add test_early_failure verifying that Rpc.start() raises with
the actual core error message for invalid accounts directories.
2026-02-26 18:25:31 +01:00
iequidoo
af182a85a3 fix: Don't generate new timestamp for re-sent messages (#7889)
Timestamp renewal was introduced in 1dbf924c6a "feat:
chat::resend_msgs: Guarantee strictly increasing time in the Date header" so that re-sent messages
can be deduplicated on the reciver side, but the deduplication logic doesn't depend on "Date"
anymore.
2026-02-25 12:43:45 -03:00
Hocuri
7d8989a068 fix: If importing a backup fails, delete the partially-imported profile (#7885)
fix https://github.com/chatmail/core/issues/7863

`test_import_encrypted_bak_into_encrypted_acct` CFFI test fails because
it tests that trying to import an encrypted account with a wrong
passphrase into an already-encrypted database will fail, but leave the
already-encrypted database (esp, leave it with the same password).

But in order to reset the database after a failed login attempt, I'm
using this code:

```rust
        context.sql.close().await;
        fs::remove_file(context.sql.dbfile.as_path())
            .await
            .log_err(context)
            .ok();
        context
            .sql
            .open(context, "".to_string()) // <-- NOTE THIS LINE
            .await
            .log_err(context)
            .ok();
```

We're not remembering the password, so, we can't just pass the correct
password there.

Since password-protected databases are not really supported anyways, we
decided to just skip the test.

I also tried two tricks for deleting everything [found on
Stackoverflow](https://stackoverflow.com/questions/525512/drop-all-tables-command),
but neither of them managed to actually reset the database (i.e. they
led to a failed Rust test, because asserting
`!context2.is_configured().await?` failed):

```rust
        context
            .sql
            .call_write(|conn| {
                let mut stmt = conn.prepare(
                    "select 'drop table ' || name || ';' from sqlite_master where type = 'table';",
                )?;
                let mut iter = stmt.query(())?;
                while iter.next()?.is_some() {}
                Ok(())
            })
            .await
            .log_err(context)
            .ok();
        context
            .sql
            .run_migrations(context)
            .await
            .log_err(context)
            .ok();
```

```rust
        context
            .sql
            .transaction(|t| {
                t.execute_batch(
                    "
PRAGMA writable_schema = 1;
delete from sqlite_master where type in ('table', 'index', 'trigger');
PRAGMA writable_schema = 0",
                )?;
                Ok(())
            })
            .await
            .log_err(context)
            .ok();
        context
            .sql
            .run_migrations(context)
            .await
            .log_err(context)
            .ok();
```

---------

Co-authored-by: l <link2xt@testrun.org>
2026-02-25 16:25:33 +01:00
Hocuri
d7bf10d7a4 refactor: Move migrations to the end of the file (#7895)
I sometimes find it hard to find the most recent migration.

This PR moves the migrations::run() function to the end of the file, so
that it's easy to find the most recent migration - it's at the end of
the file.

There are no changes except for switching the ordering of the functions.
2026-02-25 13:13:41 +01:00
holger krekel
f1e90c73cd chore: add dev-version bump instructions to RELEASE.md (bumping to 2.44.0-dev) 2026-02-25 10:30:42 +01:00
holger krekel
c39d2f42ef fix: tolerate empty existing directory in Accounts::new() (#7886) 2026-02-24 09:22:19 +01:00
link2xt
e60f4ff70a docs(RELEASE.md): add section about dealing with antivirus false positives 2026-02-23 23:21:14 +00:00
iequidoo
ba64d8d19b feat: Send webxdc name instead of raw file name in pre-messages. Display it in summary (#7790)
The webxdc file name itself isn't informative for users. Still, send and display it if the webxdc
manifest can't be parsed, it's better than sending "Mini App" and this isn't a normal case anyway.
2026-02-23 15:20:53 -03:00
iequidoo
4041d9a54e feat: Add 📱 to all webxdc summaries (#7790)
This can be done now as Desktop doesn't prepend icons from webxdc archives to summaries anymore.
2026-02-23 15:20:53 -03:00
link2xt
bbf9a86bce perf: batched event reception 2026-02-23 15:58:06 +00:00
Hocuri
cdb0e0ce29 fix: Make clicking on broadcast member-added messages work always (#7882)
fix #7876
2026-02-23 15:44:52 +01:00
link2xt
0e7f3c8238 test: fail fast when CHATMAIL_DOMAIN is unset
This code does not expect the variable to be unset,
so use indexing to fail with KeyError instead.
Otherwise getenv() returns None which is then converted to "none" string by formatting
and the test only fails because of connection attempts to "none" domain.
2026-02-23 14:44:16 +00:00
link2xt
16c85a9585 chore(cargo): update async-native-tls from 0.5.0 to 0.6.0 2026-02-23 14:44:16 +00:00
Hocuri
ff7023580f fix: If there was no chat description, and it's set to be an empty string, don't send out a "chat description changed" message (#7879)
fix #7877

The bug was: If there is no chat description, and the chat description
is set to an empty string, the INSERT statement inserted a row with an
empty chat description, and therefore from the view of the INSERT
statement, something changed.

This PR fixes this by simply loading the chat description first, and
comparing it.
2026-02-23 12:37:48 +01:00
B. Petersen
58d457140e fix: add cffi type for "Description changed" info message 2026-02-21 23:11:30 +01:00
biörn
b531a3c012 fix: chat-description-changed text in old clients (#7870)
instead of Alice saying to Bob "You changed the chat description",
we now say "[Chat description changed, please update ...]

i was also considering to say "[Chat description changed to:\n\n...]"
but then there is no incentive for ppl to update, and chat descriptions
for chat creation would still be missing. and this is probably far more
often used.

successor of https://github.com/chatmail/core/pull/7829
2026-02-21 21:07:41 +00:00
link2xt
f055f6226c feat: add context to message loading failures 2026-02-21 11:48:38 +00:00
link2xt
e95dca87bd feat: add backup versions to the importing error message
This would have helped debugging the problem reported at
<https://support.delta.chat/t/backup-too-new-please-update-delta-chat-message/4761>
2026-02-19 15:28:41 +00:00
B. Petersen
0d9442458a fix: add missing group description strings to cffi 2026-02-18 20:28:47 +01:00
link2xt
60cf483270 refactor(http): saturating addition to calculate cache expiration timestamp 2026-02-17 16:01:16 +00:00
link2xt
598d759b8d refactor(imex): check for overflow when adding blob size
Cannot happen without custom filesystem or sparse files,
but removes clippy lint.
2026-02-17 16:01:16 +00:00
link2xt
10b93b3943 refactor: enable clippy::arithmetic_side_effects lint 2026-02-17 16:01:16 +00:00
link2xt
5a06d08613 fix(imex): do not call set_config before running SQL migrations (#7851)
`set_config` expects that migrations have already been run and fails
if backup is old and e.g. does not have `transports` table.
2026-02-17 15:39:42 +00:00
link2xt
85de4bf678 build(git-cliff): do not capitalize the first letter of commit message
Some commit messages start with the function names
for additional context and these should not be capitalized.
2026-02-17 15:21:08 +00:00
link2xt
624fc394d9 feat: improve logging of connection failures
Previously it was not always clear whether IMAP or SMTP connection
failed and what was the endpoint used.
2026-02-17 15:14:36 +00:00
link2xt
9deba0cf2a chore(release): prepare for 2.43.0 2026-02-17 13:28:19 +00:00
dependabot[bot]
b95d28b2d9 chore(deps): bump astral-sh/setup-uv from 7.1.6 to 7.3.0
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 7.1.6 to 7.3.0.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](681c641aba...eac588ad8d)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-17 04:11:37 +00:00
link2xt
2131f5e9c0 fix: assign iroh gossip topic to pre-message when post-message is received
Iroh-Gossip-Topic is sent in a post-message. Post-message goes to trash,
so topic should be associated with the existing pre-message that is
updated rather than with the post-message.
2026-02-17 02:54:39 +00:00
Hocuri
a63f695b85 test: Fix flaky test_transport_synchronization (#7850)
Fix https://github.com/chatmail/core/issues/7835.

The problem was most probably:
- `ac1_clone` receives the sync message, sends `TRANSPORTS_MODIFIED`
event, and launches a task that will restart IO
- After IO was stopped, but before it is started again,
`ac1_clone.add_transport_from_qr(qr)` is called
- this check fails:
  ```rust
        ensure!(
            !self.scheduler.is_running().await,
            "cannot configure, already running"
        );
  ```
2026-02-14 09:24:58 +00:00
link2xt
de25eb90ff chore(cargo): update keccak from 0.1.5 to 0.1.6
0.1.5 is yanked and cargo-deny complains
2026-02-14 07:20:42 +00:00
Hocuri
3fdda6f3b8 feat: Group and broadcast channel descriptions (#7829)
fix https://github.com/chatmail/core/issues/7766

Implementation notes:

- Descriptions are only sent with member additions, when the description
is changed, and when promoting a previously-unpromoted group, in order
not to waste bandwith.
- Descriptions are not loaded everytime a chat object is loaded, because
they are only needed for the profile. Instead, they are in their own
table, and can be loaded with their own JsonRPC call.

---------

Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
2026-02-10 21:28:12 +00:00
link2xt
c475882727 perf: use recv_direct() instead of recv() on the event channel
The difference between recv_direct()[1] and recv()[2]
is that recv() allocates memory for the future on the heap.
Using recv_direct() removes one pointer indirection.

[1] https://docs.rs/async-broadcast/0.7.2/async_broadcast/struct.Receiver.html#method.recv_direct
[2] https://docs.rs/async-broadcast/0.7.2/async_broadcast/struct.Receiver.html#method.recv
2026-02-10 01:32:40 +00:00
link2xt
166e259b18 chore: update fast-socks5 to version 1.0 2026-02-10 01:32:34 +00:00
link2xt
cc38298163 refactor: enable clippy::manual_is_variant_and 2026-02-10 01:32:18 +00:00
link2xt
983f43c33c chore(release): prepare for 2.42.0 2026-02-10 00:37:13 +00:00
dependabot[bot]
5028842fd5 chore(cargo): bump quick-xml from 0.38.4 to 0.39.0
Bumps [quick-xml](https://github.com/tafia/quick-xml) from 0.38.4 to 0.39.0.
- [Release notes](https://github.com/tafia/quick-xml/releases)
- [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md)
- [Commits](https://github.com/tafia/quick-xml/compare/v0.38.4...v0.39.0)

---
updated-dependencies:
- dependency-name: quick-xml
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 04:19:06 -03:00
link2xt
e78b509d0a chore: update rPGP from 0.18.0 to 0.19.0 2026-02-08 21:09:38 +00:00
link2xt
583979c6fc fix: set mvbox_move to '0' explicitly for existing chatmail profiles
Otherwise if the user for some reason has no `mvbox_move` config set,
they get a device message about `mvbox_move` deprecation.
2026-02-08 01:58:23 +00:00
link2xt
5bfd8dd517 feat: do not scan not watched folders 2026-02-08 01:57:10 +00:00
link2xt
32b0ca81f8 test: remove test_dont_show_emails
Existing test relies on folder scanning.
We are going to remove the option to not show emails
(<https://github.com/chatmail/core/issues/7631>)
so the test will be removed eventually anyway.
2026-02-08 01:57:10 +00:00
d2weber
8dd7e5c5dd Fix typo in CHANGELOG for marknoticed_all_chats 2026-02-06 17:23:37 +01:00
link2xt
5bb0b86f6a chore(release): prepare for 2.41.0 2026-02-06 00:39:03 +00:00
B. Petersen
ed2b0e8f03 feat: use different strings for audio and video calls 2026-02-05 21:54:52 +01:00
B. Petersen
8152ff518e fix: make use of call stock strings 2026-02-05 21:54:52 +01:00
dependabot[bot]
cbcfb7087e chore(cargo): bump time from 0.3.37 to 0.3.47
Bumps [time](https://github.com/time-rs/time) from 0.3.37 to 0.3.47.
- [Release notes](https://github.com/time-rs/time/releases)
- [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md)
- [Commits](https://github.com/time-rs/time/compare/v0.3.37...v0.3.47)

---
updated-dependencies:
- dependency-name: time
  dependency-version: 0.3.47
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-05 19:14:15 +00:00
link2xt
396104af47 feat: do not require ShowEmails to be set to All for adding second relay 2026-02-05 19:12:08 +00:00
iequidoo
69f6727751 fix: Don't set download state to Failure if message is available on another Session's transport (#7684) 2026-02-05 18:58:52 +00:00
link2xt
b72a677f4c chore(release): prepare for 2.40.0 2026-02-04 21:46:02 +00:00
link2xt
00e78eecf6 feat: add device message about legacy mvbox_move 2026-02-04 21:51:56 +01:00
link2xt
8b0621b724 test: set mvbox_move to 0 for test rust accounts 2026-02-04 21:51:56 +01:00
Casper Zandbergen
63bf4c4f33 feat: allow clients to specify whether a call has video initially or not (#7740) 2026-02-04 16:49:32 +00:00
iequidoo
d6bce56d18 fix: Cross-account forwarding of a message which has_html() (#7791)
This includes forwarding of long messages. Also this fixes sending, but more likely resending of
forwarded messages for which the original message was deleted, because now we save HTML to the db
immediately when creating a forwarded message.

Co-authored-by: Hocuri <hocuri@gmx.de>
2026-02-04 11:41:27 -03:00
iequidoo
c8dec0dcdd feat: Don't call BlobObject::create_and_deduplicate() when forwarding message to the same account
It has a really complex logic, so it's better to avoid calling it if possible than think which side
effects and performance penalties it has. It was never called here before adding forwarding messages
across contexts (accounts).
2026-02-04 11:41:27 -03:00
dependabot[bot]
509644ea5f chore(cargo): bump tracing-subscriber from 0.3.20 to 0.3.22
Bumps [tracing-subscriber](https://github.com/tokio-rs/tracing) from 0.3.20 to 0.3.22.
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](https://github.com/tokio-rs/tracing/compare/tracing-subscriber-0.3.20...tracing-subscriber-0.3.22)

---
updated-dependencies:
- dependency-name: tracing-subscriber
  dependency-version: 0.3.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-04 03:22:29 +00:00
dependabot[bot]
3e95239e71 chore(cargo): bump rustls-pki-types from 1.13.2 to 1.14.0
Bumps [rustls-pki-types](https://github.com/rustls/pki-types) from 1.13.2 to 1.14.0.
- [Release notes](https://github.com/rustls/pki-types/releases)
- [Commits](https://github.com/rustls/pki-types/compare/v/1.13.2...v/1.14.0)

---
updated-dependencies:
- dependency-name: rustls-pki-types
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-04 01:38:52 +00:00
dependabot[bot]
74d4b823d2 chore(cargo): bump uuid from 1.19.0 to 1.20.0
Bumps [uuid](https://github.com/uuid-rs/uuid) from 1.19.0 to 1.20.0.
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.19.0...v1.20.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-04 01:25:40 +00:00
dependabot[bot]
1bcfb90b90 chore(cargo): bump serde_json from 1.0.148 to 1.0.149
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.148 to 1.0.149.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.148...v1.0.149)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.149
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 21:35:20 +00:00
dependabot[bot]
411ee511ed chore(cargo): bump toml from 0.9.10+spec-1.1.0 to 0.9.11+spec-1.1.0
Bumps [toml](https://github.com/toml-rs/toml) from 0.9.10+spec-1.1.0 to 0.9.11+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.10...toml-v0.9.11)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.11+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 21:35:01 +00:00
dependabot[bot]
e5a30c341c chore(cargo): bump tokio-stream from 0.1.17 to 0.1.18
Bumps [tokio-stream](https://github.com/tokio-rs/tokio) from 0.1.17 to 0.1.18.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-stream-0.1.17...tokio-stream-0.1.18)

---
updated-dependencies:
- dependency-name: tokio-stream
  dependency-version: 0.1.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 21:34:43 +00:00
link2xt
3d409c37a1 chore: remove RUSTSEC-2026-0002 exception from deny.toml
It is an "unsound" advisory for a transitive dependency
and cargo-deny does not report them by default
since cargo-deny 0.19.0.
2026-02-03 21:25:41 +00:00
dependabot[bot]
b46c86c9b7 chore(deps): bump EmbarkStudios/cargo-deny-action from 2.0.14 to 2.0.15
Bumps [EmbarkStudios/cargo-deny-action](https://github.com/embarkstudios/cargo-deny-action) from 2.0.14 to 2.0.15.
- [Release notes](https://github.com/embarkstudios/cargo-deny-action/releases)
- [Commits](76cd80eb77...3fd3802e88)

---
updated-dependencies:
- dependency-name: EmbarkStudios/cargo-deny-action
  dependency-version: 2.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 21:25:41 +00:00
dependabot[bot]
e5e268f503 chore(cargo): bump thiserror from 2.0.17 to 2.0.18
Bumps [thiserror](https://github.com/dtolnay/thiserror) from 2.0.17 to 2.0.18.
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/2.0.17...2.0.18)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 20:54:49 +00:00
link2xt
633536bb13 fix: remove Config::DeleteToTrash and Config::ConfiguredTrashFolder
`delete_to_trash` is an option that was added for Gmail
as Gmail archives the messages by default
when they are deleted over IMAP:
<https://github.com/chatmail/core/issues/3957>
(implemented in <https://github.com/chatmail/core/pull/3972>).

Closes <https://github.com/chatmail/core/issues/6444>.
2026-02-03 18:31:55 +00:00
link2xt
94ee485155 chore: update provider database 2026-02-03 18:31:55 +00:00
link2xt
ec0dc8bcad refactor: mark ProviderOptions as non_exhaustive
This prevents triggering clippy lint `needless_update`.
2026-02-03 18:31:55 +00:00
dependabot[bot]
49296e3014 chore(cargo): bump colorutils-rs from 0.7.5 to 0.7.6
Bumps [colorutils-rs](https://github.com/awxkee/colorutils-rs) from 0.7.5 to 0.7.6.
- [Release notes](https://github.com/awxkee/colorutils-rs/releases)
- [Commits](https://github.com/awxkee/colorutils-rs/compare/0.7.5...0.7.6)

---
updated-dependencies:
- dependency-name: colorutils-rs
  dependency-version: 0.7.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 18:17:53 +00:00
dependabot[bot]
2b93e856e4 chore(cargo): bump data-encoding from 2.9.0 to 2.10.0
Bumps [data-encoding](https://github.com/ia0/data-encoding) from 2.9.0 to 2.10.0.
- [Commits](https://github.com/ia0/data-encoding/compare/v2.9.0...v2.10.0)

---
updated-dependencies:
- dependency-name: data-encoding
  dependency-version: 2.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 17:50:42 +00:00
dependabot[bot]
c5be7df1d7 chore(cargo): bump chrono from 0.4.42 to 0.4.43
Bumps [chrono](https://github.com/chronotope/chrono) from 0.4.42 to 0.4.43.
- [Release notes](https://github.com/chronotope/chrono/releases)
- [Changelog](https://github.com/chronotope/chrono/blob/main/CHANGELOG.md)
- [Commits](https://github.com/chronotope/chrono/compare/v0.4.42...v0.4.43)

---
updated-dependencies:
- dependency-name: chrono
  dependency-version: 0.4.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 17:29:05 +00:00
dependabot[bot]
6b74cb6539 chore(cargo): bump human-panic from 2.0.4 to 2.0.6
Bumps [human-panic](https://github.com/rust-cli/human-panic) from 2.0.4 to 2.0.6.
- [Changelog](https://github.com/rust-cli/human-panic/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/human-panic/compare/v2.0.4...v2.0.6)

---
updated-dependencies:
- dependency-name: human-panic
  dependency-version: 2.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 17:28:50 +00:00
dependabot[bot]
de2ac8cca2 chore(cargo): bump syn from 2.0.111 to 2.0.114
Bumps [syn](https://github.com/dtolnay/syn) from 2.0.111 to 2.0.114.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.111...2.0.114)

---
updated-dependencies:
- dependency-name: syn
  dependency-version: 2.0.114
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 17:28:30 +00:00
dependabot[bot]
085fcd2751 chore(cargo): bump quote from 1.0.42 to 1.0.44
Bumps [quote](https://github.com/dtolnay/quote) from 1.0.42 to 1.0.44.
- [Release notes](https://github.com/dtolnay/quote/releases)
- [Commits](https://github.com/dtolnay/quote/compare/1.0.42...1.0.44)

---
updated-dependencies:
- dependency-name: quote
  dependency-version: 1.0.44
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 17:28:12 +00:00
dependabot[bot]
83f30e4a54 chore(cargo): bump libc from 0.2.178 to 0.2.180
Bumps [libc](https://github.com/rust-lang/libc) from 0.2.178 to 0.2.180.
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.180/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.178...0.2.180)

---
updated-dependencies:
- dependency-name: libc
  dependency-version: 0.2.180
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 17:27:56 +00:00
dependabot[bot]
e79b4baa09 chore(cargo): bump tokio-util from 0.7.17 to 0.7.18
Bumps [tokio-util](https://github.com/tokio-rs/tokio) from 0.7.17 to 0.7.18.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-util-0.7.17...tokio-util-0.7.18)

---
updated-dependencies:
- dependency-name: tokio-util
  dependency-version: 0.7.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 17:27:43 +00:00
dependabot[bot]
1e0c0d8efa chore(cargo): bump tokio from 1.48.0 to 1.49.0
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.48.0 to 1.49.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.48.0...tokio-1.49.0)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-03 17:27:10 +00:00
link2xt
378fb09c80 ci: make scripts/deny.sh test the locked version of dependencies 2026-02-03 17:25:42 +00:00
link2xt
ff2fbebff0 chore(cargo): update bytes from 1.11.0 to 1.11.1
Fixes <https://rustsec.org/advisories/RUSTSEC-2026-0007>
2026-02-03 17:25:42 +00:00
missytake
50a73666fd api(jsonrpc): process events forever by default 2026-01-31 15:56:13 +01:00
iequidoo
61a8eff2ad fix: receive_imf: Look up key contact by intended recipient fingerprint (#7661)
For now, do this only for `OneOneChat` and `MailingListOrBroadcast`, this is enough to correctly
support messages from modern Delta Chat versions sending Intended Recipient Fingerprint subpackets
and single-recipient messages from modern versions of other MUAs.
2026-01-30 07:53:44 -03:00
iequidoo
cbd379fdf0 feat: Trash messages with intended recipient fingerprints, but w/o our one included 2026-01-30 07:53:44 -03:00
iequidoo
fe826f762e fix: add_or_lookup_key_contacts*(): Advance fingerprint_iter on invalid address 2026-01-30 07:53:44 -03:00
iequidoo
2019debe99 refactor: Rename lookup_key_contacts_by_address_list() to lookup_key_contacts_fallback_to_chat()
It only looks up contacts by address in the given chat, so `_fallback_to_chat` suffix is more
informative. It's obvious that such a lookup is done by address, there are no other reasonable
options.
2026-01-30 07:53:44 -03:00
iequidoo
6c4f4bfd19 test: Message in blocked chat arrives as InSeen
It's strange that this wasn't covered by any test.
2026-01-30 07:53:44 -03:00
iequidoo
44b0736216 test: Encrypted incoming message goes to encrypted 1:1 chat even if references messages in ad-hoc group
This is an important thing forgotten to be checked in 332527089.

Also there's another test which currently doesn't work as we want: outgoing encrypted messages
continue to arrive to ad-hoc group even if we already have contact's key. This should be fixed by
sending and receiving Intended Recipient Fingerprint subpackets.

The good thing is that apparently there are no scenarios requiring the contact to update their
software, the user should just update own devices.
2026-01-30 07:53:44 -03:00
link2xt
3b29469102 feat: do not load more than one own key 2026-01-30 09:48:38 +00:00
link2xt
6325a35b5b test: make test_dont_move_sync_msgs less flaky 2026-01-30 02:08:49 +00:00
iequidoo
c08644490a feat: Make summary for pre-messages look like summary for fully downloaded messages (#7775)
This is not possible for webxdcs and vCards currently however, so add workarounds for them:
- Use translated "Mini App" as the webxdc name.
- Use just "👤" instead of the vCard summary (i.e. the vCard contact name).
2026-01-29 22:10:08 -03:00
Hocuri
955f79923a fix: Restart i/o when there are new transports in a sync message (#7640) 2026-01-28 22:00:40 -03:00
iequidoo
c9026bff2c test: 2nd device receives message via new primary transport
This currently fails because we don't start I/O for new transports synced from another device.
2026-01-28 22:00:40 -03:00
link2xt
4fc0d0f53d refactor: remove unused Context.is_inbox() 2026-01-28 17:19:21 +00:00
link2xt
1bf24618fa feat: never create IMAP folders
Existing setups already have the folders created
and for new setups only INBOX should be used.
2026-01-28 14:55:51 +00:00
link2xt
3f98e45c29 chore: update provider database 2026-01-27 23:39:16 +00:00
link2xt
26ddcfaaed feat: do not collect email addresses from messages after configuration
This can only result in adding unencrypted email-contacts
and we do not want to encourage creating unencrypted chats.
2026-01-27 17:49:16 +00:00
Hocuri
f0a12d493c refactor: Remove unneeded dbg! statements (#7776)
I forgot to remove these back when I implemented the v2 migration, and
they sometimes annoy me when I grep for `dbg`.
2026-01-27 12:30:28 +01:00
iequidoo
c848ea7eda feat: Send Intended Recipient Fingerprint subpackets
Implement "5.2.3.36. Intended Recipient Fingerprint" from RFC 9580.
2026-01-26 18:27:36 -03:00
iequidoo
7c55356271 feat: MimeMessage: Put intended recipient fingerprints into signature 2026-01-26 18:27:36 -03:00
Hocuri
f4ee01ecca fix: Don't upscale images and test that image resolution isn't changed unnecessarily (#7769)
This adds a test for https://github.com/chatmail/core/pull/7760.

Also, it fixes another bug which I uncovered with the test: If the
resolution was already lower than the max resolution, then the image was
upscaled to match the max resolution.

---------

Co-authored-by: 72374 <250991390+72374@users.noreply.github.com>
2026-01-25 17:58:09 +00:00
B. Petersen
448c0d2268 feat: use more fitting encryption info message 2026-01-24 08:45:39 +01:00
iequidoo
3325270896 fix: Don't add SELF to unencrypted chat created from encrypted message (#7661)
I.e. create a non-replyable ad-hoc group in such cases. Unencrypted replies to encrypted messages
are a security issue and "Composing a Reply Message" from RFC 9787 and "Replying and Forwarding
Guidance" from RFC 9788 forbid such replies.
2026-01-24 02:45:53 -03:00
iequidoo
b563064b26 fix: apply_group_changes(): Check whether From is key-contact
If From is an address-contact, it mustn't be able to modify an encrypted group. If From is a
key-contact, it mustn't be added to members of an unencrypted group.
2026-01-24 02:45:53 -03:00
iequidoo
8d32d3ae0c feat: receive_imf: Log reasoning for chat assignment 2026-01-24 02:45:53 -03:00
iequidoo
c5f19f67a9 fix: Make self-contact a key-contact even if key isn't generated yet 2026-01-24 02:45:53 -03:00
link2xt
baeb31b5fa chore(release): prepare for 2.39.0 2026-01-23 21:52:40 +00:00
link2xt
5d3bc00fd5 docs(RELEASE.md): add section about dealing with failed releases 2026-01-23 21:43:02 +00:00
link2xt
424928b660 docs(RELEASE.md): push preparation commit to the main branch before tagging 2026-01-23 21:43:02 +00:00
72374
1b8c732611 fix: Do not additionally reduce the resolution of images that fit into the resolution-limit and are larger than the file-size-limit (#7760)
See: https://support.delta.chat/t/high-quality-avatar/1052/8

This removes an additional resolution-reduction for images that fit
within the resolution-limit.

The additional reduction of the image-resolution was added in
b7864f232b.
It seems that back then only resolution-reduction was used to make the
file-size of images smaller, and the purpose was to skip an unnecessary
encoding-step.

However, encoding images with [jpeg-quality set to
`75`](a6b2a54e46/src/blob.rs (L392)),
as it is currently done, can significantly reduce the file-size, even if
the resolution is the same. As the resolution of the image that will be
encoded is rather low when it fits within the resolution-limit, further
reducing it can significantly reduce the quality of the image.
Thus it is better to not skip the encoding-step at the original
resolution.
2026-01-23 18:25:25 +01:00
bjoern
2531dfea1d chore: cleanup deprecated functions/defines (#7763)
this PR cleans up with some easy, deprecated stuff.

i roughly checked that they are no longer in use - by these checks,
other deprecated function were kept, eg.
dc_provider_new_from_email_with_dns() and dc_chat_is_protected() is
still used by deltatouch - to not add noise there, we remove them here
on the next cleanup ...

for DC_STR_*, however, if they are in used, the corresponding lines
should just be removed

this will cleanup https://c.delta.chat/deprecated.html as well
2026-01-23 15:25:48 +01:00
link2xt
9003b248aa chore: merge v2.38.0 into main branch
Release preparation commit was not pushed to main.
2026-01-23 09:09:59 +00:00
link2xt
35875f9b32 ci: update Rust to 1.93.0 2026-01-23 09:03:11 +00:00
Hocuri
008e6c4af3 chore(release): prepare for 2.38.0 2026-01-22 21:32:31 +01:00
Nico de Haen
a6baba1852 fix: forward message with file (#7755)
resolves #7724: When forwarding a message with file to another profile, the file was not copied to the target $blobdir and so the forwarded message missed it

---------

Co-authored-by: Hocuri <hocuri@gmx.de>
2026-01-22 20:15:26 +00:00
Hocuri
a6b2a54e46 fix: Prevent possible infinite loop with invalid smtp row (#7746)
If `Message::load_from_db_optional()` or `set_msg_failed()` fails, we
shouldn't early-return. Because it's important that the line
`execute("DELETE FROM smtp WHERE id=?", (rowid,))` is executed in order
to prevent an infinite loop, if one of these functions fails.
2026-01-21 16:46:17 +01:00
Stefano Volpe
99aa99eb5b api: public re-export of Connectivity (#7737) 2026-01-20 18:46:00 -03:00
iequidoo
566395f1fa fix: Emit MsgsChanged instead of MsgsNoticed on self-MDN if chat still has fresh messages
Otherwise if the user reads messages being offline and then the device comes online, sent MDNs will
remove all notifications from other devices even if new messages have arrived. Notifications not
removed at all look more acceptable.
2026-01-20 18:34:10 -03:00
Simon Laux
4ccd3cb665 api(rust and jsonrpc): marknoticed_all_chats method to mark all chats as notices, including muted ones. (#7709)
made for solving

https://github.com/deltachat/deltachat-desktop/issues/5891#issuecomment-3687566470

will also be more efficient, because desktop currently loads all fresh
messages to find out which chats to mark as noticed.
76d32bfc93/packages/frontend/src/components/AccountListSidebar/AccountItem.tsx (L334)


# progress
- [x] implementation
- [x] write a test
- [x] make a pr to use it in desktop
https://github.com/deltachat/deltachat-desktop/pull/5923
- [x] address review comments

---------

Co-authored-by: WofWca <wofwca@protonmail.com>
2026-01-20 08:52:59 +00:00
Hocuri
f5e1e2678b fix: Make it possible to leave and immediately delete a chat (#7744)
Without this PR, if you leave and immediately delete a chat, the leave
message won't be sent.

This is needed for
https://github.com/deltachat/deltachat-android/issues/4158.
2026-01-19 15:07:19 +01:00
Hocuri
c3a5e3ac0d feat: In teamprofiles, don't mark chat as read on outgoing message (#7717)
Fix https://github.com/chatmail/core/issues/7704
2026-01-19 11:39:06 +00:00
Simon Laux
b2f31c8148 api(rust, jsonrpc): add get_message_read_receipt_count method (#7732)
closes #7728
2026-01-19 11:37:10 +00:00
Hocuri
29c57ad065 fix: Don't remember old channel members in the database (#7716)
This PR fixes a bug that old channel members were remembered in the
database even after they left the channel. Concretely, they remained in
the `past_members` table that's only meant for groups.

Though it was not a bad bug; we're anyways not cleaning up old contacts.
2026-01-19 11:35:01 +01:00
Simon Laux
82a0d6b0ab fix: more reliable parsing of dclogin: links with ip address as host (#7734)
Also adds a test for it.

closes #7733
2026-01-18 16:11:59 +00:00
link2xt
5ff323ce15 feat(pgp): use preferred hash algorithm for signing instead of hardcoded SHA256
There is no difference for RSA and Ed25519,
the only signing keys that we generate.
The both use SHA256:
<7e3b6c0af2/src/types/params/public.rs (L231-L234)>

The only difference is for the possible future PQC signing keys
and imported NIST P-512 and NIST P-384 keys.
2026-01-18 03:59:12 +00:00
iequidoo
a67a5299bf Send and apply MDNs to self (#7005)
We currently synchronize "seen" status of messages by setting `\Seen` flag on IMAP and then looking
for new `\Seen` flags using `CONDSTORE` IMAP extension. This approach has multiple disadvantages:
- It requires that the server supports `CONDSTORE` extension. For example Maddy does not support
  CONDSTORE yet: https://github.com/foxcpp/maddy/issues/727
- It leaks the seen status to the server without any encryption.
- It requires more than just store-and-forward queues and prevents replacing IMAP with simpler
  protocols like POP3 or UUCP or some HTTP-based API for queue polling.

A simpler approach is to send MDNs to self when `Config::BccSelf` (aka multidevice) is enabled,
regardless of whether the message requested and MDN. If MDN was requested and we have MDNs enabled,
then also send to the message sender, but MDN to self is sent regardless of whether read receipts
are actually enabled.

`sync_seen_flags()` and `CONDSTORE` check is better completely removed, maybe after one
release. `store_seen_flags_on_imap()` can be kept for unencrypted non-chat messages.

One potential problem with sending MDNs is that it may trigger ratelimits on some providers and
count as another recipient.
2026-01-17 20:54:35 -03:00
link2xt
659d21aa9d docs: fix formatting of indoc! link 2026-01-17 14:40:17 +00:00
link2xt
8f604e74ec fix: do not resolve ICE server hostnames during IMAP loop
Hostname resolution may timeout if DNS servers are not responding.
It is also not necessary to resolve fallback ICE server hostnames
if the user is not going to use calls.
2026-01-17 12:55:21 +00:00
170 changed files with 8748 additions and 5798 deletions

View File

@@ -20,7 +20,7 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.92.0
RUST_VERSION: 1.94.0
# Minimum Supported Rust Version
MSRV: 1.88.0
@@ -59,9 +59,9 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: EmbarkStudios/cargo-deny-action@76cd80eb775d7bbbd2d80292136d74d39e1b4918
- uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979
with:
arguments: --all-features --workspace
arguments: --workspace --all-features --locked
command: check
command-arguments: "-Dwarnings"
@@ -174,7 +174,7 @@ jobs:
run: cargo build -p deltachat_ffi
- name: Upload C library
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.os }}-libdeltachat.a
path: target/debug/libdeltachat.a
@@ -200,7 +200,7 @@ jobs:
run: cargo build -p deltachat-rpc-server
- name: Upload deltachat-rpc-server
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.os }}-deltachat-rpc-server
path: ${{ matrix.os == 'windows-latest' && 'target/debug/deltachat-rpc-server.exe' || 'target/debug/deltachat-rpc-server' }}

View File

@@ -34,13 +34,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
- name: Upload binary
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-linux
path: result/bin/deltachat-rpc-server
@@ -58,13 +58,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
- name: Upload wheel
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
path: result/*.whl
@@ -82,13 +82,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
- name: Upload binary
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}
path: result/bin/deltachat-rpc-server.exe
@@ -106,13 +106,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel
- name: Upload wheel
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-wheel
path: result/*.whl
@@ -139,7 +139,7 @@ jobs:
run: cargo build --release --package deltachat-rpc-server --target ${{ matrix.arch }}-apple-darwin --features vendored
- name: Upload binary
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-macos
path: target/${{ matrix.arch }}-apple-darwin/release/deltachat-rpc-server
@@ -157,13 +157,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
- name: Upload binary
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-android
path: result/bin/deltachat-rpc-server
@@ -181,13 +181,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel
- name: Upload wheel
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-android-wheel
path: result/*.whl
@@ -208,7 +208,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v7
@@ -496,7 +496,7 @@ jobs:
ls -lah
- name: Upload to artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-npm-package
path: deltachat-rpc-server/npm-package/*.tgz

View File

@@ -25,7 +25,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- run: nix fmt flake.nix -- --check
build:
@@ -84,7 +84,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- run: nix build .#${{ matrix.installable }}
build-macos:
@@ -105,5 +105,5 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- run: nix build .#${{ matrix.installable }}

View File

@@ -23,7 +23,7 @@ jobs:
working-directory: deltachat-rpc-client
run: python3 -m build
- name: Store the distribution packages
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: python-package-distributions
path: deltachat-rpc-client/dist/

View File

@@ -18,11 +18,11 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: repl.exe
path: "result/bin/deltachat-repl.exe"

View File

@@ -36,7 +36,7 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build Python documentation
run: nix build .#python-docs
- name: Upload to py.delta.chat
@@ -55,7 +55,7 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build C documentation
run: nix build .#docs
- name: Upload to c.delta.chat

View File

@@ -6,26 +6,21 @@ on:
pull_request:
branches: ["**"]
permissions: {}
jobs:
zizmor:
name: zizmor latest via PyPI
name: Run zizmor
runs-on: ubuntu-latest
permissions:
security-events: write
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
contents: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867
- name: Run zizmor
run: uvx zizmor --format sarif . > results.sarif
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: results.sarif
category: zizmor
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2

6
.github/zizmor.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
rules:
unpinned-uses:
config:
policies:
actions/*: ref-pin
dependabot/*: ref-pin

View File

@@ -1,5 +1,403 @@
# Changelog
## [2.46.0] - 2026-03-19
### API-Changes
- [**breaking**] remove functions for sending and receiving Autocrypt Setup Message.
- Add `list_transports_ex()` and `set_transport_unpublished()` functions.
### Features / Changes
- add `IncomingCallAccepted.from_this_device`.
- mark messages as "fresh".
- decode `dcaccount://` URLs and error out on empty URLs early.
- enable anonymous OpenPGP key IDs.
- tls: do not verify TLS certificates for hostnames starting with `_`.
### Fixes
- Mark call message as seen when accepting/declining a call ([#7842](https://github.com/chatmail/core/pull/7842)).
- do not send MDNs for hidden messages.
- call sync_all() instead of sync_data() when writing accounts.toml.
- fsync() the rename() of accounts.toml.
- count recipients by Intended Recipient Fingerprints.
### Miscellaneous Tasks
- deps: bump zizmorcore/zizmor-action from 0.5.0 to 0.5.2.
- cargo: bump astral-tokio-tar from 0.5.6 to 0.6.0.
- deps: bump actions/upload-artifact from 6 to 7.
- cargo: bump blake3 from 1.8.2 to 1.8.3.
- add constant_time_eq 0.3.1 to deny.toml.
### Refactor
- use re-exported rustls::pki_types.
- import tokio_rustls::rustls.
- Move transport_tests to their own file.
### Tests
- Shift time even more in flaky test_sync_broadcast_and_send_message.
- test markfresh_chat()
## [2.45.0] - 2026-03-14
### API-Changes
- JSON-RPC: add `createQrSvg` ([#7949](https://github.com/chatmail/core/pull/7949)).
### Features / Changes
- Do not read own public key from the database.
- Securejoin v3, encrypt all securejoin messages ([#7754](https://github.com/chatmail/core/pull/7754)).
- Domain separation between securejoin auth tokens and broadcast channel secrets ([#7981](https://github.com/chatmail/core/pull/7981)).
- Merge OpenPGP certificates and distribute relays in them.
- Advertise SEIPDv2 feature for new keys.
- Don't depend on cleartext `Chat-Version`, `In-Reply-To`, and `References` headers for `prefetch_should_download` ([#7932](https://github.com/chatmail/core/pull/7932)).
- Don't send unencrypted `In-Reply-To` and `References` headers ([#7935](https://github.com/chatmail/core/pull/7935)).
- Don't send unencrypted `Auto-Submitted` header ([#7938](https://github.com/chatmail/core/pull/7938)).
- Remove QR code tokens sync compatibility code.
- Mutex to prevent fetching from multiple IMAP servers at the same time.
- Add support to gif stickers ([#7941](https://github.com/chatmail/core/pull/7941))
### Fixes
- Fix the deadlock by adding a mutex around `wal_checkpoint()`.
- Do not run more than one housekeeping at a time.
- ffi: don't steal Arc in `dc_jsonrpc_init` ([#7962](https://github.com/chatmail/core/pull/7962)).
- Handle the case that the user starts a securejoin, and then deletes the contact ([#7883](https://github.com/chatmail/core/pull/7883)).
- Do not trash pre-message if it is received twice.
- Set `is_chatmail` during initial configuration.
- vCard: Improve property value escaping ([#7931](https://github.com/chatmail/core/pull/7931)).
- Percent-decode the address in `dclogin://` URLs.
- Make broadcast owner and subscriber hidden contacts for each other ([#7856](https://github.com/chatmail/core/pull/7856)).
- Set proper placeholder texts for system messages ([#7953](https://github.com/chatmail/core/pull/7953)).
- Add "member added" messages to `OutBroadcast` when executing `SetPgpContacts` sync message ([#7952](https://github.com/chatmail/core/pull/7952)).
- Correct channel system messages ([#7959](https://github.com/chatmail/core/pull/7959)).
- Drop messages encrypted with the wrong symmetric secret ([#7963](https://github.com/chatmail/core/pull/7963)).
- Fix debug assert message incorrectly talking about past members in the current member branch.
- Update device chats at the end of configuration.
- `deltachat_rpc_client`: make `@futuremethod` decorator keep method metadata.
- Use the correct chat description stock string again ([#7939](https://github.com/chatmail/core/pull/7939)).
- Use correct string for encryption info.
### CI
- Update Rust to 1.94.0.
- Allow non-hash references for `actions/*` and `dependabot/*`.
- update zizmor workflow to use zizmorcore/zizmor-action.
### Documentation
- update `store_self_keypair()` documentation.
- Fix documentation for membership change stock strings ([#7944](https://github.com/chatmail/core/pull/7944)).
- use correct define for 'description changed' info message.
### Refactor
- Un-resultify `KeyPair::new()`.
- Remove `KeyPair` type.
- pgp: do not use legacy key ID except for IssuerKeyId subpacket.
- `use super::*` in qr::dclogin_scheme.
- Move WAL checkpointing into `sql::pool` submodule.
- Order self addresses by addition timestamp.
### Tests
- Remove arbitrary timeouts from `test_4_lowlevel.py`.
- Fix flaky `test_qr_securejoin_broadcast` ([#7937](https://github.com/chatmail/core/pull/7937)).
- Work around `test_sync_broadcast_and_send_message` flakiness.
### Miscellaneous Tasks
- bump version to 2.44.0-dev.
- cargo: bump futures from 0.3.31 to 0.3.32.
- cargo: bump quick-xml from 0.39.0 to 0.39.2.
- cargo: bump criterion from 0.8.1 to 0.8.2.
- cargo: bump tempfile from 3.24.0 to 3.25.0.
- cargo: bump async-imap from 0.11.1 to 0.11.2.
- cargo: bump regex from 1.12.2 to 1.12.3.
- cargo: bump hyper-util from 0.1.19 to 0.1.20.
- cargo: bump anyhow from 1.0.100 to 1.0.102.
- cargo: bump syn from 2.0.114 to 2.0.117.
- cargo: bump proptest from 1.9.0 to 1.10.0.
- cargo: bump strum from 0.27.2 to 0.28.0.
- cargo: bump strum_macros from 0.27.2 to 0.28.0.
- cargo: bump quinn-proto from 0.11.9 to 0.11.14.
## [2.44.0] - 2026-02-27
### Build system
- git-cliff: do not capitalize the first letter of commit message.
### Documentation
- RELEASE.md: add section about dealing with antivirus false positives.
### Features / Changes
- improve logging of connection failures.
- add backup versions to the importing error message.
- add context to message loading failures.
- Add 📱 to all webxdc summaries ([#7790](https://github.com/chatmail/core/pull/7790)).
- Send webxdc name instead of raw file name in pre-messages. Display it in summary ([#7790](https://github.com/chatmail/core/pull/7790)).
- rpc: add startup health-check and propagate server errors.
### Fixes
- imex: do not call `set_config` before running SQL migrations ([#7851](https://github.com/chatmail/core/pull/7851)).
- add missing group description strings to cffi.
- chat-description-changed text in old clients ([#7870](https://github.com/chatmail/core/pull/7870)).
- add cffi type for "Description changed" info message.
- If there was no chat description, and it's set to be an empty string, don't send out a "chat description changed" message ([#7879](https://github.com/chatmail/core/pull/7879)).
- Make clicking on broadcast member-added messages work always ([#7882](https://github.com/chatmail/core/pull/7882)).
- tolerate empty existing directory in Accounts::new() ([#7886](https://github.com/chatmail/core/pull/7886)).
- If importing a backup fails, delete the partially-imported profile ([#7885](https://github.com/chatmail/core/pull/7885)).
- Don't generate new timestamp for re-sent messages ([#7889](https://github.com/chatmail/core/pull/7889)).
### Miscellaneous Tasks
- cargo: update async-native-tls from 0.5.0 to 0.6.0.
- add dev-version bump instructions to RELEASE.md (bumping to 2.44.0-dev).
- deps: bump cachix/install-nix-action from 31.9.0 to 31.9.1.
### Performance
- batched event reception.
### Refactor
- enable clippy::arithmetic_side_effects lint.
- imex: check for overflow when adding blob size.
- http: saturating addition to calculate cache expiration timestamp.
- Move migrations to the end of the file ([#7895](https://github.com/chatmail/core/pull/7895)).
- do not chain Autocrypt key verification to parsing.
### Tests
- fail fast when CHATMAIL_DOMAIN is unset.
## [2.43.0] - 2026-02-17
### Features / Changes
- Group and broadcast channel descriptions ([#7829](https://github.com/chatmail/core/pull/7829)).
### Fixes
- Assign iroh gossip topic to pre-message when post-message is received.
### Miscellaneous Tasks
- Update fast-socks5 to version 1.0.
- cargo: Update keccak from 0.1.5 to 0.1.6.
- deps: Bump astral-sh/setup-uv from 7.1.6 to 7.3.0.
### Performance
- Use recv_direct() instead of recv() on the event channel.
### Refactor
- Enable `clippy::manual_is_variant_and`.
### Tests
- Fix flaky `test_transport_synchronization` ([#7850](https://github.com/chatmail/core/pull/7850)).
## [2.42.0] - 2026-02-10
### Fixes
- Set `mvbox_move` to '0' explicitly for existing chatmail profiles.
It's needed to prevent device message about deprecated `mvbox_move` option from appearing in chatmail profiles.
### Features / Changes
- Do not scan not watched folders.
### Miscellaneous Tasks
- Update rPGP from 0.18.0 to 0.19.0.
- cargo: Bump quick-xml from 0.38.4 to 0.39.0.
### Tests
- Remove test_dont_show_emails.
### Other
- Fix typo in CHANGELOG for marknoticed_all_chats.
## [2.41.0] - 2026-02-06
### Features / Changes
- Do not require `ShowEmails` to be set to `All` for adding second relay.
- Use different strings for audio and video calls.
### Fixes
- Don't set download state to Failure if message is available on another Session's transport ([#7684](https://github.com/chatmail/core/pull/7684)).
- Make use of call stock strings.
### Miscellaneous Tasks
- cargo: Bump `time` from 0.3.37 to 0.3.47.
## [2.40.0] - 2026-02-04
### Features / Changes
- Receive_imf: Log reasoning for chat assignment.
- Use more fitting encryption info message.
- Send Intended Recipient Fingerprint subpackets.
- Trash messages with intended recipient fingerprints, but w/o our one included.
- Do not collect email addresses from messages after configuration.
- Add device message about legacy `mvbox_move`.
- Never create IMAP folders.
- Make summary for pre-messages look like summary for fully downloaded messages ([#7775](https://github.com/chatmail/core/pull/7775)).
- Don't call `BlobObject::create_and_deduplicate()` when forwarding message to the same account.
- Allow clients to specify whether a call has video initially or not ([#7740](https://github.com/chatmail/core/pull/7740)).
- Do not load more than one own key from the keychain.
### Fixes
- Cross-account forwarding of a message which `has_html()` ([#7791](https://github.com/chatmail/core/pull/7791)).
- Make self-contact a key-contact even if key isn't generated yet.
- `apply_group_changes()`: Check whether From is key-contact.
- Don't add SELF to unencrypted chat created from encrypted message ([#7661](https://github.com/chatmail/core/pull/7661)).
- Don't upscale images and test that image resolution isn't changed unnecessarily ([#7769](https://github.com/chatmail/core/pull/7769)).
- Restart i/o when there are new transports in a sync message ([#7640](https://github.com/chatmail/core/pull/7640)).
- `add_or_lookup_key_contacts*()`: Advance fingerprint_iter on invalid address.
- `receive_imf`: Look up key contact by intended recipient fingerprint ([#7661](https://github.com/chatmail/core/pull/7661)).
- Remove `Config::DeleteToTrash` and `Config::ConfiguredTrashFolder`.
### API-Changes
- jsonrpc(python): Process events forever by default.
### CI
- Make scripts/deny.sh test the locked version of dependencies.
### Refactor
- Remove unneeded dbg! statements ([#7776](https://github.com/chatmail/core/pull/7776)).
- Remove unused Context.is_inbox().
- Rename lookup_key_contacts_by_address_list() to lookup_key_contacts_fallback_to_chat().
- Mark `ProviderOptions` as `non_exhaustive`.
### Miscellaneous Tasks
- Update provider database.
- cargo: Update `bytes` from 1.11.0 to 1.11.1.
- cargo: Bump tokio from 1.48.0 to 1.49.0.
- cargo: Bump tokio-util from 0.7.17 to 0.7.18.
- cargo: Bump libc from 0.2.178 to 0.2.180.
- cargo: Bump quote from 1.0.42 to 1.0.44.
- cargo: Bump syn from 2.0.111 to 2.0.114.
- cargo: Bump human-panic from 2.0.4 to 2.0.6.
- cargo: Bump chrono from 0.4.42 to 0.4.43.
- cargo: Bump data-encoding from 2.9.0 to 2.10.0.
- cargo: Bump colorutils-rs from 0.7.5 to 0.7.6.
- Update provider database.
- cargo: Bump thiserror from 2.0.17 to 2.0.18.
- deps: Bump EmbarkStudios/cargo-deny-action from 2.0.14 to 2.0.15.
- Remove RUSTSEC-2026-0002 exception from deny.toml.
- cargo: Bump tokio-stream from 0.1.17 to 0.1.18.
- cargo: Bump toml from 0.9.10+spec-1.1.0 to 0.9.11+spec-1.1.0.
- cargo: Bump serde_json from 1.0.148 to 1.0.149.
- cargo: Bump uuid from 1.19.0 to 1.20.0.
- cargo: Bump rustls-pki-types from 1.13.2 to 1.14.0.
- cargo: Bump tracing-subscriber from 0.3.20 to 0.3.22.
### Tests
- 2nd device receives message via new primary transport.
- Make `test_dont_move_sync_msgs` less flaky.
- Encrypted incoming message goes to encrypted 1:1 chat even if references messages in ad-hoc group.
- Message in blocked chat arrives as InSeen.
- Set `mvbox_move` to 0 for test rust accounts.
## [2.39.0] - 2026-01-23
### CI
- Update Rust to 1.93.0.
### Documentation
- RELEASE.md: Push preparation commit to the main branch before tagging.
- RELEASE.md: Add section about dealing with failed releases.
### Fixes
- Forward message with file ([#7755](https://github.com/chatmail/core/pull/7755)).
- Do not additionally reduce the resolution of images that fit into the resolution-limit and are larger than the file-size-limit ([#7760](https://github.com/chatmail/core/pull/7760)).
### Miscellaneous Tasks
- Merge v2.38.0 into main branch.
- Cleanup deprecated functions/defines ([#7763](https://github.com/chatmail/core/pull/7763)).
## [2.38.0] - 2026-01-22
### API-Changes
- [**breaking**] Jsonrpc: remove `contacts` from `FullChat`. To migrate load contacts on demand via `get_contacts_by_ids` using `FullChat.contactIds` ([#7282](https://github.com/chatmail/core/pull/7282)).
- jsonrpc: Add run_until parameter for bots ([#7688](https://github.com/chatmail/core/pull/7688)).
- rust, jsonrpc: Add `get_message_read_receipt_count` method ([#7732](https://github.com/chatmail/core/pull/7732)).
- rust and jsonrpc: Marknoticed_all_chats method to mark all chats as noticed, including muted ones. ([#7709](https://github.com/chatmail/core/pull/7709)).
- Public re-export of Connectivity ([#7737](https://github.com/chatmail/core/pull/7737)).
### Documentation
- Fix chat types.
- Set_config_from_qr() configures context for "DCACCOUNT:" and "DCLOGIN:" QRs ([#7450](https://github.com/chatmail/core/pull/7450)).
- Fix formatting of `indoc!` link.
### Features / Changes
- Pre-messages / next version of download on demand ([#7371](https://github.com/chatmail/core/pull/7371)).
- Connectivity view: move quota up and combine with IMAP state. ([#7653](https://github.com/chatmail/core/pull/7653)).
- Execute sync message before checking for primary transport update.
- Disable partial search by contact address.
- Don't put text into post-message ([#7714](https://github.com/chatmail/core/pull/7714)).
- Don't scale up Origin of multiple and broadcast recipients when sending a message.
- pgp: Use preferred hash algorithm for signing instead of hardcoded SHA256.
- In teamprofiles, don't mark chat as read on outgoing message ([#7717](https://github.com/chatmail/core/pull/7717)).
- Send and apply MDNs to self ([#7005](https://github.com/chatmail/core/pull/7005))
### Fixes
- Do not show contact address in message info ([#7695](https://github.com/chatmail/core/pull/7695)).
- Take transport_id into account when marking messages with \Seen flags.
- Send bcc-self messages to all own relays ([#7656](https://github.com/chatmail/core/pull/7656)).
- Only emit TransportsModified if transports are really modified.
- Logging errors in deltachat-rpc-server during startup ([#7707](https://github.com/chatmail/core/pull/7707)).
- Use only lowercase letters for stats id ([#7700](https://github.com/chatmail/core/pull/7700)).
- Hide incoming broadcasts in `DC_GCL_FOR_FORWARDING` ([#7726](https://github.com/chatmail/core/pull/7726)).
- Do not resolve ICE server hostnames during IMAP loop.
- More reliable parsing of `dclogin:` links with ip address as host ([#7734](https://github.com/chatmail/core/pull/7734)).
- Don't remember old channel members in the database ([#7716](https://github.com/chatmail/core/pull/7716)).
- Make it possible to leave and immediately delete a chat ([#7744](https://github.com/chatmail/core/pull/7744)).
- Emit MsgsChanged instead of MsgsNoticed on self-MDN if chat still has fresh messages.
- Prevent possible infinite loop with invalid `smtp` row ([#7746](https://github.com/chatmail/core/pull/7746)).
- Sync broadcast subscribers list ([#7578](https://github.com/chatmail/core/pull/7578))
### Refactor
- Don't use `concat!` in sql statements ([#7720](https://github.com/chatmail/core/pull/7720)).
### Tests
- Port test_dont_move_sync_msgs to JSON-RPC ([#7676](https://github.com/chatmail/core/pull/7676)).
- rpc-client: Replace remaining print()s with `logging` ([#6082](https://github.com/chatmail/core/pull/6082)).
## [2.37.0] - 2026-01-08
### API-Changes
@@ -185,7 +583,7 @@ that failed to be published for 2.31.0 due to not configured "trusted publishers
### Features / Changes
- Lookup_or_create_adhoc_group(): Add context to SQL errors ([#7554](https://github.com/chatmail/core/pull/7554)).
- `lookup_or_create_adhoc_group()`: Add context to SQL errors ([#7554](https://github.com/chatmail/core/pull/7554)).
## [2.31.0] - 2025-12-04
@@ -7543,3 +7941,12 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.35.0]: https://github.com/chatmail/core/compare/v2.34.0..v2.35.0
[2.36.0]: https://github.com/chatmail/core/compare/v2.35.0..v2.36.0
[2.37.0]: https://github.com/chatmail/core/compare/v2.36.0..v2.37.0
[2.38.0]: https://github.com/chatmail/core/compare/v2.37.0..v2.38.0
[2.39.0]: https://github.com/chatmail/core/compare/v2.38.0..v2.39.0
[2.40.0]: https://github.com/chatmail/core/compare/v2.39.0..v2.40.0
[2.41.0]: https://github.com/chatmail/core/compare/v2.40.0..v2.41.0
[2.42.0]: https://github.com/chatmail/core/compare/v2.41.0..v2.42.0
[2.43.0]: https://github.com/chatmail/core/compare/v2.42.0..v2.43.0
[2.44.0]: https://github.com/chatmail/core/compare/v2.43.0..v2.44.0
[2.45.0]: https://github.com/chatmail/core/compare/v2.44.0..v2.45.0
[2.46.0]: https://github.com/chatmail/core/compare/v2.45.0..v2.46.0

518
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.37.0"
version = "2.47.0-dev"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.88"
@@ -45,7 +45,7 @@ anyhow = { workspace = true }
async-broadcast = "0.7.2"
async-channel = { workspace = true }
async-imap = { version = "0.11.1", default-features = false, features = ["runtime-tokio", "compress"] }
async-native-tls = { version = "0.5", default-features = false, features = ["runtime-tokio"] }
async-native-tls = { version = "0.6", default-features = false, features = ["runtime-tokio"] }
async-smtp = { version = "0.10.2", default-features = false, features = ["runtime-tokio"] }
async_zip = { version = "0.0.18", default-features = false, features = ["deflate", "tokio-fs"] }
base64 = { workspace = true }
@@ -56,7 +56,7 @@ chrono = { workspace = true, features = ["alloc", "clock", "std"] }
colorutils-rs = { version = "0.7.5", default-features = false }
data-encoding = "2.9.0"
escaper = "0.1"
fast-socks5 = "0.10"
fast-socks5 = "1"
fd-lock = "4"
futures-lite = { workspace = true }
futures = { workspace = true }
@@ -78,17 +78,16 @@ num-derive = "0.4"
num-traits = { workspace = true }
parking_lot = "0.12.4"
percent-encoding = "2.3"
pgp = { version = "0.18.0", default-features = false }
pgp = { version = "0.19.0", default-features = false }
pin-project = "1"
qrcodegen = "1.7.0"
quick-xml = { version = "0.38", features = ["escape-html"] }
quick-xml = { version = "0.39", features = ["escape-html"] }
rand-old = { package = "rand", version = "0.8" }
rand = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rustls-pki-types = "1.12.0"
sanitize-filename = { workspace = true }
sdp = "0.10.0"
sdp = "0.17.1"
serde_json = { workspace = true }
serde_urlencoded = "0.7.1"
serde = { workspace = true, features = ["derive"] }
@@ -96,15 +95,15 @@ sha-1 = "0.10"
sha2 = "0.10"
shadowsocks = { version = "1.23.1", default-features = false, features = ["aead-cipher", "aead-cipher-2022"] }
smallvec = "1.15.1"
strum = "0.27"
strum_macros = "0.27"
strum = "0.28"
strum_macros = "0.28"
tagger = "4.3.4"
textwrap = "0.16.2"
thiserror = { workspace = true }
tokio-io-timeout = "1.2.1"
tokio-rustls = { version = "0.26.2", default-features = false }
tokio-stream = { version = "0.1.17", features = ["fs"] }
astral-tokio-tar = { version = "0.5.6", default-features = false }
astral-tokio-tar = { version = "0.6", default-features = false }
tokio-util = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.9"
@@ -182,11 +181,11 @@ harness = false
anyhow = "1"
async-channel = "2.5.0"
base64 = "0.22"
chrono = { version = "0.4.42", default-features = false }
chrono = { version = "0.4.43", default-features = false }
deltachat-contact-tools = { path = "deltachat-contact-tools" }
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
deltachat = { path = ".", default-features = false }
futures = "0.3.31"
futures = "0.3.32"
futures-lite = "2.6.1"
libc = "0.2"
log = "0.4"
@@ -194,15 +193,15 @@ mailparse = "0.16.1"
nu-ansi-term = "0.50"
num-traits = "0.2"
rand = "0.9"
regex = "1.10"
regex = "1.12"
rusqlite = "0.37"
sanitize-filename = "0.6"
serde = "1.0"
serde_json = "1"
tempfile = "3.24.0"
tempfile = "3.25.0"
thiserror = "2"
tokio = "1"
tokio-util = "0.7.17"
tokio-util = "0.7.18"
tracing-subscriber = "0.3"
yerpc = "0.6.4"

View File

@@ -1,4 +1,4 @@
# Releasing a new version of DeltaChat core
# Releasing a new version of chatmail core
For example, to release version 1.116.0 of the core, do the following steps.
@@ -14,8 +14,55 @@ For example, to release version 1.116.0 of the core, do the following steps.
5. Commit the changes as `chore(release): prepare for 1.116.0`.
Optionally, use a separate branch like `prep-1.116.0` for this commit and open a PR for review.
6. Tag the release: `git tag --annotate v1.116.0`.
6. Push the commit to the `main` branch.
7. Push the release tag: `git push origin v1.116.0`.
7. Once the commit is on the `main` branch and passed CI, tag the release: `git tag --annotate v1.116.0`.
8. Create a GitHub release: `gh release create v1.116.0 --notes ''`.
8. Push the release tag: `git push origin v1.116.0`.
9. Create a GitHub release: `gh release create v1.116.0 --notes ''`.
10. Update the version to the next development version:
`scripts/set_core_version.py 1.117.0-dev`.
11. Commit and push the change:
`git commit -m "chore: bump version to 1.117.0-dev" && git push origin main`.
12. Once the binaries are generated and [published](https://github.com/chatmail/core/releases),
check Windows binaries for false positive detections at [VirusTotal].
Either upload the binaries directly or submit a direct link to the artifact.
You can use [old browsers interface](https://www.virustotal.com/old-browsers/)
if there are problems with using the default website.
If you submit a direct link and get to the page saying
"No security vendors flagged this URL as malicious",
it does not mean that the file itself is not detected.
You need to go to the "details" tab
and click on the SHA-256 hash in the "Body SHA-256" section.
If any false positive is detected,
open an issue to track removing it.
See <https://github.com/chatmail/core/issues/7847>
for an example of false positive detection issue.
If there is a false positive "Microsoft" detection,
mark the issue as a blocker.
[VirusTotal]: https://www.virustotal.com/
## Dealing with antivirus false positives
If Windows release is incorrectly detected by some antivirus, submit requests to remove detection.
"Microsoft" antivirus is built in Windows and will break user setups so removing its detection should be highest priority.
To submit false positive to Microsoft, go to <https://www.microsoft.com/en-us/wdsi/filesubmission> and select "Submit file as a ... Software developer" option.
False positive contacts for other vendors can be found at <https://docs.virustotal.com/docs/false-positive-contacts>.
Not all of them may be up to date, so check the links below first.
Previously we successfully used the following contacts:
- [ESET-NOD32](mailto:samples@eset.com)
- [Symantec](https://symsubmit.symantec.com/)
## Dealing with failed releases
Once you make a GitHub release,
CI will try to build and publish [PyPI](https://pypi.org/) and [npm](https://www.npmjs.com/) packages.
If this fails for some reason, do not modify the failed tag, do not delete it and do not force-push to the `main` branch.
Fix the build process and tag a new release instead.

View File

@@ -21,7 +21,7 @@ text TEXT DEFAULT '' NOT NULL -- message text
```
Do not use macros like [`concat!`](https://doc.rust-lang.org/std/macro.concat.html)
or [`indoc!](https://docs.rs/indoc).
or [`indoc!`](https://docs.rs/indoc).
Do not escape newlines like this:
```
sql.execute(

View File

@@ -8,43 +8,47 @@
//! cargo bench --bench decrypting --features="internals"
//! ```
//!
//! or, if you want to only run e.g. the 'Decrypt a symmetrically encrypted message' benchmark:
//! or, if you want to only run e.g. the 'Decrypt and parse a symmetrically encrypted message' benchmark:
//!
//! ```text
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt a symmetrically encrypted message'
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt and parse a symmetrically encrypted message'
//! ```
//!
//! You can also pass a substring.
//! So, you can run all 'Decrypt and parse' benchmarks with:
//! You can also pass a substring:
//!
//! ```text
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt and parse'
//! cargo bench --bench decrypting --features="internals" -- 'symmetrically'
//! ```
//!
//! Symmetric decryption has to try out all known secrets,
//! You can benchmark this by adapting the `NUM_SECRETS` variable.
use std::hint::black_box;
use std::sync::LazyLock;
use criterion::{Criterion, criterion_group, criterion_main};
use deltachat::internals_for_benches::create_broadcast_secret;
use deltachat::internals_for_benches::create_dummy_keypair;
use deltachat::internals_for_benches::save_broadcast_secret;
use deltachat::securejoin::get_securejoin_qr;
use deltachat::{
Events,
chat::ChatId,
config::Config,
context::Context,
internals_for_benches::key_from_asc,
internals_for_benches::parse_and_get_text,
internals_for_benches::store_self_keypair,
pgp::{KeyPair, SeipdVersion, decrypt, pk_encrypt, symm_encrypt_message},
Events, chat::ChatId, config::Config, context::Context, internals_for_benches::key_from_asc,
internals_for_benches::parse_and_get_text, internals_for_benches::store_self_keypair,
stock_str::StockStrings,
};
use rand::{Rng, rng};
use tempfile::tempdir;
const NUM_SECRETS: usize = 500;
static NUM_BROADCAST_SECRETS: LazyLock<usize> = LazyLock::new(|| {
std::env::var("NUM_BROADCAST_SECRETS")
.unwrap_or("500".to_string())
.parse()
.unwrap()
});
static NUM_AUTH_TOKENS: LazyLock<usize> = LazyLock::new(|| {
std::env::var("NUM_AUTH_TOKENS")
.unwrap_or("5000".to_string())
.parse()
.unwrap()
});
async fn create_context() -> Context {
let dir = tempdir().unwrap();
@@ -58,9 +62,7 @@ async fn create_context() -> Context {
.await
.unwrap();
let secret = key_from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap();
let public = secret.signed_public_key();
let key_pair = KeyPair { public, secret };
store_self_keypair(&context, &key_pair)
store_self_keypair(&context, &secret)
.await
.expect("Failed to save key");
@@ -70,66 +72,6 @@ async fn create_context() -> Context {
fn criterion_benchmark(c: &mut Criterion) {
let mut group = c.benchmark_group("Decrypt");
// ===========================================================================================
// Benchmarks for decryption only, without any other parsing
// ===========================================================================================
group.sample_size(10);
group.bench_function("Decrypt a symmetrically encrypted message", |b| {
let plain = generate_plaintext();
let secrets = generate_secrets();
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
let secret = secrets[NUM_SECRETS / 2].clone();
symm_encrypt_message(
plain.clone(),
create_dummy_keypair("alice@example.org").unwrap().secret,
black_box(&secret),
true,
)
.await
.unwrap()
});
b.iter(|| {
let mut msg =
decrypt(encrypted.clone().into_bytes(), &[], black_box(&secrets)).unwrap();
let decrypted = msg.as_data_vec().unwrap();
assert_eq!(black_box(decrypted), plain);
});
});
group.bench_function("Decrypt a public-key encrypted message", |b| {
let plain = generate_plaintext();
let key_pair = create_dummy_keypair("alice@example.org").unwrap();
let secrets = generate_secrets();
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
pk_encrypt(
plain.clone(),
vec![black_box(key_pair.public.clone())],
key_pair.secret.clone(),
true,
true,
SeipdVersion::V2,
)
.await
.unwrap()
});
b.iter(|| {
let mut msg = decrypt(
encrypted.clone().into_bytes(),
std::slice::from_ref(&key_pair.secret),
black_box(&secrets),
)
.unwrap();
let decrypted = msg.as_data_vec().unwrap();
assert_eq!(black_box(decrypted), plain);
});
});
// ===========================================================================================
// Benchmarks for the whole parsing pipeline, incl. decryption (but excl. receive_imf())
// ===========================================================================================
@@ -139,7 +81,7 @@ fn criterion_benchmark(c: &mut Criterion) {
// "secret" is the shared secret that was used to encrypt text_symmetrically_encrypted.eml.
// Put it into the middle of our secrets:
secrets[NUM_SECRETS / 2] = "secret".to_string();
secrets[*NUM_BROADCAST_SECRETS / 2] = "secret".to_string();
let context = rt.block_on(async {
let context = create_context().await;
@@ -148,6 +90,10 @@ fn criterion_benchmark(c: &mut Criterion) {
.await
.unwrap();
}
for _i in 0..*NUM_AUTH_TOKENS {
get_securejoin_qr(&context, None).await.unwrap();
}
println!("NUM_AUTH_TOKENS={}", *NUM_AUTH_TOKENS);
context
});
@@ -161,7 +107,7 @@ fn criterion_benchmark(c: &mut Criterion) {
)
.await
.unwrap();
assert_eq!(text, "Symmetrically encrypted message");
assert_eq!(black_box(text), "Symmetrically encrypted message");
}
});
});
@@ -176,7 +122,7 @@ fn criterion_benchmark(c: &mut Criterion) {
)
.await
.unwrap();
assert_eq!(text, "hi");
assert_eq!(black_box(text), "hi");
}
});
});
@@ -185,17 +131,12 @@ fn criterion_benchmark(c: &mut Criterion) {
}
fn generate_secrets() -> Vec<String> {
let secrets: Vec<String> = (0..NUM_SECRETS)
let secrets: Vec<String> = (0..*NUM_BROADCAST_SECRETS)
.map(|_| create_broadcast_secret())
.collect();
println!("NUM_BROADCAST_SECRETS={}", *NUM_BROADCAST_SECRETS);
secrets
}
fn generate_plaintext() -> Vec<u8> {
let mut plain: Vec<u8> = vec![0; 500];
rng().fill(&mut plain[..]);
plain
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

View File

@@ -66,7 +66,7 @@ body = """
{% for commit in commits %}
- {% if commit.breaking %}[**breaking**] {% endif %}\
{% if commit.scope %}{{ commit.scope }}: {% endif %}\
{{ commit.message | upper_first }}.\
{{ commit.message }}.\
{% if commit.footers is defined %}\
{% for footer in commit.footers %}{% if 'BREAKING CHANGE' in footer.token %}
{% raw %} {% endraw %}- {{ footer.value }}\

View File

@@ -36,6 +36,45 @@ impl VcardContact {
}
}
fn escape(s: &str) -> String {
// https://www.rfc-editor.org/rfc/rfc6350.html#section-3.4
s
// backslash must be first!
.replace(r"\", r"\\")
.replace(',', r"\,")
.replace(';', r"\;")
.replace('\n', r"\n")
}
fn unescape(s: &str) -> String {
// https://www.rfc-editor.org/rfc/rfc6350.html#section-3.4
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(next) = chars.next() {
match next {
'\\' | ',' | ';' => out.push(next),
'n' | 'N' => out.push('\n'),
_ => {
// Invalid escape sequence (keep unchanged)
out.push('\\');
out.push(next);
}
}
} else {
// Invalid escape sequence (keep unchanged)
out.push('\\');
}
} else {
out.push(c);
}
}
out
}
/// Returns a vCard containing given contacts.
///
/// Calling [`parse_vcard()`] on the returned result is a reverse operation.
@@ -46,10 +85,6 @@ pub fn make_vcard(contacts: &[VcardContact]) -> String {
Some(datetime.format("%Y%m%dT%H%M%SZ").to_string())
}
fn escape(s: &str) -> String {
s.replace(',', "\\,")
}
let mut res = "".to_string();
for c in contacts {
// Mustn't contain ',', but it's easier to escape than to error out.
@@ -124,7 +159,7 @@ pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
fn vcard_property<'a>(line: &'a str, property: &str) -> Option<(&'a str, String)> {
let (params, value) = vcard_property_raw(line, property)?;
// Some fields can't contain commas, but unescape them everywhere for safety.
Some((params, value.replace("\\,", ",")))
Some((params, unescape(value)))
}
fn base64_key(line: &str) -> Option<&str> {
let (params, value) = vcard_property_raw(line, "key")?;

View File

@@ -91,7 +91,7 @@ fn test_make_and_parse_vcard() {
authname: "Alice Wonderland".to_string(),
key: Some("[base64-data]".to_string()),
profile_image: Some("image in Base64".to_string()),
biography: Some("Hi, I'm Alice".to_string()),
biography: Some("Hi,\nI'm Alice; and this is a backslash: \\".to_string()),
timestamp: Ok(1713465762),
},
VcardContact {
@@ -110,7 +110,7 @@ fn test_make_and_parse_vcard() {
FN:Alice Wonderland\r\n\
KEY:data:application/pgp-keys;base64\\,[base64-data]\r\n\
PHOTO:data:image/jpeg;base64\\,image in Base64\r\n\
NOTE:Hi\\, I'm Alice\r\n\
NOTE:Hi\\,\\nI'm Alice\\; and this is a backslash: \\\\\r\n\
REV:20240418T184242Z\r\n\
END:VCARD\r\n",
"BEGIN:VCARD\r\n\
@@ -276,3 +276,14 @@ END:VCARD",
assert!(contacts[0].timestamp.is_err());
assert_eq!(contacts[0].profile_image.as_ref().unwrap(), "/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z");
}
#[test]
fn test_vcard_value_escape_unescape() {
let original = "Text, with; chars and a \\ and a newline\nand a literal newline \\n";
let expected_escaped = r"Text\, with\; chars and a \\ and a newline\nand a literal newline \\n";
let escaped = escape(original);
assert_eq!(escaped, expected_escaped);
let unescaped = unescape(&escaped);
assert_eq!(original, unescaped);
}

View File

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

View File

@@ -1242,9 +1242,12 @@ uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t c
* This needs to be a one-to-one chat.
* @param place_call_info any data that other devices receive
* in #DC_EVENT_INCOMING_CALL.
* @param has_video Whether the call has video initially.
* This allows the recipient's client to adjust incoming call UX.
* A call can be upgraded to include video later.
* @return ID of the system message announcing the call.
*/
uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info);
uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info, int has_video);
/**
@@ -1563,10 +1566,10 @@ dc_array_t* dc_wait_next_msgs (dc_context_t* context);
* Mark all messages in a chat as _noticed_.
* _Noticed_ messages are no longer _fresh_ and do not count as being unseen
* but are still waiting for being marked as "seen" using dc_markseen_msgs()
* (IMAP/MDNs is not done for noticed messages).
* (read receipts aren't sent for noticed messages).
*
* Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
* See also dc_markseen_msgs().
* See also dc_markseen_msgs() and dc_markfresh_chat().
*
* @memberof dc_context_t
* @param context The context object as returned from dc_context_new().
@@ -1575,6 +1578,29 @@ dc_array_t* dc_wait_next_msgs (dc_context_t* context);
void dc_marknoticed_chat (dc_context_t* context, uint32_t chat_id);
/**
* Mark the last incoming message in chat as _fresh_.
*
* UI can use this to offer a "mark unread" option,
* so that already noticed chats (see dc_marknoticed_chat()) get a badge counter again.
*
* dc_get_fresh_msg_cnt() and dc_get_fresh_msgs() usually is increased by one afterwards.
*
* #DC_EVENT_MSGS_CHANGED is fired as usual,
* however, #DC_EVENT_INCOMING_MSG is _not_ fired again.
* This is to not add complexity to incoming messages code,
* e.g. UI usually does not add notifications for manually unread chats.
* If the UI wants to update system badge counters,
* they should do so directly after calling dc_markfresh_chat().
*
* @memberof dc_context_t
* @param context The context object as returned from dc_context_new().
* @param chat_id The chat ID of which the last incoming message should be marked as fresh.
* If the chat does not have incoming messages, nothing happens.
*/
void dc_markfresh_chat (dc_context_t* context, uint32_t chat_id);
/**
* Returns all message IDs of the given types in a given chat or any chat.
* Typically used to show a gallery.
@@ -1851,15 +1877,16 @@ int dc_remove_contact_from_chat (dc_context_t* context, uint32_t ch
/**
* Set group name.
* Set the name of a group or broadcast channel.
*
* If the group is already _promoted_ (any message was sent to the group),
* all group members are informed by a special status message that is sent automatically by this function.
* or if this is a brodacast channel,
* all members are informed by a special status message that is sent automatically by this function.
*
* Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
*
* @memberof dc_context_t
* @param chat_id The chat ID to set the name for. Must be a group chat.
* @param chat_id The chat ID to set the name for. Must be a group chat or broadcast channel.
* @param name New name of the group.
* @param context The context object.
* @return 1=success, 0=error
@@ -1886,10 +1913,11 @@ int dc_set_chat_name (dc_context_t* context, uint32_t ch
int dc_set_chat_ephemeral_timer (dc_context_t* context, uint32_t chat_id, uint32_t timer);
/**
* Set group profile image.
* Set group or broadcast channel profile image.
*
* If the group is already _promoted_ (any message was sent to the group),
* all group members are informed by a special status message that is sent automatically by this function.
* or if this is a brodacast channel,
* all members are informed by a special status message that is sent automatically by this function.
*
* Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
*
@@ -1897,7 +1925,7 @@ int dc_set_chat_ephemeral_timer (dc_context_t* context, uint32_t chat_id, uint32
*
* @memberof dc_context_t
* @param context The context object.
* @param chat_id The chat ID to set the image for.
* @param chat_id The chat ID to set the image for. Must be a group chat or broadcast channel.
* @param image Full path of the image to use as the group image. The image will immediately be copied to the
* `blobdir`; the original image will not be needed anymore.
* If you pass NULL here, the group image is deleted (for promoted groups, all members are informed about
@@ -2220,10 +2248,6 @@ uint32_t dc_lookup_contact_id_by_addr (dc_context_t* context, const char*
uint32_t dc_create_contact (dc_context_t* context, const char* name, const char* addr);
// Deprecated 2025-05-20, setting this flag is a no-op.
#define DC_GCL_DEPRECATED_VERIFIED_ONLY 0x01
#define DC_GCL_ADD_SELF 0x02
#define DC_GCL_ADDRESS 0x04
@@ -2296,17 +2320,6 @@ dc_array_t* dc_import_vcard (dc_context_t* context, const char*
dc_array_t* dc_get_contacts (dc_context_t* context, uint32_t flags, const char* query);
/**
* Get the number of blocked contacts.
*
* @deprecated Deprecated 2021-02-22, use dc_array_get_cnt() on dc_get_blocked_contacts() instead.
* @memberof dc_context_t
* @param context The context object.
* @return The number of blocked contacts.
*/
int dc_get_blocked_cnt (dc_context_t* context);
/**
* Get blocked contacts.
*
@@ -2481,76 +2494,6 @@ void dc_imex (dc_context_t* context, int what, c
char* dc_imex_has_backup (dc_context_t* context, const char* dir);
/**
* Initiate Autocrypt Setup Transfer.
* Before starting the setup transfer with this function, the user should be asked:
*
* ~~~
* "An 'Autocrypt Setup Message' securely shares your end-to-end setup with other Autocrypt-compliant apps.
* The setup will be encrypted by a setup code which is displayed here and must be typed on the other device.
* ~~~
*
* After that, this function should be called to send the Autocrypt Setup Message.
* The function creates the setup message and adds it to outgoing message queue.
* The message is sent asynchronously.
*
* The required setup code is returned in the following format:
*
* ~~~
* 1234-1234-1234-1234-1234-1234-1234-1234-1234
* ~~~
*
* The setup code should be shown to the user then:
*
* ~~~
* "Your key has been sent to yourself. Switch to the other device and
* open the setup message. You should be prompted for a setup code. Type
* the following digits into the prompt:
*
* 1234 - 1234 - 1234 -
* 1234 - 1234 - 1234 -
* 1234 - 1234 - 1234
*
* Once you're done, your other device will be ready to use Autocrypt."
* ~~~
*
* On the _other device_ you will call dc_continue_key_transfer() then
* for setup messages identified by dc_msg_is_setupmessage().
*
* For more details about the Autocrypt setup process, please refer to
* https://autocrypt.org/en/latest/level1.html#autocrypt-setup-message
*
* @memberof dc_context_t
* @param context The context object.
* @return The setup code. Must be released using dc_str_unref() after usage.
* On errors, e.g. if the message could not be sent, NULL is returned.
*/
char* dc_initiate_key_transfer (dc_context_t* context);
/**
* Continue the Autocrypt Key Transfer on another device.
*
* If you have started the key transfer on another device using dc_initiate_key_transfer()
* and you've detected a setup message with dc_msg_is_setupmessage(), you should prompt the
* user for the setup code and call this function then.
*
* You can use dc_msg_get_setupcodebegin() to give the user a hint about the code (useful if the user
* has created several messages and should not enter the wrong code).
*
* @memberof dc_context_t
* @param context The context object.
* @param msg_id The ID of the setup message to decrypt.
* @param setup_code The setup code entered by the user. This is the same setup code as returned from
* dc_initiate_key_transfer() on the other device.
* There is no need to format the string correctly, the function will remove all spaces and other characters and
* insert the `-` characters at the correct places.
* @return 1=key successfully decrypted and imported; both devices will use the same key now;
* 0=key transfer failed e.g. due to a bad setup code.
*/
int dc_continue_key_transfer (dc_context_t* context, uint32_t msg_id, const char* setup_code);
/**
* Signal an ongoing process to stop.
*
@@ -2579,7 +2522,6 @@ void dc_stop_ongoing_process (dc_context_t* context);
#define DC_QR_FPR_MISMATCH 220 // id=contact
#define DC_QR_FPR_WITHOUT_ADDR 230 // test1=formatted fingerprint
#define DC_QR_ACCOUNT 250 // text1=domain
#define DC_QR_BACKUP 251 // deprecated
#define DC_QR_BACKUP2 252
#define DC_QR_BACKUP_TOO_NEW 255
#define DC_QR_PROXY 271 // text1=address (e.g. "127.0.0.1:9050")
@@ -4623,6 +4565,7 @@ int dc_msg_is_info (const dc_msg_t* msg);
* 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"
* - DC_INFO_GROUP_DESCRIPTION_CHANGED (70) - Info-message "Description changed", UI should open the profile with the description
*
* For the messages that refer to a CONTACT,
* dc_msg_get_info_contact_id() returns the contact ID.
@@ -4669,16 +4612,19 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
#define DC_INFO_GROUP_IMAGE_CHANGED 3
#define DC_INFO_MEMBER_ADDED_TO_GROUP 4
#define DC_INFO_MEMBER_REMOVED_FROM_GROUP 5
// Deprecated as of 2026-03-16, not used for new messages.
#define DC_INFO_AUTOCRYPT_SETUP_MESSAGE 6
#define DC_INFO_SECURE_JOIN_MESSAGE 7
#define DC_INFO_LOCATIONSTREAMING_ENABLED 8
#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 // deprecated 2025-07
#define DC_INFO_INVALID_UNENCRYPTED_MAIL 13
#define DC_INFO_WEBXDC_INFO_MESSAGE 32
#define DC_INFO_CHAT_E2EE 50
#define DC_INFO_GROUP_DESCRIPTION_CHANGED 70
/**
@@ -4699,40 +4645,6 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
char* dc_msg_get_webxdc_href (const dc_msg_t* msg);
/**
* Check if the message is an Autocrypt Setup Message.
*
* Setup messages should be shown in an unique way e.g. using a different text color.
* On a click or another action, the user should be prompted for the setup code
* which is forwarded to dc_continue_key_transfer() then.
*
* Setup message are typically generated by dc_initiate_key_transfer() on another device.
*
* @memberof dc_msg_t
* @param msg The message object.
* @return 1=message is a setup message, 0=no setup message.
* For setup messages, dc_msg_get_viewtype() returns #DC_MSG_FILE.
*/
int dc_msg_is_setupmessage (const dc_msg_t* msg);
/**
* Get the first characters of the setup code.
*
* Typically, this is used to pre-fill the first entry field of the setup code.
* If the user has several setup messages, he can be sure typing in the correct digits.
*
* To check, if a message is a setup message, use dc_msg_is_setupmessage().
* To decrypt a secret key from a setup message, use dc_continue_key_transfer().
*
* @memberof dc_msg_t
* @param msg The message object.
* @return Typically the first two digits of the setup code or an empty string if unknown.
* NULL is never returned. Must be released using dc_str_unref() when done.
*/
char* dc_msg_get_setupcodebegin (const dc_msg_t* msg);
/**
* Gets the error status of the message.
* If there is no error associated with the message, NULL is returned.
@@ -6326,7 +6238,7 @@ void dc_event_unref(dc_event_t* event);
* should not be disturbed by a dialog or so. Instead, use a bubble or so.
*
* However, for ongoing processes (e.g. dc_configure())
* or for functions that are expected to fail (e.g. dc_continue_key_transfer())
* or for functions that are expected to fail
* it might be better to delay showing these events until the function has really
* failed (returned false). It should be sufficient to report only the _last_ error
* in a message box then.
@@ -6765,6 +6677,7 @@ void dc_event_unref(dc_event_t* event);
* UI usually only takes action in case call UI was opened before, otherwise the event should be ignored.
*
* @param data1 (int) msg_id ID of the message referring to the call
* @param data2 (int) 1 if the call was accepted from this device (process).
*/
#define DC_EVENT_INCOMING_CALL_ACCEPTED 2560
@@ -6793,8 +6706,8 @@ void dc_event_unref(dc_event_t* event);
* UI should update the list.
*
* The event is emitted when the transports are modified on another device
* using the JSON-RPC calls `add_or_update_transport`, `add_transport_from_qr`, `delete_transport`
* or `set_config(configured_addr)`.
* using the JSON-RPC calls `add_or_update_transport`, `add_transport_from_qr`, `delete_transport`,
* `set_transport_unpublished` or `set_config(configured_addr)`.
*/
#define DC_EVENT_TRANSPORTS_MODIFIED 2600
@@ -7022,61 +6935,16 @@ void dc_event_unref(dc_event_t* event);
/// Used in summaries.
#define DC_STR_FILE 12
/// "Group name changed from %1$s to %2$s."
///
/// Used in status messages for group name changes.
/// - %1$s will be replaced by the old group name
/// - %2$s will be replaced by the new group name
///
/// @deprecated 2022-09-10
#define DC_STR_MSGGRPNAME 15
/// "Group image changed."
///
/// Used in status messages for group images changes.
///
/// @deprecated 2022-09-10
#define DC_STR_MSGGRPIMGCHANGED 16
/// "Member %1$s added."
///
/// Used in status messages for added members.
/// - %1$s will be replaced by the name of the added member
///
/// @deprecated 2022-09-10
#define DC_STR_MSGADDMEMBER 17
/// "Member %1$s removed."
///
/// Used in status messages for removed members.
/// - %1$s will be replaced by the name of the removed member
///
/// @deprecated 2022-09-10
#define DC_STR_MSGDELMEMBER 18
/// "Group left."
///
/// Used in status messages.
///
/// @deprecated 2022-09-10
#define DC_STR_MSGGROUPLEFT 19
/// "GIF"
///
/// Used in summaries.
#define DC_STR_GIF 23
/// @deprecated 2025-07, this string is no longer needed.
#define DC_STR_ENCRYPTEDMSG 24
/// "End-to-end encryption available."
///
/// Used to build the string returned by dc_get_contact_encrinfo().
/// @deprecated 2026-01-23
#define DC_STR_E2E_AVAILABLE 25
/// @deprecated Deprecated 2021-02-07, this string is no longer needed.
#define DC_STR_ENCR_TRANSP 27
/// "No encryption."
///
/// Used to build the string returned by dc_get_contact_encrinfo().
@@ -7087,90 +6955,23 @@ void dc_event_unref(dc_event_t* event);
/// Used to build the string returned by dc_get_contact_encrinfo().
#define DC_STR_FINGERPRINTS 30
/// "Message opened"
///
/// Used in subjects of outgoing read receipts.
///
/// @deprecated Deprecated 2024-07-26
#define DC_STR_READRCPT 31
/// "The message '%1$s' you sent was displayed on the screen of the recipient."
///
/// Used as message text of outgoing read receipts.
/// - %1$s will be replaced by the subject of the displayed message
///
/// @deprecated Deprecated 2024-06-23
#define DC_STR_READRCPT_MAILBODY 32
/// @deprecated Deprecated, this string is no longer needed.
#define DC_STR_MSGGRPIMGDELETED 33
/// "End-to-end encryption preferred."
///
/// Used to build the string returned by dc_get_contact_encrinfo().
/// @deprecated 2025-06-05
#define DC_STR_E2E_PREFERRED 34
/// "%1$s verified"
///
/// Used in status messages.
/// - %1$s will be replaced by the name of the verified contact
#define DC_STR_CONTACT_VERIFIED 35
/// "Cannot establish guaranteed end-to-end encryption with %1$s."
///
/// Used in status messages.
/// - %1$s will be replaced by the name of the contact that cannot be verified
/// @deprecated 2025-06-05
#define DC_STR_CONTACT_NOT_VERIFIED 36
/// "Changed setup for %1$s."
///
/// Used in status messages.
/// - %1$s will be replaced by the name of the contact with the changed setup
/// @deprecated 2025-06-05
#define DC_STR_CONTACT_SETUP_CHANGED 37
/// "Archived chats"
///
/// Used as the name for the corresponding chatlist entry.
#define DC_STR_ARCHIVEDCHATS 40
/// "Autocrypt Setup Message"
///
/// @deprecated 2025-04
#define DC_STR_AC_SETUP_MSG_SUBJECT 42
/// "This is the Autocrypt Setup Message, open it in a compatible client to use your setup"
///
/// @deprecated 2025-04
#define DC_STR_AC_SETUP_MSG_BODY 43
/// "Cannot login as %1$s."
///
/// Used in error strings.
/// - %1$s will be replaced by the failing login name
#define DC_STR_CANNOT_LOGIN 60
/// "%1$s by %2$s"
///
/// Used to concretize actions,
/// - %1$s will be replaced by an action
/// as #DC_STR_MSGADDMEMBER or #DC_STR_MSGGRPIMGCHANGED (full-stop removed, if any)
/// - %2$s will be replaced by the name of the user taking that action
///
/// @deprecated 2022-09-10
#define DC_STR_MSGACTIONBYUSER 62
/// "%1$s by me"
///
/// Used to concretize actions.
/// - %1$s will be replaced by an action
/// as #DC_STR_MSGADDMEMBER or #DC_STR_MSGGRPIMGCHANGED (full-stop removed, if any)
///
/// @deprecated 2022-09-10
#define DC_STR_MSGACTIONBYME 63
/// "Location streaming enabled."
///
/// Used in status messages.
@@ -7211,13 +7012,6 @@ void dc_event_unref(dc_event_t* event);
/// Used as message text for the message added to the device chat after successful login.
#define DC_STR_WELCOME_MESSAGE 71
/// "Unknown sender for this chat. See 'info' for more details."
///
/// Use as message text if assigning the message to a chat is not totally correct.
///
/// @deprecated 2025-08-18
#define DC_STR_UNKNOWN_SENDER_FOR_CHAT 72
/// "Message from %1$s"
///
/// Used in subjects of outgoing messages in one-to-one chats.
@@ -7231,53 +7025,6 @@ void dc_event_unref(dc_event_t* event);
/// - %1$s will be replaced by the name of the contact the message cannot be sent to
#define DC_STR_FAILED_SENDING_TO 74
/// "Message deletion timer is disabled."
///
/// Used in status messages.
///
/// @deprecated 2022-09-10
#define DC_STR_EPHEMERAL_DISABLED 75
/// "Message deletion timer is set to %1$s s."
///
/// Used in status messages when the other constants
/// (#DC_STR_EPHEMERAL_MINUTE, #DC_STR_EPHEMERAL_HOUR and so on) do not match the timer.
/// - %1$s will be replaced by the number of seconds the timer is set to
///
/// @deprecated 2022-09-10
#define DC_STR_EPHEMERAL_SECONDS 76
/// "Message deletion timer is set to 1 minute."
///
/// Used in status messages.
///
/// @deprecated 2022-09-10
#define DC_STR_EPHEMERAL_MINUTE 77
/// "Message deletion timer is set to 1 hour."
///
/// Used in status messages.
///
/// @deprecated 2022-09-10
#define DC_STR_EPHEMERAL_HOUR 78
/// "Message deletion timer is set to 1 day."
///
/// Used in status messages.
///
/// @deprecated 2022-09-10
#define DC_STR_EPHEMERAL_DAY 79
/// "Message deletion timer is set to 1 week."
///
/// Used in status messages.
///
/// @deprecated 2022-09-10
#define DC_STR_EPHEMERAL_WEEK 80
/// @deprecated Deprecated 2021-01-30, DC_STR_EPHEMERAL_WEEKS is used instead.
#define DC_STR_EPHEMERAL_FOUR_WEEKS 81
/// "Error: %1$s"
///
/// Used in error strings.
@@ -7311,42 +7058,6 @@ void dc_event_unref(dc_event_t* event);
/// Used as device message text.
#define DC_STR_SELF_DELETED_MSG_BODY 91
/// "Message deletion timer is set to %1$s minutes."
///
/// Used in status messages.
///
/// `%1$s` will be replaced by the number of minutes (always >1) the timer is set to.
///
/// @deprecated Replaced by DC_STR_MSG_YOU_EPHEMERAL_TIMER_MINUTES and DC_STR_MSG_EPHEMERAL_TIMER_MINUTES_BY.
#define DC_STR_EPHEMERAL_MINUTES 93
/// "Message deletion timer is set to %1$s hours."
///
/// Used in status messages.
///
/// `%1$s` will be replaced by the number of hours (always >1) the timer is set to.
///
/// @deprecated Replaced by DC_STR_MSG_YOU_EPHEMERAL_TIMER_HOURS and DC_STR_MSG_EPHEMERAL_TIMER_HOURS_BY.
#define DC_STR_EPHEMERAL_HOURS 94
/// "Message deletion timer is set to %1$s days."
///
/// Used in status messages.
///
/// `%1$s` will be replaced by the number of days (always >1) the timer is set to.
///
/// @deprecated Replaced by DC_STR_MSG_YOU_EPHEMERAL_TIMER_DAYS and DC_STR_MSG_EPHEMERAL_TIMER_DAYS_BY.
#define DC_STR_EPHEMERAL_DAYS 95
/// "Message deletion timer is set to %1$s weeks."
///
/// Used in status messages.
///
/// `%1$s` will be replaced by the number of weeks (always >1) the timer is set to.
///
/// @deprecated Replaced by DC_STR_MSG_YOU_EPHEMERAL_TIMER_WEEKS and DC_STR_MSG_EPHEMERAL_TIMER_WEEKS_BY.
#define DC_STR_EPHEMERAL_WEEKS 96
/// "Forwarded"
///
/// Used in message summary text for notifications and chatlist.
@@ -7359,9 +7070,6 @@ void dc_event_unref(dc_event_t* event);
/// `%1$s` will be replaced by the percentage used
#define DC_STR_QUOTA_EXCEEDING_MSG_BODY 98
/// @deprecated Deprecated 2025-11-12, this string is no longer needed.
#define DC_STR_PARTIAL_DOWNLOAD_MSG_BODY 99
/// "Multi Device Synchronization"
///
/// Used in subjects of outgoing sync messages.
@@ -7387,10 +7095,6 @@ void dc_event_unref(dc_event_t* event);
/// Used as a headline in the connectivity view.
#define DC_STR_OUTGOING_MESSAGES 104
/// @deprecated Deprecated 2022-04-16, this string is no longer needed.
#define DC_STR_ONE_MOMENT 106
/// "Connected"
///
/// Used as status in the connectivity view.
@@ -7481,12 +7185,6 @@ void dc_event_unref(dc_event_t* event);
/// Used as status in the connectivity view.
#define DC_STR_NOT_CONNECTED 121
/// "%1$s changed their address from %2$s to %3$s"
///
/// Used as an info message to chats with contacts that changed their address.
/// @deprecated 2025-06-05
#define DC_STR_AEAP_ADDR_CHANGED 122
/// "You changed group name from \"%1$s\" to \"%2$s\"."
///
/// `%1$s` will be replaced by the old group name.
@@ -7601,17 +7299,6 @@ void dc_event_unref(dc_event_t* event);
/// Used in status messages.
#define DC_STR_EPHEMERAL_TIMER_SECONDS_BY_OTHER 141
/// "You set message deletion timer to 1 minute."
///
/// @deprecated 2025-11-14, this string is no longer needed
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_YOU 142
/// "Message deletion timer is set to 1 minute by %1$s."
///
/// `%1$s` will be replaced by name of the contact.
/// @deprecated 2025-11-14, this string is no longer needed
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_OTHER 143
/// "You set message deletion timer to 1 hour."
///
/// Used in status messages.
@@ -7732,7 +7419,7 @@ void dc_event_unref(dc_event_t* event);
/// "Messages are end-to-end encrypted."
///
/// Used in info messages.
/// Used in info-messages, UI may add smth. as "Tap to learn more."
#define DC_STR_CHAT_PROTECTION_ENABLED 170
/// "Others will only see this group after you sent a first message."
@@ -7781,26 +7468,9 @@ void dc_event_unref(dc_event_t* event);
/// Used as info message.
#define DC_STR_SECUREJOIN_WAIT 190
/// "Could not yet establish guaranteed end-to-end encryption, but you may already send a message."
///
/// @deprecated 2025-03
#define DC_STR_SECUREJOIN_WAIT_TIMEOUT 191
/// "The contact must be online to proceed. This process will continue automatically in background."
///
/// Used as info message.
/// @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
/// "Outgoing call"
#define DC_STR_OUTGOING_CALL 194
/// "Incoming call"
#define DC_STR_INCOMING_CALL 195
/// "Declined call"
#define DC_STR_DECLINED_CALL 196
@@ -7832,6 +7502,19 @@ void dc_event_unref(dc_event_t* event);
/// `%1$s` and `%2$s` will both be replaced by the name of the inviter.
#define DC_STR_SECURE_JOIN_CHANNEL_STARTED 203
/// "Channel name changed from %1$s to %2$s."
///
/// Used in status messages.
///
/// `%1$s` will be replaced by the old channel name.
/// `%2$s` will be replaced by the new channel name.
#define DC_STR_CHANNEL_NAME_CHANGED 204
/// "Channel image changed."
///
/// Used in status messages.
#define DC_STR_CHANNEL_IMAGE_CHANGED 205
/// "The attachment contains anonymous usage statistics, which help us improve Delta Chat. Thank you!"
///
/// Used as the message body for statistics sent out.
@@ -7852,6 +7535,29 @@ void dc_event_unref(dc_event_t* event);
/// Used as the first info messages in newly created classic email threads.
#define DC_STR_CHAT_UNENCRYPTED_EXPLANATON 230
/// "Outgoing audio call"
#define DC_STR_OUTGOING_AUDIO_CALL 232
/// "Outgoing video call"
#define DC_STR_OUTGOING_VIDEO_CALL 233
/// "Incoming audio call"
#define DC_STR_INCOMING_AUDIO_CALL 234
/// "Incoming video call"
#define DC_STR_INCOMING_VIDEO_CALL 235
/// "You changed the chat description."
#define DC_STR_GROUP_DESCRIPTION_CHANGED_BY_YOU 240
/// "Chat description changed by %1$s."
#define DC_STR_GROUP_DESCRIPTION_CHANGED_BY_OTHER 241
/// "Messages are end-to-end encrypted."
///
/// Used when creating text for the "Encryption Info" dialogs.
#define DC_STR_MESSAGES_ARE_E2EE 242
/**
* @}
*/

View File

@@ -15,6 +15,7 @@ use std::collections::BTreeMap;
use std::convert::TryFrom;
use std::fmt::Write;
use std::future::Future;
use std::mem::ManuallyDrop;
use std::ptr;
use std::str::FromStr;
use std::sync::{Arc, LazyLock, Mutex};
@@ -679,7 +680,6 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::ChatModified(_)
| EventType::ChatDeleted { .. }
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::IncomingCallAccepted { .. }
| EventType::OutgoingCallAccepted { .. }
| EventType::CallEnded { .. }
| EventType::EventChannelOverflow { .. }
@@ -702,6 +702,9 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
} => status_update_serial.to_u32() as libc::c_int,
EventType::WebxdcRealtimeData { data, .. } => data.len() as libc::c_int,
EventType::IncomingCall { has_video, .. } => *has_video as libc::c_int,
EventType::IncomingCallAccepted {
from_this_device, ..
} => *from_this_device as libc::c_int,
#[allow(unreachable_patterns)]
#[cfg(test)]
@@ -1181,6 +1184,7 @@ pub unsafe extern "C" fn dc_place_outgoing_call(
context: *mut dc_context_t,
chat_id: u32,
place_call_info: *const libc::c_char,
has_video: bool,
) -> u32 {
if context.is_null() || chat_id == 0 {
eprintln!("ignoring careless call to dc_place_outgoing_call()");
@@ -1190,7 +1194,7 @@ pub unsafe extern "C" fn dc_place_outgoing_call(
let chat_id = ChatId::new(chat_id);
let place_call_info = to_string_lossy(place_call_info);
block_on(ctx.place_outgoing_call(chat_id, place_call_info))
block_on(ctx.place_outgoing_call(chat_id, place_call_info, has_video))
.context("Failed to place call")
.log_err(ctx)
.map(|msg_id| msg_id.to_u32())
@@ -1519,6 +1523,23 @@ pub unsafe extern "C" fn dc_marknoticed_chat(context: *mut dc_context_t, chat_id
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_markfresh_chat(context: *mut dc_context_t, chat_id: u32) {
if context.is_null() {
eprintln!("ignoring careless call to dc_markfresh_chat()");
return;
}
let ctx = &*context;
block_on(async move {
chat::markfresh_chat(ctx, ChatId::new(chat_id))
.await
.context("Failed markfresh chat")
.log_err(ctx)
.unwrap_or(())
})
}
fn from_prim<S, T>(s: S) -> Option<T>
where
T: FromPrimitive,
@@ -2260,22 +2281,6 @@ pub unsafe extern "C" fn dc_get_contacts(
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_get_blocked_cnt(context: *mut dc_context_t) -> libc::c_int {
if context.is_null() {
eprintln!("ignoring careless call to dc_get_blocked_cnt()");
return 0;
}
let ctx = &*context;
block_on(async move {
Contact::get_all_blocked(ctx)
.await
.unwrap_or_log_default(ctx, "failed to get blocked count")
.len() as libc::c_int
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_get_blocked_contacts(
context: *mut dc_context_t,
@@ -2441,45 +2446,6 @@ pub unsafe extern "C" fn dc_imex_has_backup(
}
}
#[no_mangle]
pub unsafe extern "C" fn dc_initiate_key_transfer(context: *mut dc_context_t) -> *mut libc::c_char {
if context.is_null() {
eprintln!("ignoring careless call to dc_initiate_key_transfer()");
return ptr::null_mut(); // NULL explicitly defined as "error"
}
let ctx = &*context;
match block_on(imex::initiate_key_transfer(ctx))
.context("dc_initiate_key_transfer()")
.log_err(ctx)
{
Ok(res) => res.strdup(),
Err(_) => ptr::null_mut(),
}
}
#[no_mangle]
pub unsafe extern "C" fn dc_continue_key_transfer(
context: *mut dc_context_t,
msg_id: u32,
setup_code: *const libc::c_char,
) -> libc::c_int {
if context.is_null() || msg_id <= constants::DC_MSG_ID_LAST_SPECIAL || setup_code.is_null() {
eprintln!("ignoring careless call to dc_continue_key_transfer()");
return 0;
}
let ctx = &*context;
block_on(imex::continue_key_transfer(
ctx,
MsgId::new(msg_id),
&to_string_lossy(setup_code),
))
.context("dc_continue_key_transfer")
.log_err(ctx)
.is_ok() as libc::c_int
}
#[no_mangle]
pub unsafe extern "C" fn dc_stop_ongoing_process(context: *mut dc_context_t) {
if context.is_null() {
@@ -3802,16 +3768,6 @@ pub unsafe extern "C" fn dc_msg_get_webxdc_href(msg: *mut dc_msg_t) -> *mut libc
ffi_msg.message.get_webxdc_href().strdup()
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_is_setupmessage(msg: *mut dc_msg_t) -> libc::c_int {
if msg.is_null() {
eprintln!("ignoring careless call to dc_msg_is_setupmessage()");
return 0;
}
let ffi_msg = &*msg;
ffi_msg.message.is_setupmessage().into()
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_has_html(msg: *mut dc_msg_t) -> libc::c_int {
if msg.is_null() {
@@ -3822,20 +3778,6 @@ pub unsafe extern "C" fn dc_msg_has_html(msg: *mut dc_msg_t) -> libc::c_int {
ffi_msg.message.has_html().into()
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_get_setupcodebegin(msg: *mut dc_msg_t) -> *mut libc::c_char {
if msg.is_null() {
eprintln!("ignoring careless call to dc_msg_get_setupcodebegin()");
return "".strdup();
}
let ffi_msg = &*msg;
let ctx = &*ffi_msg.context;
block_on(ffi_msg.message.get_setupcodebegin(ctx))
.unwrap_or_default()
.strdup()
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_set_text(msg: *mut dc_msg_t, text: *const libc::c_char) {
if msg.is_null() {
@@ -5165,10 +5107,10 @@ pub unsafe extern "C" fn dc_jsonrpc_init(
return ptr::null_mut();
}
let account_manager = Arc::from_raw(account_manager);
let cmd_api = block_on(deltachat_jsonrpc::api::CommandApi::from_arc(
account_manager.clone(),
));
let account_manager = ManuallyDrop::new(Arc::from_raw(account_manager));
let cmd_api = block_on(deltachat_jsonrpc::api::CommandApi::from_arc(Arc::clone(
&account_manager,
)));
let (request_handle, receiver) = RpcClient::new();
let handle = RpcSession::new(request_handle, cmd_api);

View File

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

View File

@@ -11,8 +11,8 @@ use deltachat::blob::BlobObject;
use deltachat::calls::ice_servers;
use deltachat::chat::{
self, add_contact_to_chat, forward_msgs, forward_msgs_2ctx, get_chat_media, get_chat_msgs,
get_chat_msgs_ex, marknoticed_chat, remove_contact_from_chat, Chat, ChatId, ChatItem,
MessageListOptions,
get_chat_msgs_ex, marknoticed_all_chats, marknoticed_chat, remove_contact_from_chat, Chat,
ChatId, ChatItem, MessageListOptions,
};
use deltachat::chatlist::Chatlist;
use deltachat::config::{get_all_ui_config_keys, Config};
@@ -23,15 +23,15 @@ use deltachat::ephemeral::Timer;
use deltachat::imex;
use deltachat::location;
use deltachat::message::{
self, delete_msgs_ex, get_existing_msg_ids, get_msg_read_receipts, markseen_msgs, Message,
MessageState, MsgId, Viewtype,
self, delete_msgs_ex, get_existing_msg_ids, get_msg_read_receipt_count, get_msg_read_receipts,
markseen_msgs, Message, MessageState, MsgId, Viewtype,
};
use deltachat::peer_channels::{
leave_webxdc_realtime, send_webxdc_realtime_advertisement, send_webxdc_realtime_data,
};
use deltachat::provider::get_provider_info;
use deltachat::qr::{self, Qr};
use deltachat::qr_code_generator::{generate_backup_qr, get_securejoin_qr_svg};
use deltachat::qr_code_generator::{create_qr_svg, generate_backup_qr, get_securejoin_qr_svg};
use deltachat::reaction::{get_msg_reactions, send_reaction};
use deltachat::securejoin;
use deltachat::stock_str::StockMessage;
@@ -68,6 +68,7 @@ use self::types::{
},
};
use crate::api::types::chat_list::{get_chat_list_item_by_id, ChatListItemFetchResult};
use crate::api::types::login_param::TransportListEntry;
use crate::api::types::qr::{QrObject, SecurejoinSource, SecurejoinUiPath};
#[derive(Debug)]
@@ -194,6 +195,16 @@ impl CommandApi {
.context("event channel is closed")
}
/// Waits for at least one event and return a batch of events.
async fn get_next_event_batch(&self) -> Vec<Event> {
self.event_emitter
.recv_batch()
.await
.into_iter()
.map(|event| event.into())
.collect()
}
// ---------------------------------------------
// Account Management
// ---------------------------------------------
@@ -518,6 +529,7 @@ impl CommandApi {
/// from a server encoded in a QR code.
/// - [Self::list_transports()] to get a list of all configured transports.
/// - [Self::delete_transport()] to remove a transport.
/// - [Self::set_transport_unpublished()] to set whether contacts see this transport.
async fn add_or_update_transport(
&self,
account_id: u32,
@@ -543,7 +555,23 @@ impl CommandApi {
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to delete a transport.
/// Use [Self::list_transports_ex()] to additionally query
/// whether the transports are marked as 'unpublished'.
async fn list_transports(&self, account_id: u32) -> Result<Vec<EnteredLoginParam>> {
let ctx = self.get_context(account_id).await?;
let res = ctx
.list_transports()
.await?
.into_iter()
.map(|t| t.param.into())
.collect();
Ok(res)
}
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to delete a transport.
async fn list_transports_ex(&self, account_id: u32) -> Result<Vec<TransportListEntry>> {
let ctx = self.get_context(account_id).await?;
let res = ctx
.list_transports()
@@ -561,6 +589,26 @@ impl CommandApi {
ctx.delete_transport(&addr).await
}
/// Change whether the transport is unpublished.
///
/// Unpublished transports are not advertised to contacts,
/// and self-sent messages are not sent there,
/// so that we don't cause extra messages to the corresponding inbox,
/// but can still receive messages from contacts who don't know our new transport addresses yet.
///
/// The default is false, but when the user updates from a version that didn't have this flag,
/// existing secondary transports are set to unpublished,
/// so that an existing transport address doesn't suddenly get spammed with a lot of messages.
async fn set_transport_unpublished(
&self,
account_id: u32,
addr: String,
unpublished: bool,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.set_transport_unpublished(&addr, unpublished).await
}
/// Signal an ongoing process to stop.
async fn stop_ongoing_process(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
@@ -689,25 +737,6 @@ impl CommandApi {
message::estimate_deletion_cnt(&ctx, from_server, seconds).await
}
// ---------------------------------------------
// autocrypt
// ---------------------------------------------
async fn initiate_autocrypt_key_transfer(&self, account_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
deltachat::imex::initiate_key_transfer(&ctx).await
}
async fn continue_autocrypt_key_transfer(
&self,
account_id: u32,
message_id: u32,
setup_code: String,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
deltachat::imex::continue_key_transfer(&ctx, MsgId::new(message_id), &setup_code).await
}
// ---------------------------------------------
// chat list
// ---------------------------------------------
@@ -854,6 +883,8 @@ impl CommandApi {
/// if `checkQr()` returns `askVerifyContact` or `askVerifyGroup`
/// an out-of-band-verification can be joined using `secure_join()`
///
/// @deprecated as of 2026-03; use create_qr_svg(get_chat_securejoin_qr_code()) instead.
///
/// chat_id: If set to a group-chat-id,
/// the Verified-Group-Invite protocol is offered in the QR code;
/// works for protected groups as well as for normal groups.
@@ -1068,7 +1099,8 @@ impl CommandApi {
/// Set group name.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// all group members are informed by a special status message that is sent automatically by this function.
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
async fn set_chat_name(&self, account_id: u32, chat_id: u32, new_name: String) -> Result<()> {
@@ -1076,10 +1108,39 @@ impl CommandApi {
chat::set_chat_name(&ctx, ChatId::new(chat_id), &new_name).await
}
/// Set group or broadcast channel description.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
///
/// See also [`Self::get_chat_description`] / `getChatDescription()`.
async fn set_chat_description(
&self,
account_id: u32,
chat_id: u32,
description: String,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
chat::set_chat_description(&ctx, ChatId::new(chat_id), &description).await
}
/// Load the chat description from the database.
///
/// UIs show this in the profile page of the chat,
/// it is settable by [`Self::set_chat_description`] / `setChatDescription()`.
async fn get_chat_description(&self, account_id: u32, chat_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
chat::get_chat_description(&ctx, ChatId::new(chat_id)).await
}
/// Set group profile image.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// all group members are informed by a special status message that is sent automatically by this function.
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
///
@@ -1164,10 +1225,24 @@ impl CommandApi {
Ok(None)
}
/// Mark all messages in all chats as _noticed_.
/// Skips messages from blocked contacts, but does not skip messages in muted chats.
///
/// _Noticed_ messages are no longer _fresh_ and do not count as being unseen
/// but are still waiting for being marked as "seen" using markseen_msgs()
/// (read receipts aren't sent for noticed messages).
///
/// Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
/// See also markseen_msgs().
pub async fn marknoticed_all_chats(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
marknoticed_all_chats(&ctx).await
}
/// Mark all messages in a chat as _noticed_.
/// _Noticed_ messages are no longer _fresh_ and do not count as being unseen
/// but are still waiting for being marked as "seen" using markseen_msgs()
/// (IMAP/MDNs is not done for noticed messages).
/// (read receipts aren't sent for noticed messages).
///
/// Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
/// See also markseen_msgs().
@@ -1434,6 +1509,18 @@ impl CommandApi {
MessageInfo::from_msg_id(&ctx, MsgId::new(message_id)).await
}
/// Returns count of read receipts on message.
///
/// This view count is meant as a feedback measure for the channel owner only.
async fn get_message_read_receipt_count(
&self,
account_id: u32,
message_id: u32,
) -> Result<usize> {
let ctx = self.get_context(account_id).await?;
get_msg_read_receipt_count(&ctx, MsgId::new(message_id)).await
}
/// Returns contacts that sent read receipts and the time of reading.
async fn get_message_read_receipts(
&self,
@@ -1914,6 +2001,8 @@ impl CommandApi {
/// even if there is no concurrent call to [`CommandApi::provide_backup`],
/// but will fail after 60 seconds to avoid deadlocks.
///
/// @deprecated as of 2026-03; use `create_qr_svg(get_backup_qr())` instead.
///
/// Returns the QR code rendered as an SVG image.
async fn get_backup_qr_svg(&self, account_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
@@ -1927,6 +2016,11 @@ impl CommandApi {
generate_backup_qr(&ctx, &qr).await
}
/// Renders the given text as a QR code SVG image.
async fn create_qr_svg(&self, text: String) -> Result<String> {
create_qr_svg(&text)
}
/// Gets a backup from a remote provider.
///
/// This retrieves the backup from a remote device over the network and imports it into
@@ -2141,10 +2235,11 @@ impl CommandApi {
account_id: u32,
chat_id: u32,
place_call_info: String,
has_video: bool,
) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
let msg_id = ctx
.place_outgoing_call(ChatId::new(chat_id), place_call_info)
.place_outgoing_call(ChatId::new(chat_id), place_call_info, has_video)
.await?;
Ok(msg_id.to_u32())
}
@@ -2439,7 +2534,10 @@ impl CommandApi {
continue;
}
let sticker_name = sticker_entry.file_name().into_string().unwrap_or_default();
if sticker_name.ends_with(".png") || sticker_name.ends_with(".webp") {
if sticker_name.ends_with(".png")
|| sticker_name.ends_with(".webp")
|| sticker_name.ends_with(".gif")
{
sticker_paths.push(
sticker_entry
.path()

View File

@@ -1,6 +1,6 @@
use anyhow::{Context as _, Result};
use deltachat::calls::{call_state, sdp_has_video, CallState};
use deltachat::calls::{call_state, CallState};
use deltachat::context::Context;
use deltachat::message::MsgId;
use serde::Serialize;
@@ -15,7 +15,7 @@ pub struct JsonrpcCallInfo {
/// even if incoming call event was missed.
pub sdp_offer: String,
/// True if SDP offer has a video.
/// True if the call is started as a video call.
pub has_video: bool,
/// Call state.
@@ -30,7 +30,7 @@ impl JsonrpcCallInfo {
format!("Attempting to get call state of non-call message {msg_id}")
})?;
let sdp_offer = call_info.place_call_info.clone();
let has_video = sdp_has_video(&sdp_offer).unwrap_or_default();
let has_video = call_info.has_video_initially();
let state = JsonrpcCallState::from_msg_id(context, msg_id).await?;
Ok(JsonrpcCallInfo {

View File

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

View File

@@ -4,6 +4,16 @@ use serde::Deserialize;
use serde::Serialize;
use yerpc::TypeDef;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct TransportListEntry {
/// The login data entered by the user.
pub param: EnteredLoginParam,
/// Whether this transport is set to 'unpublished'.
/// See `set_transport_unpublished` / `setTransportUnpublished` for details.
pub is_unpublished: bool,
}
/// Login parameters entered by the user.
///
/// Usually it will be enough to only set `addr` and `password`,
@@ -56,6 +66,15 @@ pub struct EnteredLoginParam {
pub oauth2: Option<bool>,
}
impl From<dc::TransportListEntry> for TransportListEntry {
fn from(transport: dc::TransportListEntry) -> Self {
TransportListEntry {
param: transport.param.into(),
is_unpublished: transport.is_unpublished,
}
}
}
impl From<dc::EnteredLoginParam> for EnteredLoginParam {
fn from(param: dc::EnteredLoginParam) -> Self {
let imap_security: Socket = param.imap.security.into();

View File

@@ -68,7 +68,6 @@ pub struct MessageObject {
/// if `show_padlock` is `false`,
/// and nothing if it is `true`.
show_padlock: bool,
is_setupmessage: bool,
is_info: bool,
is_forwarded: bool,
@@ -88,8 +87,6 @@ pub struct MessageObject {
override_sender_name: Option<String>,
sender: ContactObject,
setup_code_begin: Option<String>,
file: Option<String>,
file_mime: Option<String>,
@@ -226,7 +223,6 @@ impl MessageObject {
subject: message.get_subject().to_owned(),
show_padlock: message.get_showpadlock(),
is_setupmessage: message.is_setupmessage(),
is_info: message.is_info(),
is_forwarded: message.is_forwarded(),
is_bot: message.is_bot(),
@@ -243,8 +239,6 @@ impl MessageObject {
override_sender_name,
sender,
setup_code_begin: message.get_setupcodebegin(context).await,
file: match message.get_file(context) {
Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()),
None => None,
@@ -388,6 +382,7 @@ impl From<download::DownloadState> for DownloadState {
pub enum SystemMessageType {
Unknown,
GroupNameChanged,
GroupDescriptionChanged,
GroupImageChanged,
MemberAddedToGroup,
MemberRemovedFromGroup,
@@ -440,6 +435,7 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
match system_message_type {
SystemMessage::Unknown => SystemMessageType::Unknown,
SystemMessage::GroupNameChanged => SystemMessageType::GroupNameChanged,
SystemMessage::GroupDescriptionChanged => SystemMessageType::GroupDescriptionChanged,
SystemMessage::GroupImageChanged => SystemMessageType::GroupImageChanged,
SystemMessage::MemberAddedToGroup => SystemMessageType::MemberAddedToGroup,
SystemMessage::MemberRemovedFromGroup => SystemMessageType::MemberRemovedFromGroup,

View File

@@ -19,6 +19,8 @@ pub enum QrObject {
invitenumber: String,
/// Authentication code.
authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Ask the user whether to join the group.
AskVerifyGroup {
@@ -34,6 +36,8 @@ pub enum QrObject {
invitenumber: String,
/// Authentication code.
authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Ask the user whether to join the broadcast channel.
AskJoinBroadcast {
@@ -54,6 +58,8 @@ pub enum QrObject {
invitenumber: String,
/// Authentication code.
authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Contact fingerprint is verified.
///
@@ -229,6 +235,7 @@ impl From<Qr> for QrObject {
fingerprint,
invitenumber,
authcode,
is_v3,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
@@ -237,6 +244,7 @@ impl From<Qr> for QrObject {
fingerprint,
invitenumber,
authcode,
is_v3,
}
}
Qr::AskVerifyGroup {
@@ -246,6 +254,7 @@ impl From<Qr> for QrObject {
fingerprint,
invitenumber,
authcode,
is_v3,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
@@ -256,6 +265,7 @@ impl From<Qr> for QrObject {
fingerprint,
invitenumber,
authcode,
is_v3,
}
}
Qr::AskJoinBroadcast {
@@ -265,6 +275,7 @@ impl From<Qr> for QrObject {
fingerprint,
authcode,
invitenumber,
is_v3,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
@@ -275,6 +286,7 @@ impl From<Qr> for QrObject {
fingerprint,
authcode,
invitenumber,
is_v3,
}
}
Qr::FprOk { contact_id } => {

View File

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

View File

@@ -53,18 +53,19 @@ export class BaseDeltaChat<
*/
async eventLoop(): Promise<void> {
while (true) {
const event = await this.rpc.getNextEvent();
//@ts-ignore
this.emit(event.event.kind, event.contextId, event.event);
this.emit("ALL", event.contextId, event.event);
for (const event of await this.rpc.getNextEventBatch()) {
//@ts-ignore
this.emit(event.event.kind, event.contextId, event.event);
this.emit("ALL", event.contextId, event.event);
if (this.contextEmitters[event.contextId]) {
this.contextEmitters[event.contextId].emit(
event.event.kind,
//@ts-ignore
event.event as any,
);
this.contextEmitters[event.contextId].emit("ALL", event.event as any);
if (this.contextEmitters[event.contextId]) {
this.contextEmitters[event.contextId].emit(
event.event.kind,
//@ts-ignore
event.event as any,
);
this.contextEmitters[event.contextId].emit("ALL", event.event as any);
}
}
}
}

View File

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

View File

@@ -302,9 +302,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
// TODO: reuse commands definition in main.rs.
"imex" => println!(
"====================Import/Export commands==\n\
initiate-key-transfer\n\
get-setupcodebegin <msg-id>\n\
continue-key-transfer <msg-id> <setup-code>\n\
has-backup\n\
export-backup\n\
import-backup <backup-file>\n\
@@ -343,6 +340,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
addmember <contact-id>\n\
removemember <contact-id>\n\
groupname <name>\n\
groupdescription <description>\n\
groupimage <image>\n\
chatinfo\n\
sendlocations <seconds>\n\
@@ -407,34 +405,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
============================================="
),
},
"initiate-key-transfer" => match initiate_key_transfer(&context).await {
Ok(setup_code) => {
println!("Setup code for the transferred setup message: {setup_code}",)
}
Err(err) => bail!("Failed to generate setup code: {err}"),
},
"get-setupcodebegin" => {
ensure!(!arg1.is_empty(), "Argument <msg-id> missing.");
let msg_id: MsgId = MsgId::new(arg1.parse()?);
let msg = Message::load_from_db(&context, msg_id).await?;
if msg.is_setupmessage() {
let setupcodebegin = msg.get_setupcodebegin(&context).await;
println!(
"The setup code for setup message {} starts with: {}",
msg_id,
setupcodebegin.unwrap_or_default(),
);
} else {
bail!("{msg_id} is no setup message.",);
}
}
"continue-key-transfer" => {
ensure!(
!arg1.is_empty() && !arg2.is_empty(),
"Arguments <msg-id> <setup-code> expected"
);
continue_key_transfer(&context, MsgId::new(arg1.parse()?), arg2).await?;
}
"has-backup" => {
has_backup(&context, blobdir).await?;
}
@@ -770,6 +740,13 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Chat name set");
}
"groupdescription" => {
ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "Argument <description> missing.");
chat::set_chat_description(&context, sel_chat.as_ref().unwrap().get_id(), arg1).await?;
println!("Chat description set");
}
"groupimage" => {
ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "Argument <image> missing.");

View File

@@ -149,10 +149,7 @@ impl Completer for DcHelper {
}
}
const IMEX_COMMANDS: [&str; 13] = [
"initiate-key-transfer",
"get-setupcodebegin",
"continue-key-transfer",
const IMEX_COMMANDS: [&str; 10] = [
"has-backup",
"export-backup",
"import-backup",
@@ -179,7 +176,7 @@ const DB_COMMANDS: [&str; 11] = [
"housekeeping",
];
const CHAT_COMMANDS: [&str; 39] = [
const CHAT_COMMANDS: [&str; 40] = [
"listchats",
"listarchived",
"start-realtime",
@@ -192,6 +189,7 @@ const CHAT_COMMANDS: [&str; 39] = [
"addmember",
"removemember",
"groupname",
"groupdescription",
"groupimage",
"chatinfo",
"sendlocations",

View File

@@ -2,6 +2,9 @@
RPC client connects to standalone Delta Chat RPC server `deltachat-rpc-server`
and provides asynchronous interface to it.
`rpc.start()` performs a health-check RPC call to verify the server
started successfully and will raise an error if startup fails
(e.g. if the accounts directory could not be used).
## Getting started

View File

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

View File

@@ -8,7 +8,7 @@ from .const import EventType, SpecialContactId
from .contact import Contact
from .deltachat import DeltaChat
from .message import Message
from .rpc import Rpc
from .rpc import JsonRpcError, Rpc
__all__ = [
"Account",
@@ -19,6 +19,7 @@ __all__ = [
"Contact",
"DeltaChat",
"EventType",
"JsonRpcError",
"Message",
"SpecialContactId",
"Rpc",

View File

@@ -1,4 +1,5 @@
import argparse
import functools
import os
import re
import sys
@@ -186,6 +187,7 @@ class futuremethod: # noqa: N801
"""Decorator for async methods."""
def __init__(self, func):
functools.update_wrapper(self, func)
self._func = func
def __get__(self, instance, owner=None):

View File

@@ -483,10 +483,6 @@ class Account:
passphrase = "" # Importing passphrase-protected keys is currently not supported.
self._rpc.import_self_keys(self.id, str(path), passphrase)
def initiate_autocrypt_key_transfer(self) -> None:
"""Send Autocrypt Setup Message."""
return self._rpc.initiate_autocrypt_key_transfer(self.id)
def ice_servers(self) -> list:
"""Return ICE servers for WebRTC configuration."""
ice_servers_json = self._rpc.ice_servers(self.id)

View File

@@ -303,7 +303,7 @@ class Chat:
f.flush()
self._rpc.send_msg(self.account.id, self.id, {"viewtype": ViewType.VCARD, "file": f.name})
def place_outgoing_call(self, place_call_info: str) -> Message:
def place_outgoing_call(self, place_call_info: str, has_video_initially: bool) -> Message:
"""Starts an outgoing call."""
msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info)
msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info, has_video_initially)
return Message(self.account, msg_id)

View File

@@ -104,7 +104,7 @@ class Client:
def _process_events(
self,
until_func: Callable[[AttrDict], bool],
until_func: Callable[[AttrDict], bool] = _forever,
until_event: EventType = False,
) -> AttrDict:
"""Process events until the given callable evaluates to True,

View File

@@ -44,6 +44,14 @@ class Message:
read_receipts = self._rpc.get_message_read_receipts(self.account.id, self.id)
return [AttrDict(read_receipt) for read_receipt in read_receipts]
def get_read_receipt_count(self) -> int:
"""
Returns count of read receipts on message.
This view count is meant as a feedback measure for the channel owner only.
"""
return self._rpc.get_message_read_receipt_count(self.account.id, self.id)
def get_reactions(self) -> Optional[AttrDict]:
"""Get message reactions."""
reactions = self._rpc.get_message_reactions(self.account.id, self.id)
@@ -64,14 +72,6 @@ class Message:
"""Return True if the message exists."""
return bool(self._rpc.get_existing_msg_ids(self.account.id, [self.id]))
def continue_autocrypt_key_transfer(self, setup_code: str) -> None:
"""Continue the Autocrypt Setup Message key transfer.
This function can be called on received Autocrypt Setup Message
to import the key encrypted with the provided setup code.
"""
self._rpc.continue_autocrypt_key_transfer(self.account.id, self.id, setup_code)
def send_webxdc_status_update(self, update: Union[dict, str], description: str) -> None:
"""Send a webxdc status update. This message must be a webxdc."""
if not isinstance(update, str):

View File

@@ -22,7 +22,7 @@ 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".
Currently this is "Messages are end-to-end encrypted."
"""
@@ -54,13 +54,13 @@ class ACFactory:
def get_credentials(self) -> (str, str):
"""Generate new credentials for chatmail account."""
domain = os.getenv("CHATMAIL_DOMAIN")
domain = os.environ["CHATMAIL_DOMAIN"]
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
return f"{username}@{domain}", f"{username}${username}"
def get_account_qr(self):
"""Return "dcaccount:" QR code for testing chatmail relay."""
domain = os.getenv("CHATMAIL_DOMAIN")
domain = os.environ["CHATMAIL_DOMAIN"]
return f"dcaccount:{domain}"
@futuremethod

View File

@@ -54,7 +54,12 @@ class RpcMethod:
class Rpc:
"""RPC client."""
def __init__(self, accounts_dir: Optional[str] = None, rpc_server_path="deltachat-rpc-server", **kwargs):
def __init__(
self,
accounts_dir: Optional[str] = None,
rpc_server_path="deltachat-rpc-server",
**kwargs,
):
"""Initialize RPC client.
The 'kwargs' arguments will be passed to subprocess.Popen().
@@ -79,8 +84,15 @@ class Rpc:
self.events_thread: Thread
def start(self) -> None:
"""Start RPC server subprocess."""
popen_kwargs = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE}
"""Start RPC server subprocess and wait for successful initialization.
This method blocks until the RPC server responds to an initial
health-check RPC call (get_system_info).
If the server fails to start
(e.g., due to an invalid accounts directory),
a JsonRpcError is raised.
"""
popen_kwargs = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE}
if sys.version_info >= (3, 11):
# Prevent subprocess from capturing SIGINT.
popen_kwargs["process_group"] = 0
@@ -90,6 +102,7 @@ class Rpc:
popen_kwargs.update(self._kwargs)
self.process = subprocess.Popen(self.rpc_server_path, **popen_kwargs)
self.id_iterator = itertools.count(start=1)
self.event_queues = {}
self.request_results = {}
@@ -102,6 +115,22 @@ class Rpc:
self.events_thread = Thread(target=self.events_loop)
self.events_thread.start()
# Perform a health-check RPC call to ensure the server started
# successfully and the accounts directory is usable.
try:
system_info = self.get_system_info()
except (JsonRpcError, Exception) as e:
# The reader_loop already saw EOF on stdout, so the process
# has exited and stderr is available.
stderr = self.process.stderr.read().decode(errors="replace").strip()
if stderr:
raise JsonRpcError(f"RPC server failed to start: {stderr}") from e
raise JsonRpcError(f"RPC server startup check failed: {e}") from e
logging.info(
"RPC server ready. Core version: %s",
system_info.get("deltachat_core_version", "unknown"),
)
def close(self) -> None:
"""Terminate RPC server process and wait until the reader loop finishes."""
self.closing = True
@@ -132,6 +161,10 @@ class Rpc:
except Exception:
# Log an exception if the reader loop dies.
logging.exception("Exception in the reader loop")
finally:
# Unblock any pending requests when the server closes stdout.
for _request_id, queue in self.request_results.items():
queue.put({"error": {"code": -32000, "message": "RPC server closed"}})
def writer_loop(self) -> None:
"""Writer loop ensuring only a single thread writes requests."""
@@ -140,7 +173,6 @@ class Rpc:
data = (json.dumps(request) + "\n").encode()
self.process.stdin.write(data)
self.process.stdin.flush()
except Exception:
# Log an exception if the writer loop dies.
logging.exception("Exception in the writer loop")
@@ -154,15 +186,15 @@ class Rpc:
def events_loop(self) -> None:
"""Request new events and distributes them between queues."""
try:
while True:
while events := self.get_next_event_batch():
for event in events:
account_id = event["contextId"]
queue = self.get_queue(account_id)
payload = event["event"]
logging.debug("account_id=%d got an event %s", account_id, payload)
queue.put(payload)
if self.closing:
return
event = self.get_next_event()
account_id = event["contextId"]
queue = self.get_queue(account_id)
event = event["event"]
logging.debug("account_id=%d got an event %s", account_id, event)
queue.put(event)
except Exception:
# Log an exception if the event loop dies.
logging.exception("Exception in the event loop")

View File

@@ -10,15 +10,15 @@ def test_calls(acfactory) -> None:
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info)
outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info, has_video_initially=True)
assert outgoing_call_message.get_call_info().state.kind == "Alerting"
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
assert incoming_call_event.place_call_info == place_call_info
assert not incoming_call_event.has_video # Cannot be parsed as SDP, so false by default
assert incoming_call_event.has_video
incoming_call_message = Message(bob, incoming_call_event.msg_id)
assert incoming_call_message.get_call_info().state.kind == "Alerting"
assert not incoming_call_message.get_call_info().has_video
assert incoming_call_message.get_call_info().has_video
incoming_call_message.accept_incoming_call(accept_call_info)
assert incoming_call_message.get_call_info().sdp_offer == place_call_info
@@ -41,46 +41,38 @@ def test_video_call(acfactory) -> None:
#
# `s=` cannot be empty according to RFC 3264,
# so it is more clear as `s=-`.
place_call_info = """v=0\r
o=alice 2890844526 2890844526 IN IP6 2001:db8::3\r
s=-\r
c=IN IP6 2001:db8::3\r
t=0 0\r
a=group:BUNDLE foo bar\r
\r
m=audio 10000 RTP/AVP 0 8 97\r
b=AS:200\r
a=mid:foo\r
a=rtcp-mux\r
a=rtpmap:0 PCMU/8000\r
a=rtpmap:8 PCMA/8000\r
a=rtpmap:97 iLBC/8000\r
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r
\r
m=video 10002 RTP/AVP 31 32\r
b=AS:1000\r
a=mid:bar\r
a=rtcp-mux\r
a=rtpmap:31 H261/90000\r
a=rtpmap:32 MPV/90000\r
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r
"""
alice, bob = acfactory.get_online_accounts(2)
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.place_outgoing_call(place_call_info)
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
assert incoming_call_event.place_call_info == place_call_info
assert incoming_call_event.place_call_info == "offer"
assert incoming_call_event.has_video
incoming_call_message = Message(bob, incoming_call_event.msg_id)
assert incoming_call_message.get_call_info().has_video
def test_audio_call(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.place_outgoing_call("offer", has_video_initially=False)
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
assert incoming_call_event.place_call_info == "offer"
assert not incoming_call_event.has_video
incoming_call_message = Message(bob, incoming_call_event.msg_id)
assert not incoming_call_message.get_call_info().has_video
def test_ice_servers(acfactory) -> None:
alice = acfactory.get_online_account()
@@ -92,7 +84,7 @@ def test_no_contact_request_call(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer")
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
alice_chat_bob.send_text("Hello!")
# Notification for "Hello!" message should arrive
@@ -119,7 +111,7 @@ def test_who_can_call_me_nobody(acfactory) -> None:
bob.create_chat(alice)
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer")
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
alice_chat_bob.send_text("Hello!")
# Notification for "Hello!" message should arrive
@@ -144,7 +136,7 @@ def test_who_can_call_me_everybody(acfactory) -> None:
bob.set_config("who_can_call_me", "0")
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer")
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
incoming_call_message = Message(bob, incoming_call_event.msg_id)

View File

@@ -8,8 +8,10 @@ from imap_tools import AND, U
from deltachat_rpc_client import Contact, EventType, Message
def test_move_works(acfactory):
def test_move_works(acfactory, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("mvbox_move", "1")
ac2.bring_online()
@@ -24,68 +26,6 @@ def test_move_works(acfactory):
assert msg.text == "message1"
def test_move_avoids_loop(acfactory, direct_imap):
"""Test that the message is only moved from INBOX to DeltaChat.
This is to avoid busy loop if moved message reappears in the Inbox
or some scanned folder later.
For example, this happens on servers that alias `INBOX.DeltaChat` to `DeltaChat` folder,
so the message moved to `DeltaChat` appears as a new message in the `INBOX.DeltaChat` folder.
We do not want to move this message from `INBOX.DeltaChat` to `DeltaChat` again.
"""
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.set_config("mvbox_move", "1")
ac2.set_config("delete_server_after", "0")
ac2.bring_online()
# Create INBOX.DeltaChat folder and make sure
# it is detected by full folder scan.
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("INBOX.DeltaChat")
ac2.stop_io()
ac2.start_io()
while True:
event = ac2.wait_for_event()
# Wait until the end of folder scan.
if event.kind == EventType.INFO and "Found folders:" in event.msg:
break
ac1_chat = acfactory.get_accepted_chat(ac1, ac2)
ac1_chat.send_text("Message 1")
# Message is moved to the DeltaChat folder and downloaded.
ac2_msg1 = ac2.wait_for_incoming_msg().get_snapshot()
assert ac2_msg1.text == "Message 1"
# Move the message to the INBOX.DeltaChat again.
# We assume that test server uses "." as the delimiter.
ac2_direct_imap.select_folder("DeltaChat")
ac2_direct_imap.conn.move(["*"], "INBOX.DeltaChat")
ac1_chat.send_text("Message 2")
ac2_msg2 = ac2.wait_for_incoming_msg().get_snapshot()
assert ac2_msg2.text == "Message 2"
# Stop and start I/O to trigger folder scan.
ac2.stop_io()
ac2.start_io()
while True:
event = ac2.wait_for_event()
# Wait until the end of folder scan.
if event.kind == EventType.INFO and "Found folders:" in event.msg:
break
# Check that Message 1 is still in the INBOX.DeltaChat folder
# and Message 2 is in the DeltaChat folder.
ac2_direct_imap.select_folder("INBOX")
assert len(ac2_direct_imap.get_all_messages()) == 0
ac2_direct_imap.select_folder("DeltaChat")
assert len(ac2_direct_imap.get_all_messages()) == 1
ac2_direct_imap.select_folder("INBOX.DeltaChat")
assert len(ac2_direct_imap.get_all_messages()) == 1
def test_reactions_for_a_reordering_move(acfactory, direct_imap):
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
@@ -97,6 +37,8 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
addr, password = acfactory.get_credentials()
ac2 = acfactory.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password})
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("mvbox_move", "1")
assert ac2.is_configured()
@@ -130,174 +72,12 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
def test_delete_deltachat_folder(acfactory, direct_imap):
"""Test that DeltaChat folder is recreated if user deletes it manually."""
ac1 = acfactory.new_configured_account()
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.conn.folder.delete("DeltaChat")
assert "DeltaChat" not in ac1_direct_imap.list_folders()
# Wait until new folder is created and UIDVALIDITY is updated.
while True:
event = ac1.wait_for_event()
if event.kind == EventType.INFO and "transport 1: UID validity for folder DeltaChat changed from " in event.msg:
break
ac2 = acfactory.get_online_account()
ac2.create_chat(ac1).send_text("hello")
msg = ac1.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
assert "DeltaChat" in ac1_direct_imap.list_folders()
def test_dont_show_emails(acfactory, direct_imap, log):
"""Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them.
So: If it's outgoing AND there is no Received header, then ignore the email.
If the draft email is sent out and received later (i.e. it's in "Inbox"), it must be shown.
Also, test that unknown emails in the Spam folder are not shown."""
ac1 = acfactory.new_configured_account()
ac1.stop_io()
ac1.set_config("show_emails", "2")
ac1.create_contact("alice@example.org").create_chat()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder("Drafts")
ac1_direct_imap.create_folder("Spam")
ac1_direct_imap.create_folder("Junk")
# Learn UID validity for all folders.
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.start_io()
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
ac1.stop_io()
ac1_direct_imap.append(
"Drafts",
"""
From: ac1 <{}>
Subject: subj
To: alice@example.org
Message-ID: <aepiors@example.org>
Content-Type: text/plain; charset=utf-8
message in Drafts received later
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org, unkwnown.add@junk.org
Subject: subj
To: {}
Message-ID: <spam.message2@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: delta<address: inbox@nhroy.com>
Subject: subj
To: {}
Message-ID: <spam.message99@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: alice@example.org
Subject: subj
To: {}
Message-ID: <spam.message3@junk.org>
Content-Type: text/plain; charset=utf-8
Actually interesting message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Junk",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Junk
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.set_config("scan_all_folders_debounce_secs", "0")
log.section("All prepared, now let DC find the message")
ac1.start_io()
# Wait until each folder was scanned, this is necessary for this test to test what it should test:
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
fresh_msgs = list(ac1.get_fresh_messages())
msg = fresh_msgs[0].get_snapshot()
chat_msgs = msg.chat.get_messages()
assert len(chat_msgs) == 1
assert msg.text == "subj Actually interesting message in Spam"
assert not any("unknown.address" in c.get_full_snapshot().name for c in ac1.get_chatlist())
ac1_direct_imap.select_folder("Spam")
assert ac1_direct_imap.get_uid_by_message_id("spam.message@junk.org")
ac1.stop_io()
log.section("'Send out' the draft by moving it to Inbox, and wait for DC to display it this time")
ac1_direct_imap.select_folder("Drafts")
uid = ac1_direct_imap.get_uid_by_message_id("aepiors@example.org")
ac1_direct_imap.conn.move(uid, "Inbox")
ac1.start_io()
event = ac1.wait_for_event(EventType.MSGS_CHANGED)
msg2 = Message(ac1, event.msg_id).get_snapshot()
assert msg2.text == "subj message in Drafts received later"
assert len(msg.chat.get_messages()) == 2
def test_move_works_on_self_sent(acfactory):
def test_move_works_on_self_sent(acfactory, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
# Enable movebox and wait until it is created.
# Create and enable movebox.
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder("DeltaChat")
ac1.set_config("mvbox_move", "1")
ac1.set_config("bcc_self", "1")
ac1.bring_online()
@@ -314,6 +94,8 @@ def test_move_works_on_self_sent(acfactory):
def test_moved_markseen(acfactory, direct_imap):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("mvbox_move", "1")
ac2.set_config("delete_server_after", "0")
ac2.set_config("sync_msgs", "0") # Do not send a sync message when accepting a contact request.
@@ -356,6 +138,8 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
for ac in ac1, ac2:
ac.set_config("delete_server_after", "0")
if mvbox_move:
ac_direct_imap = direct_imap(ac)
ac_direct_imap.create_folder("DeltaChat")
ac.set_config("mvbox_move", "1")
ac.bring_online()
@@ -390,120 +174,12 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
def test_mvbox_and_trash(acfactory, direct_imap, log):
log.section("ac1: start with mvbox")
ac1 = acfactory.get_online_account()
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
log.section("ac2: start without a mvbox")
ac2 = acfactory.get_online_account()
log.section("ac1: create trash")
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder("Trash")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.stop_io()
ac1.start_io()
log.section("ac1: send message and wait for ac2 to receive it")
acfactory.get_accepted_chat(ac1, ac2).send_text("message1")
assert ac2.wait_for_incoming_msg().get_snapshot().text == "message1"
assert ac1.get_config("configured_mvbox_folder") == "DeltaChat"
while ac1.get_config("configured_trash_folder") != "Trash":
ac1.wait_for_event(EventType.CONNECTIVITY_CHANGED)
@pytest.mark.parametrize(
("folder", "move", "expected_destination"),
[
(
"xyz",
False,
"xyz",
), # Test that emails aren't found in a random folder
(
"xyz",
True,
"xyz",
), # ...emails are found in a random folder and downloaded without moving
(
"Spam",
False,
"INBOX",
), # ...emails are moved from the spam folder to the Inbox
],
)
# Testrun.org does not support the CREATE-SPECIAL-USE capability, which means that we can't create a folder with
# the "\Junk" flag (see https://tools.ietf.org/html/rfc6154). So, we can't test spam folder detection by flag.
def test_scan_folders(acfactory, log, direct_imap, folder, move, expected_destination):
"""Delta Chat periodically scans all folders for new messages to make sure we don't miss any."""
variant = folder + "-" + str(move) + "-" + expected_destination
log.section("Testing variant " + variant)
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("delete_server_after", "0")
if move:
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
ac1.stop_io()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder(folder)
# Wait until each folder was selected once and we are IDLEing:
ac1.start_io()
ac1.bring_online()
ac1.stop_io()
assert folder in ac1_direct_imap.list_folders()
log.section("Send a message from ac2 to ac1 and manually move it to `folder`")
ac1_direct_imap.select_config_folder("inbox")
with ac1_direct_imap.idle() as idle1:
acfactory.get_accepted_chat(ac2, ac1).send_text("hello")
idle1.wait_for_new_message()
ac1_direct_imap.conn.move(["*"], folder) # "*" means "biggest UID in mailbox"
log.section("start_io() and see if DeltaChat finds the message (" + variant + ")")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.start_io()
chat = ac1.create_chat(ac2)
n_msgs = 1 # "Messages are end-to-end encrypted."
if folder == "Spam":
msg = ac1.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
n_msgs += 1
else:
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
assert len(chat.get_messages()) == n_msgs
# The message has reached its destination.
ac1_direct_imap.select_folder(expected_destination)
assert len(ac1_direct_imap.get_all_messages()) == 1
if folder != expected_destination:
ac1_direct_imap.select_folder(folder)
assert len(ac1_direct_imap.get_all_messages()) == 0
def test_trash_multiple_messages(acfactory, direct_imap, log):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.stop_io()
# Create the Trash folder on IMAP server and configure deletion to it. There was a bug that if
# Trash wasn't configured initially, it can't be configured later, let's check this.
log.section("Creating trash folder")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("Trash")
ac2.set_config("delete_server_after", "0")
ac2.set_config("sync_msgs", "0")
ac2.set_config("delete_to_trash", "1")
log.section("Check that Trash can be configured initially as well")
ac3 = ac2.clone()
ac3.bring_online()
assert ac3.get_config("configured_trash_folder")
ac3.stop_io()
ac2.start_io()
chat12 = acfactory.get_accepted_chat(ac1, ac2)
@@ -520,17 +196,15 @@ def test_trash_multiple_messages(acfactory, direct_imap, log):
assert msg.text in texts
if text != "second":
to_delete.append(msg)
# ac2 has received some messages, this is impossible w/o the trash folder configured, let's
# check the configuration.
assert ac2.get_config("configured_trash_folder") == "Trash"
log.section("ac2: deleting all messages except second")
assert len(to_delete) == len(texts) - 1
ac2.delete_messages(to_delete)
log.section("ac2: test that only one message is left")
ac2_direct_imap = direct_imap(ac2)
while 1:
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
ac2.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
ac2_direct_imap.select_config_folder("inbox")
nr_msgs = len(ac2_direct_imap.get_all_messages())
assert nr_msgs > 0

View File

@@ -24,6 +24,13 @@ def path_to_webxdc(request):
return str(p)
@pytest.fixture
def path_to_large_webxdc(request):
p = request.path.parent.parent.parent.joinpath("test-data/webxdc/realtime-check.xdc")
assert p.exists()
return str(p)
def log(msg):
logging.info(msg)
@@ -227,3 +234,29 @@ def test_advertisement_after_chatting(acfactory, path_to_webxdc):
ac2_webxdc_msg.send_webxdc_realtime_advertisement()
event = ac1.wait_for_event(EventType.WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED)
assert event.msg_id == ac1_webxdc_msg.id
def test_realtime_large_webxdc(acfactory, path_to_large_webxdc):
"""Tests initializing realtime channel on a large webxdc.
This is a regression test for a bug that existed in version 2.42.0.
Large webxdc is split into pre- and post- message,
and this previously resulted in failure to initialize realtime.
"""
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("webxdc_realtime_enabled", "1")
ac2.set_config("webxdc_realtime_enabled", "1")
ac2.create_chat(ac1)
ac1_ac2_chat = ac1.create_chat(ac2)
ac1_webxdc_msg = ac1_ac2_chat.send_message(text="realtime check", file=path_to_large_webxdc)
# Receive pre-message.
ac2_webxdc_msg = ac2.wait_for_incoming_msg()
# Receive post-message.
ac2_webxdc_msg = ac2.wait_for_msg(EventType.MSGS_CHANGED)
ac2_webxdc_msg.send_webxdc_realtime_advertisement()
event = ac1.wait_for_event(EventType.WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED)
assert event.msg_id == ac1_webxdc_msg.id

View File

@@ -1,49 +0,0 @@
import pytest
from deltachat_rpc_client import EventType
from deltachat_rpc_client.rpc import JsonRpcError
def wait_for_autocrypt_setup_message(account):
while True:
event = account.wait_for_event()
if event.kind == EventType.MSGS_CHANGED and event.msg_id != 0:
msg_id = event.msg_id
msg = account.get_message_by_id(msg_id)
if msg.get_snapshot().is_setupmessage:
return msg
def test_autocrypt_setup_message_key_transfer(acfactory):
alice1 = acfactory.get_online_account()
alice2 = acfactory.get_unconfigured_account()
alice2.add_or_update_transport({"addr": alice1.get_config("addr"), "password": alice1.get_config("mail_pw")})
alice2.bring_online()
setup_code = alice1.initiate_autocrypt_key_transfer()
msg = wait_for_autocrypt_setup_message(alice2)
# Test that entering wrong code returns an error.
with pytest.raises(JsonRpcError):
msg.continue_autocrypt_key_transfer("7037-0673-6287-3013-4095-7956-5617-6806-6756")
msg.continue_autocrypt_key_transfer(setup_code)
def test_ac_setup_message_twice(acfactory):
alice1 = acfactory.get_online_account()
alice2 = acfactory.get_unconfigured_account()
alice2.add_or_update_transport({"addr": alice1.get_config("addr"), "password": alice1.get_config("mail_pw")})
alice2.bring_online()
# Send the first Autocrypt Setup Message and ignore it.
_setup_code = alice1.initiate_autocrypt_key_transfer()
wait_for_autocrypt_setup_message(alice2)
# Send the second Autocrypt Setup Message and import it.
setup_code = alice1.initiate_autocrypt_key_transfer()
msg = wait_for_autocrypt_setup_message(alice2)
msg.continue_autocrypt_key_transfer(setup_code)

View File

@@ -1,6 +1,7 @@
import pytest
from deltachat_rpc_client import EventType
from deltachat_rpc_client.const import ChatType, DownloadState
from deltachat_rpc_client.rpc import JsonRpcError
@@ -37,8 +38,8 @@ def test_add_second_address(acfactory) -> None:
with pytest.raises(JsonRpcError):
account.set_config(option, "1")
with pytest.raises(JsonRpcError):
account.set_config("show_emails", "0")
# show_emails does not matter for multi-relay, can be set to anything
account.set_config("show_emails", "0")
@pytest.mark.parametrize("key", ["mvbox_move", "only_fetch_mvbox"])
@@ -57,8 +58,8 @@ def test_no_second_transport_with_mvbox(acfactory, key) -> None:
account.add_transport_from_qr(qr)
def test_no_second_transport_without_classic_emails(acfactory) -> None:
"""Test that second transport cannot be configured if classic emails are not fetched."""
def test_second_transport_without_classic_emails(acfactory) -> None:
"""Test that second transport can be configured if classic emails are not fetched."""
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
@@ -67,8 +68,7 @@ def test_no_second_transport_without_classic_emails(acfactory) -> None:
qr = acfactory.get_account_qr()
account.set_config("show_emails", "0")
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
account.add_transport_from_qr(qr)
def test_change_address(acfactory) -> None:
@@ -120,6 +120,33 @@ def test_change_address(acfactory) -> None:
assert sender_addr2 == new_alice_addr
def test_download_on_demand(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
alice.set_config("download_limit", "1")
alice.stop_io()
qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr)
alice.start_io()
alice.create_chat(bob)
chat_bob_alice = bob.create_chat(alice)
chat_bob_alice.send_message(file="../test-data/image/screenshot.jpg")
msg = alice.wait_for_incoming_msg()
snapshot = msg.get_snapshot()
assert snapshot.download_state == DownloadState.AVAILABLE
chat_id = snapshot.chat_id
# Actually the message isn't available yet. Wait somehow for the post-message to arrive.
chat_bob_alice.send_message("Now you can download my previous message")
alice.wait_for_incoming_msg()
alice._rpc.download_full_message(alice.id, msg.id)
for dstate in [DownloadState.IN_PROGRESS, DownloadState.DONE]:
event = alice.wait_for_event(EventType.MSGS_CHANGED)
assert event.chat_id == chat_id
assert event.msg_id == msg.id
assert msg.get_snapshot().download_state == dstate
@pytest.mark.parametrize("is_chatmail", ["0", "1"])
def test_mvbox_move_first_transport(acfactory, is_chatmail) -> None:
"""Test that mvbox_move is disabled by default even for non-chatmail accounts.
@@ -161,6 +188,13 @@ def test_reconfigure_transport(acfactory) -> None:
def test_transport_synchronization(acfactory, log) -> None:
"""Test synchronization of transports between devices."""
def wait_for_io_started(ac):
while True:
ev = ac.wait_for_event(EventType.INFO)
if "scheduler is running" in ev.msg:
return
ac1, ac2 = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone()
ac1_clone.bring_online()
@@ -169,11 +203,13 @@ def test_transport_synchronization(acfactory, log) -> None:
ac1.add_transport_from_qr(qr)
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1_clone)
assert len(ac1.list_transports()) == 2
assert len(ac1_clone.list_transports()) == 2
ac1_clone.add_transport_from_qr(qr)
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1)
assert len(ac1.list_transports()) == 3
assert len(ac1_clone.list_transports()) == 3
@@ -183,11 +219,15 @@ def test_transport_synchronization(acfactory, log) -> None:
ac1_clone.delete_transport(transport2["addr"])
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1)
[transport1, transport3] = ac1.list_transports()
log.section("ac1 changes the primary transport")
ac1.set_config("configured_addr", transport3["addr"])
# One event for updated `add_timestamp` of the new primary transport,
# one event for the `configured_addr` update.
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
[transport1, transport3] = ac1_clone.list_transports()
assert ac1_clone.get_config("configured_addr") == addr3
@@ -196,6 +236,7 @@ def test_transport_synchronization(acfactory, log) -> None:
ac1.delete_transport(transport1["addr"])
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1_clone)
[transport3] = ac1_clone.list_transports()
assert transport3["addr"] == addr3
assert ac1_clone.get_config("configured_addr") == addr3
@@ -209,7 +250,7 @@ def test_transport_synchronization(acfactory, log) -> None:
def test_transport_sync_new_as_primary(acfactory, log) -> None:
"""Test synchronization of new transport as primary between devices."""
ac1 = acfactory.get_online_account()
ac1, bob = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone()
ac1_clone.bring_online()
@@ -229,6 +270,15 @@ def test_transport_sync_new_as_primary(acfactory, log) -> None:
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
assert ac1_clone.get_config("configured_addr") == transport2["addr"]
log.section("ac1_clone receives a message via the new primary transport")
ac1_chat = ac1.create_chat(bob)
ac1_chat.send_text("Hello!")
bob_chat_id = bob.wait_for_incoming_msg_event().chat_id
bob_chat = bob.get_chat_by_id(bob_chat_id)
bob_chat.accept()
bob_chat.send_text("hello back")
assert ac1_clone.wait_for_incoming_msg().get_snapshot().text == "hello back"
def test_recognize_self_address(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
@@ -268,11 +318,10 @@ def test_transport_limit(acfactory) -> None:
account.add_transport_from_qr(qr)
def test_message_info_imap_urls(acfactory, log) -> None:
def test_message_info_imap_urls(acfactory) -> None:
"""Test that message info contains IMAP URLs of where the message was received."""
alice, bob = acfactory.get_online_accounts(2)
log.section("Alice adds ac1 clone removes second transport")
qr = acfactory.get_account_qr()
for i in range(3):
alice.add_transport_from_qr(qr)
@@ -280,9 +329,6 @@ def test_message_info_imap_urls(acfactory, log) -> None:
for _ in range(i + 1):
alice.bring_online()
new_alice_addr = alice.list_transports()[2]["addr"]
alice.set_config("configured_addr", new_alice_addr)
# Enable multi-device mode so messages are not deleted immediately.
alice.set_config("bcc_self", "1")
@@ -290,12 +336,53 @@ def test_message_info_imap_urls(acfactory, log) -> None:
# This is where he will send the message.
bob_chat = bob.create_chat(alice)
# Alice changes the transport again.
alice.set_config("configured_addr", alice.list_transports()[3]["addr"])
# Alice switches to another transport and removes the rest of the transports.
new_alice_addr = alice.list_transports()[1]["addr"]
alice.set_config("configured_addr", new_alice_addr)
removed_addrs = []
for transport in alice.list_transports():
if transport["addr"] != new_alice_addr:
alice.delete_transport(transport["addr"])
removed_addrs.append(transport["addr"])
alice.stop_io()
alice.start_io()
bob_chat.send_text("Hello!")
msg = alice.wait_for_incoming_msg()
for alice_transport in alice.list_transports():
addr = alice_transport["addr"]
assert (addr == new_alice_addr) == (addr in msg.get_info())
msg_info = msg.get_info()
assert new_alice_addr in msg_info
for removed_addr in removed_addrs:
assert removed_addr not in msg_info
assert f"{new_alice_addr}/INBOX" in msg_info
def test_remove_primary_transport(acfactory, log) -> None:
"""Test that after removing the primary relay, Alice can still receive messages."""
alice, bob = acfactory.get_online_accounts(2)
qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr)
alice.bring_online()
bob_chat = bob.create_chat(alice)
alice.create_chat(bob)
log.section("Alice sets up second transport")
[transport1, transport2] = alice.list_transports()
alice.set_config("configured_addr", transport2["addr"])
bob_chat.send_text("Hello!")
msg1 = alice.wait_for_incoming_msg().get_snapshot()
assert msg1.text == "Hello!"
log.section("Alice removes the primary relay")
alice.delete_transport(transport1["addr"])
alice.stop_io()
alice.start_io()
bob_chat.send_text("Hello again!")
msg2 = alice.wait_for_incoming_msg().get_snapshot()
assert msg2.text == "Hello again!"
assert msg2.chat.get_basic_snapshot().chat_type == ChatType.SINGLE
assert msg2.chat == alice.create_chat(bob)

View File

@@ -167,11 +167,16 @@ def test_qr_securejoin_broadcast(acfactory, all_devices_online):
assert "invited you to join this channel" in first_msg.text
assert first_msg.is_info
member_added_msg = chat_msgs.pop(0).get_snapshot()
if inviter_side:
member_added_msg = chat_msgs.pop(0).get_snapshot()
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
assert member_added_msg.info_contact_id == contact_snapshot.id
else:
assert member_added_msg.text == "You joined the channel."
if chat_msgs[0].get_snapshot().text == "You joined the channel.":
member_added_msg = chat_msgs.pop(0).get_snapshot()
else:
member_added_msg = chat_msgs.pop(1).get_snapshot()
assert member_added_msg.text == "You joined the channel."
assert member_added_msg.is_info
hello_msg = chat_msgs.pop(0).get_snapshot()

View File

@@ -5,6 +5,7 @@ import logging
import os
import socket
import subprocess
import time
from unittest.mock import MagicMock
import pytest
@@ -12,7 +13,7 @@ import pytest
from deltachat_rpc_client import EventType, events
from deltachat_rpc_client.const import DownloadState, MessageState
from deltachat_rpc_client.pytestplugin import E2EE_INFO_MSGS
from deltachat_rpc_client.rpc import JsonRpcError
from deltachat_rpc_client.rpc import JsonRpcError, Rpc
def test_system_info(rpc) -> None:
@@ -387,22 +388,29 @@ def test_dont_move_sync_msgs(acfactory, direct_imap):
ac1.set_config("bcc_self", "1")
ac1.set_config("fix_is_chatmail", "1")
ac1.add_or_update_transport({"addr": addr, "password": password})
ac1.bring_online()
ac1.start_io()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.select_folder("Inbox")
# Sync messages may also be sent during configuration.
inbox_msg_cnt = len(ac1_direct_imap.get_all_messages())
ac1.wait_for_event(EventType.MSG_DELIVERED)
ac1_direct_imap.select_folder("Inbox")
while True:
if len(ac1_direct_imap.get_all_messages()) == 1:
break
time.sleep(1)
ac1.set_config("displayname", "Alice")
ac1.wait_for_event(EventType.MSG_DELIVERED)
ac1.set_config("displayname", "Bob")
ac1.wait_for_event(EventType.MSG_DELIVERED)
ac1_direct_imap.select_folder("Inbox")
assert len(ac1_direct_imap.get_all_messages()) == inbox_msg_cnt + 2
ac1_direct_imap.select_folder("DeltaChat")
assert len(ac1_direct_imap.get_all_messages()) == 0
# Message may not be delivered to IMAP immediately
# after sending over SMTP,
# retry until they are delivered to IMAP.
while True:
if len(ac1_direct_imap.get_all_messages()) == 3:
break
time.sleep(1)
def test_reaction_seen_on_another_dev(acfactory) -> None:
@@ -657,6 +665,24 @@ def test_openrpc_command_line() -> None:
assert "methods" in openrpc
def test_early_failure(tmp_path) -> None:
"""Test that Rpc.start() raises on invalid accounts directories."""
# A file instead of a directory.
file_path = tmp_path / "not_a_dir"
file_path.write_text("I am a file, not a directory")
rpc = Rpc(accounts_dir=str(file_path))
with pytest.raises(JsonRpcError, match="(?i)directory"):
rpc.start()
# A non-empty directory that is not a deltachat accounts directory.
non_dc_dir = tmp_path / "invalid_dir"
non_dc_dir.mkdir()
(non_dc_dir / "some_file").write_text("content")
rpc = Rpc(accounts_dir=str(non_dc_dir))
with pytest.raises(JsonRpcError, match="invalid_dir"):
rpc.start()
def test_provider_info(rpc) -> None:
account_id = rpc.add_account()
@@ -911,6 +937,47 @@ def test_markseen_contact_request(acfactory):
assert message2.get_snapshot().state == MessageState.IN_SEEN
@pytest.mark.parametrize("team_profile", [True, False])
def test_no_markseen_in_team_profile(team_profile, acfactory):
"""
Test that seen status is synchronized iff `team_profile` isn't set.
"""
alice, bob = acfactory.get_online_accounts(2)
if team_profile:
bob.set_config("team_profile", "1")
# Bob sets up a second device.
bob2 = bob.clone()
bob2.start_io()
alice_chat_bob = alice.create_chat(bob)
bob_chat_alice = bob.create_chat(alice)
bob2.create_chat(alice)
alice_chat_bob.send_text("Hello Bob!")
message = bob.wait_for_incoming_msg()
message2 = bob2.wait_for_incoming_msg()
assert message2.get_snapshot().state == MessageState.IN_FRESH
message.mark_seen()
# Send a message and wait until it arrives
# in order to wait until Bob2 gets the markseen message.
# This also tests that outgoing messages
# don't mark preceeding messages as seen in team profiles.
bob_chat_alice.send_text("Outgoing message")
while True:
outgoing = bob2.wait_for_msg(EventType.MSGS_CHANGED)
if outgoing.id != 0:
break
assert outgoing.get_snapshot().text == "Outgoing message"
if team_profile:
assert message2.get_snapshot().state == MessageState.IN_FRESH
else:
assert message2.get_snapshot().state == MessageState.IN_SEEN
def test_read_receipt(acfactory):
"""
Test sending a read receipt and ensure it is attributed to the correct contact.
@@ -930,6 +997,9 @@ def test_read_receipt(acfactory):
assert len(read_receipts) == 1
assert read_receipts[0].contact_id == alice_contact_bob.id
read_receipt_cnt = read_msg.get_read_receipt_count()
assert read_receipt_cnt == 1
def test_get_http_response(acfactory):
alice = acfactory.new_configured_account()
@@ -1133,6 +1203,30 @@ def test_leave_broadcast(acfactory, all_devices_online):
check_account(bob2, bob2.create_contact(alice), inviter_side=False)
def test_leave_and_delete_group(acfactory, log):
alice, bob = acfactory.get_online_accounts(2)
log.section("Alice creates a group")
alice_chat = alice.create_group("Group")
alice_chat.add_contact(bob)
assert len(alice_chat.get_contacts()) == 2 # Alice and Bob
alice_chat.send_text("hello")
log.section("Bob sees the group, and leaves and deletes it")
msg = bob.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
msg.chat.accept()
msg.chat.leave()
# Bob deletes the chat. This must not prevent the leave message from being sent.
msg.chat.delete()
log.section("Alice receives the delete message")
# After Bob left, only Alice will be left in the group:
while len(alice_chat.get_contacts()) != 1:
alice.wait_for_event(EventType.CHAT_MODIFIED)
def test_immediate_autodelete(acfactory, direct_imap, log):
ac1, ac2 = acfactory.get_online_accounts(2)

View File

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

View File

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

View File

@@ -17,11 +17,6 @@ ignore = [
# It is a transitive dependency of iroh 0.35.0,
# this should be fixed by upgrading to iroh 1.0 once it is released.
"RUSTSEC-2025-0134",
# Old versions of "lru" are transitive dependencies of iroh 0.35.0.
# <https://rustsec.org/advisories/RUSTSEC-2026-0002>
# <https://github.com/chatmail/core/issues/7692>
"RUSTSEC-2026-0002",
]
[bans]
@@ -32,6 +27,7 @@ ignore = [
skip = [
{ name = "async-channel", version = "1.9.0" },
{ name = "bitflags", version = "1.3.2" },
{ name = "constant_time_eq", version = "0.3.1" },
{ name = "derive_more-impl", version = "1.0.0" },
{ name = "derive_more", version = "1.0.0" },
{ name = "event-listener", version = "2.5.3" },

View File

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

View File

@@ -553,17 +553,6 @@ class Account:
def imex(self, path: str, imex_cmd: int, passphrase: Optional[str] = None) -> None:
lib.dc_imex(self._dc_context, imex_cmd, as_dc_charpointer(path), as_dc_charpointer(passphrase))
def initiate_key_transfer(self) -> str:
"""return setup code after a Autocrypt setup message
has been successfully sent to our own e-mail address ("self-sent message").
If sending out was unsuccessful, a RuntimeError is raised.
"""
self.check_is_configured()
res = lib.dc_initiate_key_transfer(self._dc_context)
if res == ffi.NULL:
raise RuntimeError("could not send out autocrypt setup message")
return from_dc_charpointer(res)
def get_setup_contact_qr(self) -> str:
"""get/create Setup-Contact QR Code as ascii-string.

View File

@@ -167,14 +167,6 @@ class Message:
"""return True if this message is a system/info message."""
return bool(lib.dc_msg_is_info(self._dc_msg))
def is_setup_message(self):
"""return True if this message is a setup message."""
return lib.dc_msg_is_setupmessage(self._dc_msg)
def get_setupcodebegin(self) -> str:
"""return the first characters of a setup code in a setup message."""
return from_dc_charpointer(lib.dc_msg_get_setupcodebegin(self._dc_msg))
def is_encrypted(self):
"""return True if this message was encrypted."""
return bool(lib.dc_msg_get_showpadlock(self._dc_msg))
@@ -198,12 +190,6 @@ class Message:
"""Get a message summary as a single line of text. Typically used for notifications."""
return from_dc_charpointer(lib.dc_msg_get_summarytext(self._dc_msg, width))
def continue_key_transfer(self, setup_code):
"""extract key and use it as primary key for this account."""
res = lib.dc_continue_key_transfer(self.account._dc_context, self.id, as_dc_charpointer(setup_code))
if res == 0:
raise ValueError("Importing the key from Autocrypt Setup Message failed")
@props.with_doc
def time_sent(self):
"""UTC time when the message was sent.

View File

@@ -220,21 +220,6 @@ def test_webxdc_huge_update(acfactory, data, lp):
assert update["payload"] == payload
def test_enable_mvbox_move(acfactory, lp):
(ac1,) = acfactory.get_online_accounts(1)
lp.sec("ac2: start without mvbox thread")
ac2 = acfactory.new_online_configuring_account(mvbox_move=False)
acfactory.bring_accounts_online()
lp.sec("ac2: configuring mvbox")
ac2.set_config("mvbox_move", "1")
lp.sec("ac1: send message and wait for ac2 to receive it")
acfactory.get_accepted_chat(ac1, ac2).send_text("message1")
assert ac2._evtracker.wait_next_incoming_message().text == "message1"
def test_forward_messages(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat = ac1.create_chat(ac2)
@@ -350,7 +335,7 @@ def test_long_group_name(acfactory, lp):
def test_send_self_message(acfactory, lp):
ac1 = acfactory.new_online_configuring_account(mvbox_move=True, bcc_self=True)
ac1 = acfactory.new_online_configuring_account(bcc_self=True)
acfactory.bring_accounts_online()
lp.sec("ac1: create self chat")
chat = ac1.get_self_contact().create_chat()
@@ -509,7 +494,7 @@ def test_reply_privately(acfactory):
def test_mdn_asymmetric(acfactory, lp):
ac1 = acfactory.new_online_configuring_account(mvbox_move=True)
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account()
acfactory.bring_accounts_online()
@@ -538,20 +523,14 @@ def test_mdn_asymmetric(acfactory, lp):
ac2.mark_seen_messages([msg])
lp.sec("ac1: waiting for incoming activity")
# 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 + 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.")
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder INBOX as seen.")
# MDN is received even though MDNs are already disabled
assert msg_out.is_out_mdn_received()
ac1.direct_imap.select_config_folder("mvbox")
assert len(list(ac1.direct_imap.conn.fetch(AND(seen=True)))) == 1
def test_send_receive_encrypt(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
@@ -953,7 +932,6 @@ def test_set_get_group_image(acfactory, data, lp):
def test_connectivity(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1._evtracker.wait_for_connectivity(dc.const.DC_CONNECTIVITY_CONNECTED)

View File

@@ -258,9 +258,6 @@ class TestOfflineChat:
with pytest.raises(ValueError):
ac1.set_stock_translation(dc.const.DC_STR_FILE, "xyz %1$s")
ac1._evtracker.get_matching("DC_EVENT_WARNING")
with pytest.raises(ValueError):
ac1.set_stock_translation(dc.const.DC_STR_CONTACT_NOT_VERIFIED, "xyz %2$s")
ac1._evtracker.get_matching("DC_EVENT_WARNING")
with pytest.raises(ValueError):
ac1.set_stock_translation(500, "xyz %1$s")
ac1._evtracker.get_matching("DC_EVENT_WARNING")
@@ -561,6 +558,12 @@ class TestOfflineChat:
assert messages[0 + E2EE_INFO_MSGS].text == "msg1"
assert os.path.exists(messages[1 + E2EE_INFO_MSGS].filename)
@pytest.mark.skip(
reason="We didn't find a way to correctly reset an account after a failed import attempt "
"while simultaneously making sure "
"that the password of an encrypted account survives a failed import attempt. "
"Since passphrases are not really supported anymore, we decided to just disable the test.",
)
def test_import_encrypted_bak_into_encrypted_acct(self, acfactory, tmp_path):
"""
Test that account passphrase isn't lost if backup failed to be imported.

View File

@@ -111,7 +111,7 @@ def test_dc_close_events(acfactory):
register_global_plugin(ShutdownPlugin())
assert hasattr(ac1, "_dc_context")
ac1.shutdown()
shutdowns.get(timeout=2)
shutdowns.get()
def test_wrong_db(tmp_path):
@@ -221,7 +221,7 @@ def test_logged_ac_process_ffi_failure(acfactory):
# cause any event eg contact added/changed
ac1.create_contact("something@example.org")
res = cap.get(timeout=10)
res = cap.get()
assert "ac_process_ffi_event" in res
assert "ZeroDivisionError" in res
assert "Traceback" in res

View File

@@ -1 +1 @@
2026-01-08
2026-03-19

View File

@@ -7,7 +7,7 @@ set -euo pipefail
#
# Avoid using rustup here as it depends on reading /proc/self/exe and
# has problems running under QEMU.
RUST_VERSION=1.92.0
RUST_VERSION=1.94.0
ARCH="$(uname -m)"
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu

View File

@@ -3,4 +3,4 @@
# Update package cache without changing the lockfile.
cargo update --dry-run
cargo deny --workspace --all-features check -D warnings
cargo deny --workspace --all-features --locked check -D warnings

View File

@@ -100,7 +100,7 @@ def main():
today = datetime.date.today().isoformat()
if "alpha" not in newversion:
if not newversion.endswith("-dev"):
found = False
for line in Path("CHANGELOG.md").open():
if line == f"## [{newversion}] - {today}\n":

View File

@@ -6,11 +6,11 @@ set -euo pipefail
export TZ=UTC
# Provider database revision.
REV=d041136c19a48b493823b46d472f12b9ee94ae80
REV=996c4bc82be5a7404f70b185ff062da33bfa98d9
CORE_ROOT="$PWD"
TMP="$(mktemp -d)"
git clone --filter=blob:none https://github.com/deltachat/provider-db.git "$TMP"
git clone --filter=blob:none https://github.com/chatmail/provider-db.git "$TMP"
cd "$TMP"
git checkout "$REV"
DATE=$(git show -s --format=%cs)

View File

@@ -57,8 +57,8 @@ pub struct Accounts {
impl Accounts {
/// Loads or creates an accounts folder at the given `dir`.
pub async fn new(dir: PathBuf, writable: bool) -> Result<Self> {
if writable && !dir.exists() {
Accounts::create(&dir).await?;
if writable {
Self::ensure_accounts_dir(&dir).await?;
}
let events = Events::new();
Accounts::open(events, dir, writable).await
@@ -67,10 +67,9 @@ impl Accounts {
/// Loads or creates an accounts folder at the given `dir`.
/// Uses an existing events channel.
pub async fn new_with_events(dir: PathBuf, writable: bool, events: Events) -> Result<Self> {
if writable && !dir.exists() {
Accounts::create(&dir).await?;
if writable {
Self::ensure_accounts_dir(&dir).await?;
}
Accounts::open(events, dir, writable).await
}
@@ -82,14 +81,20 @@ impl Accounts {
0
}
/// Creates a new default structure.
async fn create(dir: &Path) -> Result<()> {
fs::create_dir_all(dir)
.await
.context("failed to create folder")?;
Config::new(dir).await?;
/// Ensures the accounts directory and config file exist.
/// Creates them if the directory doesn't exist, or if it exists but is empty.
/// Errors if the directory exists with files but no config.
async fn ensure_accounts_dir(dir: &Path) -> Result<()> {
if !dir.exists() {
fs::create_dir_all(dir)
.await
.context("Failed to create folder")?;
Config::new(dir).await?;
} else if !dir.join(CONFIG_NAME).exists() {
let mut rd = fs::read_dir(dir).await?;
ensure!(rd.next_entry().await?.is_none(), "{dir:?} is not empty");
Config::new(dir).await?;
}
Ok(())
}
@@ -586,6 +591,7 @@ impl Config {
}
#[cfg(not(target_os = "ios"))]
#[expect(clippy::arithmetic_side_effects)]
async fn create_lock_task(dir: PathBuf) -> Result<Option<JoinHandle<anyhow::Result<()>>>> {
let lockfile = dir.join(LOCKFILE_NAME);
let mut lock = fd_lock::RwLock::new(fs::File::create(lockfile).await?);
@@ -680,13 +686,27 @@ impl Config {
file.write_all(toml::to_string_pretty(&self.inner)?.as_bytes())
.await
.context("failed to write a tmp config")?;
file.sync_data()
// We use `sync_all()` and not `sync_data()` here.
// This translates to `fsync()` instead of `fdatasync()`.
// `fdatasync()` may be insufficient for newely created files
// and may not even synchronize the file size on some operating systems,
// resulting in a truncated file.
file.sync_all()
.await
.context("failed to sync a tmp config")?;
drop(file);
fs::rename(&tmp_path, &self.file)
.await
.context("failed to rename config")?;
// Sync the rename().
#[cfg(not(windows))]
{
let parent = self.file.parent().context("No parent directory")?;
let parent_file = fs::File::open(parent).await?;
parent_file.sync_all().await?;
}
Ok(())
}
@@ -752,6 +772,7 @@ impl Config {
}
/// Creates a new account in the account manager directory.
#[expect(clippy::arithmetic_side_effects)]
async fn new_account(&mut self) -> Result<AccountConfig> {
let id = {
let id = self.inner.next_id;
@@ -841,6 +862,7 @@ impl Config {
///
/// Without this workaround removing account may fail on Windows with an error
/// "The process cannot access the file because it is being used by another process. (os error 32)".
#[expect(clippy::arithmetic_side_effects)]
async fn try_many_times<F, Fut, T>(f: F) -> std::result::Result<(), T>
where
F: Fn() -> Fut,
@@ -912,6 +934,26 @@ mod tests {
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_account_new_empty_existing_dir() {
let dir = tempfile::tempdir().unwrap();
let p: PathBuf = dir.path().join("accounts");
// A non-empty directory without accounts.toml should fail.
fs::create_dir_all(&p).await.unwrap();
fs::write(p.join("stray_file.txt"), b"hello").await.unwrap();
assert!(Accounts::new(p.clone(), true).await.is_err());
// Clean up to an empty directory.
fs::remove_file(p.join("stray_file.txt")).await.unwrap();
// An empty directory without accounts.toml should succeed.
let mut accounts = Accounts::new(p.clone(), true).await.unwrap();
assert_eq!(accounts.accounts.len(), 0);
let id = accounts.add_account().await.unwrap();
assert_eq!(id, 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_account_new_open_conflict() {
let dir = tempfile::tempdir().unwrap();

View File

@@ -47,11 +47,11 @@ pub struct Aheader {
pub public_key: SignedPublicKey,
pub prefer_encrypt: EncryptPreference,
// Whether `_verified` attribute is present.
//
// `_verified` attribute is an extension to `Autocrypt-Gossip`
// header that is used to tell that the sender
// marked this key as verified.
/// Whether `_verified` attribute is present.
///
/// `_verified` attribute is an extension to `Autocrypt-Gossip`
/// header that is used to tell that the sender
/// marked this key as verified.
pub verified: bool,
}
@@ -73,6 +73,7 @@ impl fmt::Display for Aheader {
let keydata = self.public_key.to_base64().chars().enumerate().fold(
String::new(),
|mut res, (i, c)| {
#[expect(clippy::arithmetic_side_effects)]
if i % 78 == 78 - "keydata=".len() {
res.push(' ')
}
@@ -107,13 +108,11 @@ impl FromStr for Aheader {
.remove("keydata")
.context("keydata attribute is not found")
.and_then(|raw| {
SignedPublicKey::from_base64(&raw).context("autocrypt key cannot be decoded")
})
.and_then(|key| {
key.verify()
.and(Ok(key))
.context("autocrypt key cannot be verified")
SignedPublicKey::from_base64(&raw).context("Autocrypt key cannot be decoded")
})?;
public_key
.verify_bindings()
.context("Autocrypt key cannot be verified")?;
let prefer_encrypt = attributes
.remove("prefer-encrypt")

View File

@@ -1,6 +1,6 @@
//! # Blob directory management.
use core::cmp::max;
use std::cmp::max;
use std::io::{Cursor, Seek};
use std::iter::FusedIterator;
use std::mem;
@@ -256,7 +256,7 @@ impl<'a> BlobObject<'a> {
/// Recode image to avatar size.
pub async fn recode_to_avatar_size(&mut self, context: &Context) -> Result<()> {
let (img_wh, max_bytes) =
let (max_wh, max_bytes) =
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
.unwrap_or_default()
{
@@ -273,7 +273,7 @@ impl<'a> BlobObject<'a> {
let is_avatar = true;
self.check_or_recode_to_size(
context, None, // The name of an avatar doesn't matter
viewtype, img_wh, max_bytes, is_avatar,
viewtype, max_wh, max_bytes, is_avatar,
)?;
Ok(())
@@ -294,7 +294,7 @@ impl<'a> BlobObject<'a> {
name: Option<String>,
viewtype: &mut Viewtype,
) -> Result<String> {
let (img_wh, max_bytes) =
let (max_wh, max_bytes) =
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
.unwrap_or_default()
{
@@ -305,13 +305,15 @@ impl<'a> BlobObject<'a> {
MediaQuality::Worse => (constants::WORSE_IMAGE_SIZE, constants::WORSE_IMAGE_BYTES),
};
let is_avatar = false;
self.check_or_recode_to_size(context, name, viewtype, img_wh, max_bytes, is_avatar)
self.check_or_recode_to_size(context, name, viewtype, max_wh, max_bytes, is_avatar)
}
/// Checks or recodes the image so that it fits into limits on width/height and byte size.
/// Checks or recodes the image so that it fits into limits on width/height and/or byte size.
///
/// If `!is_avatar`, then if `max_bytes` is exceeded, reduces the image to `img_wh` and proceeds
/// with the result without rechecking.
/// If `!is_avatar`, then if `max_bytes` is exceeded, reduces the image to `max_wh` and proceeds
/// with the result (even if `max_bytes` is still exceeded).
///
/// If `is_avatar`, the resolution will be reduced in a loop until the image fits `max_bytes`.
///
/// This modifies the blob object in-place.
///
@@ -319,12 +321,13 @@ impl<'a> BlobObject<'a> {
/// then the updated user-visible filename will be returned;
/// this may be necessary because the format may be changed to JPG,
/// i.e. "image.png" -> "image.jpg".
#[expect(clippy::arithmetic_side_effects)]
fn check_or_recode_to_size(
&mut self,
context: &Context,
name: Option<String>,
viewtype: &mut Viewtype,
mut img_wh: u32,
max_wh: u32,
max_bytes: usize,
is_avatar: bool,
) -> Result<String> {
@@ -386,7 +389,14 @@ impl<'a> BlobObject<'a> {
_ => img,
};
let exceeds_wh = img.width() > img_wh || img.height() > img_wh;
// max_wh is the maximum image width and height, i.e. the resolution-limit.
// target_wh target-resolution for resizing the image.
let exceeds_wh = img.width() > max_wh || img.height() > max_wh;
let mut target_wh = if exceeds_wh {
max_wh
} else {
max(img.width(), img.height())
};
let exceeds_max_bytes = nr_bytes > max_bytes as u64;
let jpeg_quality = 75;
@@ -425,15 +435,6 @@ impl<'a> BlobObject<'a> {
});
if do_scale {
if !exceeds_wh {
img_wh = max(img.width(), img.height());
// PNGs and WebPs may be huge because of animation, which is lost by the `image`
// crate when recoding, so don't scale them down.
if matches!(fmt, ImageFormat::Jpeg) || !encoded.is_empty() {
img_wh = img_wh * 2 / 3;
}
}
loop {
if mem::take(&mut add_white_bg) {
self::add_white_bg(&mut img);
@@ -448,9 +449,9 @@ impl<'a> BlobObject<'a> {
// usually has less pixels by cropping, UI that needs to wait anyways,
// and also benefits from slightly better (5%) encoding of Triangle-filtered images.
let new_img = if is_avatar {
img.resize(img_wh, img_wh, image::imageops::FilterType::Triangle)
img.resize(target_wh, target_wh, image::imageops::FilterType::Triangle)
} else {
img.thumbnail(img_wh, img_wh)
img.thumbnail(target_wh, target_wh)
};
if encoded_img_exceeds_bytes(
@@ -461,19 +462,19 @@ impl<'a> BlobObject<'a> {
&mut encoded,
)? && is_avatar
{
if img_wh < 20 {
if target_wh < 20 {
return Err(format_err!(
"Failed to scale image to below {max_bytes}B.",
));
}
img_wh = img_wh * 2 / 3;
target_wh = target_wh * 2 / 3;
} else {
info!(
context,
"Final scaled-down image size: {}B ({}px).",
encoded.len(),
img_wh
target_wh
);
break;
}

View File

@@ -798,3 +798,56 @@ async fn test_create_and_deduplicate_from_bytes() -> Result<()> {
Ok(())
}
/// Tests that an image that already fits into the width limit,
/// but not the bytes limit,
/// is compressed without changing the resolution.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_without_downscaling() -> Result<()> {
let t = &TestContext::new().await;
let image = include_bytes!("../../test-data/image/screenshot120x120.jpg");
const { assert!(120 < constants::WORSE_AVATAR_SIZE) };
for is_avatar in [true, false] {
let mut blob =
BlobObject::create_and_deduplicate_from_bytes(t, image, "image.jpg").unwrap();
let image_path = blob.to_abs_path();
check_image_size(&image_path, 120, 120);
assert!(
fs::metadata(&image_path).await.unwrap().len() > constants::WORSE_AVATAR_BYTES as u64
);
// Repeat the check, because a second call to `check_or_recode_to_size()`
// is not supposed to change anything:
let mut imgs = vec![];
for _ in 0..2 {
let mut viewtype = Viewtype::Image;
let new_name = blob.check_or_recode_to_size(
t,
Some("image.jpg".to_string()),
&mut viewtype,
constants::WORSE_AVATAR_SIZE,
constants::WORSE_AVATAR_BYTES,
is_avatar,
)?;
let image_path = blob.to_abs_path();
assert_eq!(new_name, "image.jpg"); // The name shall not have changed
assert_eq!(viewtype, Viewtype::Image); // The viewtype shall not have changed
let img = check_image_size(&image_path, 120, 120); // The resolution shall not have changed
imgs.push(img);
let new_image_bytes = fs::metadata(&image_path).await.unwrap().len();
assert!(
new_image_bytes < constants::WORSE_AVATAR_BYTES as u64,
"The new image size, {new_image_bytes}, should be lower than {}, is_avatar={is_avatar}",
constants::WORSE_AVATAR_BYTES
);
}
assert_eq!(imgs[0], imgs[1]);
}
Ok(())
}

View File

@@ -11,17 +11,16 @@ use crate::context::{Context, WeakContext};
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::log::warn;
use crate::message::{Message, MsgId, Viewtype};
use crate::message::{Message, MsgId, Viewtype, markseen_msgs};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::net::dns::lookup_host_with_cache;
use crate::param::Param;
use crate::stock_str;
use crate::tools::{normalize_text, time};
use anyhow::{Context as _, Result, ensure};
use deltachat_derive::{FromSql, ToSql};
use num_traits::FromPrimitive;
use sdp::SessionDescription;
use serde::Serialize;
use std::io::Cursor;
use std::str::FromStr;
use std::time::Duration;
use tokio::task;
@@ -80,6 +79,7 @@ impl CallInfo {
}
fn remaining_ring_seconds(&self) -> i64 {
#[expect(clippy::arithmetic_side_effects)]
let remaining_seconds = self.msg.timestamp_sent + RINGING_SECONDS - time();
remaining_seconds.clamp(0, RINGING_SECONDS)
}
@@ -104,10 +104,14 @@ impl CallInfo {
};
if self.is_incoming() {
self.update_text(context, &format!("Incoming call\n{duration}"))
let incoming_call_str =
stock_str::incoming_call(context, self.has_video_initially()).await;
self.update_text(context, &format!("{incoming_call_str}\n{duration}"))
.await?;
} else {
self.update_text(context, &format!("Outgoing call\n{duration}"))
let outgoing_call_str =
stock_str::outgoing_call(context, self.has_video_initially()).await;
self.update_text(context, &format!("{outgoing_call_str}\n{duration}"))
.await?;
}
Ok(())
@@ -126,6 +130,14 @@ impl CallInfo {
self.msg.param.exists(CALL_ACCEPTED_TIMESTAMP)
}
/// Returns true if the call is started as a video call.
pub fn has_video_initially(&self) -> bool {
self.msg
.param
.get_bool(Param::WebrtcHasVideoInitially)
.unwrap_or(false)
}
/// Returns true if the call is missed
/// because the caller canceled it
/// explicitly before ringing stopped.
@@ -164,6 +176,7 @@ impl CallInfo {
}
/// Returns call duration in seconds.
#[expect(clippy::arithmetic_side_effects)]
pub fn duration_seconds(&self) -> i64 {
if let (Some(start), Some(end)) = (
self.msg.param.get_i64(CALL_ACCEPTED_TIMESTAMP),
@@ -185,6 +198,7 @@ impl Context {
&self,
chat_id: ChatId,
place_call_info: String,
has_video_initially: bool,
) -> Result<MsgId> {
let chat = Chat::load_from_db(self, chat_id).await?;
ensure!(
@@ -193,12 +207,15 @@ impl Context {
);
ensure!(!chat.is_self_talk(), "Cannot call self");
let outgoing_call_str = stock_str::outgoing_call(self, has_video_initially).await;
let mut call = Message {
viewtype: Viewtype::Call,
text: "Outgoing call".into(),
text: outgoing_call_str,
..Default::default()
};
call.param.set(Param::WebrtcRoom, &place_call_info);
call.param
.set_int(Param::WebrtcHasVideoInitially, has_video_initially.into());
call.id = send_msg(self, chat_id, &mut call).await?;
let wait = RINGING_SECONDS;
@@ -232,6 +249,7 @@ impl Context {
if chat.is_contact_request() {
chat.id.accept(self).await?;
}
markseen_msgs(self, vec![call_id]).await?;
// send an acceptance message around: to the caller as well as to the other devices of the callee
let mut msg = Message {
@@ -248,6 +266,7 @@ impl Context {
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
from_this_device: true,
});
self.emit_msgs_changed(call.msg.chat_id, call_id);
Ok(())
@@ -266,10 +285,13 @@ impl Context {
if !call.is_accepted() {
if call.is_incoming() {
call.mark_as_ended(self).await?;
call.update_text(self, "Declined call").await?;
markseen_msgs(self, vec![call_id]).await?;
let declined_call_str = stock_str::declined_call(self).await;
call.update_text(self, &declined_call_str).await?;
} else {
call.mark_as_canceled(self).await?;
call.update_text(self, "Canceled call").await?;
let canceled_call_str = stock_str::canceled_call(self).await;
call.update_text(self, &canceled_call_str).await?;
}
} else {
call.mark_as_ended(self).await?;
@@ -311,10 +333,12 @@ impl Context {
if !call.is_accepted() && !call.is_ended() {
if call.is_incoming() {
call.mark_as_canceled(&context).await?;
call.update_text(&context, "Missed call").await?;
let missed_call_str = stock_str::missed_call(&context).await;
call.update_text(&context, &missed_call_str).await?;
} else {
call.mark_as_ended(&context).await?;
call.update_text(&context, "Canceled call").await?;
let canceled_call_str = stock_str::canceled_call(&context).await;
call.update_text(&context, &canceled_call_str).await?;
}
context.emit_msgs_changed(call.msg.chat_id, call_id);
context.emit_event(EventType::CallEnded {
@@ -339,18 +363,14 @@ impl Context {
if call.is_incoming() {
if call.is_stale() {
call.update_text(self, "Missed call").await?;
let missed_call_str = stock_str::missed_call(self).await;
call.update_text(self, &missed_call_str).await?;
self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call
} else {
call.update_text(self, "Incoming call").await?;
let incoming_call_str =
stock_str::incoming_call(self, call.has_video_initially()).await;
call.update_text(self, &incoming_call_str).await?;
self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified
let has_video = match sdp_has_video(&call.place_call_info) {
Ok(has_video) => has_video,
Err(err) => {
warn!(self, "Failed to determine if SDP offer has video: {err:#}.");
false
}
};
let can_call_me = match who_can_call_me(self).await? {
WhoCanCallMe::Contacts => ChatIdBlocked::lookup_by_contact(self, from_id)
.await?
@@ -377,7 +397,7 @@ impl Context {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
place_call_info: call.place_call_info.to_string(),
has_video,
has_video: call.has_video_initially(),
});
}
let wait = call.remaining_ring_seconds();
@@ -389,7 +409,9 @@ impl Context {
));
}
} else {
call.update_text(self, "Outgoing call").await?;
let outgoing_call_str =
stock_str::outgoing_call(self, call.has_video_initially()).await;
call.update_text(self, &outgoing_call_str).await?;
self.emit_msgs_changed(call.msg.chat_id, call_id);
}
} else {
@@ -411,6 +433,7 @@ impl Context {
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
from_this_device: false,
});
} else {
let accept_call_info = mime_message
@@ -439,19 +462,23 @@ impl Context {
if call.is_incoming() {
if from_id == ContactId::SELF {
call.mark_as_ended(self).await?;
call.update_text(self, "Declined call").await?;
let declined_call_str = stock_str::declined_call(self).await;
call.update_text(self, &declined_call_str).await?;
} else {
call.mark_as_canceled(self).await?;
call.update_text(self, "Missed call").await?;
let missed_call_str = stock_str::missed_call(self).await;
call.update_text(self, &missed_call_str).await?;
}
} else {
// outgoing
if from_id == ContactId::SELF {
call.mark_as_canceled(self).await?;
call.update_text(self, "Canceled call").await?;
let canceled_call_str = stock_str::canceled_call(self).await;
call.update_text(self, &canceled_call_str).await?;
} else {
call.mark_as_ended(self).await?;
call.update_text(self, "Declined call").await?;
let declined_call_str = stock_str::declined_call(self).await;
call.update_text(self, &declined_call_str).await?;
}
}
} else {
@@ -507,19 +534,6 @@ impl Context {
}
}
/// Returns true if SDP offer has a video.
pub fn sdp_has_video(sdp: &str) -> Result<bool> {
let mut cursor = Cursor::new(sdp);
let session_description =
SessionDescription::unmarshal(&mut cursor).context("Failed to parse SDP")?;
for media_description in &session_description.media_descriptions {
if media_description.media_name.media == "video" {
return Ok(true);
}
}
Ok(false)
}
/// State of the call for display in the message bubble.
#[derive(Debug, PartialEq, Eq)]
pub enum CallState {
@@ -617,33 +631,7 @@ struct IceServer {
pub credential: Option<String>,
}
/// Creates JSON with ICE servers.
async fn create_ice_servers(
context: &Context,
hostname: &str,
port: u16,
username: &str,
password: &str,
) -> Result<String> {
// Do not use cache because there is no TLS.
let load_cache = false;
let urls: Vec<String> = lookup_host_with_cache(context, hostname, port, "", load_cache)
.await?
.into_iter()
.map(|addr| format!("turn:{addr}"))
.collect();
let ice_server = IceServer {
urls,
username: Some(username.to_string()),
credential: Some(password.to_string()),
};
let json = serde_json::to_string(&[ice_server])?;
Ok(json)
}
/// Creates JSON with ICE servers from a line received over IMAP METADATA.
/// Creates ICE servers from a line received over IMAP METADATA.
///
/// IMAP METADATA returns a line such as
/// `example.com:3478:1758650868:8Dqkyyu11MVESBqjbIylmB06rv8=`
@@ -653,20 +641,107 @@ async fn create_ice_servers(
/// while `8Dqkyyu11MVESBqjbIylmB06rv8=`
/// is the password.
pub(crate) async fn create_ice_servers_from_metadata(
context: &Context,
metadata: &str,
) -> Result<(i64, String)> {
) -> Result<(i64, Vec<UnresolvedIceServer>)> {
let (hostname, rest) = metadata.split_once(':').context("Missing hostname")?;
let (port, rest) = rest.split_once(':').context("Missing port")?;
let port = u16::from_str(port).context("Failed to parse the port")?;
let (ts, password) = rest.split_once(':').context("Missing timestamp")?;
let expiration_timestamp = i64::from_str(ts).context("Failed to parse the timestamp")?;
let ice_servers = create_ice_servers(context, hostname, port, ts, password).await?;
let ice_servers = vec![UnresolvedIceServer::Turn {
hostname: hostname.to_string(),
port,
username: ts.to_string(),
credential: password.to_string(),
}];
Ok((expiration_timestamp, ice_servers))
}
/// STUN or TURN server with unresolved DNS name.
#[derive(Debug, Clone)]
pub(crate) enum UnresolvedIceServer {
/// STUN server.
Stun { hostname: String, port: u16 },
/// TURN server with the username and password.
Turn {
hostname: String,
port: u16,
username: String,
credential: String,
},
}
/// Resolves domain names of ICE servers.
///
/// On failure to resolve, logs the error
/// and skips the server, but does not fail.
pub(crate) async fn resolve_ice_servers(
context: &Context,
unresolved_ice_servers: Vec<UnresolvedIceServer>,
) -> Result<String> {
let mut result: Vec<IceServer> = Vec::new();
// Do not use cache because there is no TLS.
let load_cache = false;
for unresolved_ice_server in unresolved_ice_servers {
match unresolved_ice_server {
UnresolvedIceServer::Stun { hostname, port } => {
match lookup_host_with_cache(context, &hostname, port, "", load_cache).await {
Ok(addrs) => {
let urls: Vec<String> = addrs
.into_iter()
.map(|addr| format!("stun:{addr}"))
.collect();
let stun_server = IceServer {
urls,
username: None,
credential: None,
};
result.push(stun_server);
}
Err(err) => {
warn!(
context,
"Failed to resolve STUN {hostname}:{port}: {err:#}."
);
}
}
}
UnresolvedIceServer::Turn {
hostname,
port,
username,
credential,
} => match lookup_host_with_cache(context, &hostname, port, "", load_cache).await {
Ok(addrs) => {
let urls: Vec<String> = addrs
.into_iter()
.map(|addr| format!("turn:{addr}"))
.collect();
let turn_server = IceServer {
urls,
username: Some(username),
credential: Some(credential),
};
result.push(turn_server);
}
Err(err) => {
warn!(
context,
"Failed to resolve TURN {hostname}:{port}: {err:#}."
);
}
},
}
}
let json = serde_json::to_string(&result)?;
Ok(json)
}
/// Creates JSON with ICE servers when no TURN servers are known.
pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<String> {
pub(crate) fn create_fallback_ice_servers() -> Vec<UnresolvedIceServer> {
// Do not use public STUN server from https://stunprotocol.org/.
// It changes the hostname every year
// (e.g. stunserver2025.stunprotocol.org
@@ -674,36 +749,18 @@ pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<Str
// because of bandwidth costs:
// <https://github.com/jselbie/stunserver/issues/50>
let hostname = "nine.testrun.org";
// Do not use cache because there is no TLS.
let load_cache = false;
let urls: Vec<String> = lookup_host_with_cache(context, hostname, STUN_PORT, "", load_cache)
.await?
.into_iter()
.map(|addr| format!("stun:{addr}"))
.collect();
let stun_server = IceServer {
urls,
username: None,
credential: None,
};
let hostname = "turn.delta.chat";
// Do not use cache because there is no TLS.
let load_cache = false;
let urls: Vec<String> = lookup_host_with_cache(context, hostname, STUN_PORT, "", load_cache)
.await?
.into_iter()
.map(|addr| format!("turn:{addr}"))
.collect();
let turn_server = IceServer {
urls,
username: Some("public".to_string()),
credential: Some("o4tR7yG4rG2slhXqRUf9zgmHz".to_string()),
};
let json = serde_json::to_string(&[stun_server, turn_server])?;
Ok(json)
vec![
UnresolvedIceServer::Stun {
hostname: "nine.testrun.org".to_string(),
port: STUN_PORT,
},
UnresolvedIceServer::Turn {
hostname: "turn.delta.chat".to_string(),
port: STUN_PORT,
username: "public".to_string(),
credential: "o4tR7yG4rG2slhXqRUf9zgmHz".to_string(),
},
]
}
/// Returns JSON with ICE servers.
@@ -717,7 +774,8 @@ pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<Str
/// <https://github.com/deltachat/deltachat-desktop/issues/5447>.
pub async fn ice_servers(context: &Context) -> Result<String> {
if let Some(ref metadata) = *context.metadata.read().await {
Ok(metadata.ice_servers.clone())
let ice_servers = resolve_ice_servers(context, metadata.ice_servers.clone()).await?;
Ok(ice_servers)
} else {
Ok("[]".to_string())
}

View File

@@ -2,6 +2,7 @@ use super::*;
use crate::chat::forward_msgs;
use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::message::MessageState;
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager};
@@ -25,13 +26,6 @@ async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()>
const PLACE_INFO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP4 host.anywhere.com\r\ns=-\r\nc=IN IP4 host.anywhere.com\r\nt=0 0\r\nm=audio 62986 RTP/AVP 0 4 18\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=rtpmap:18 G729/8000\r\na=inactive\r\n";
const ACCEPT_INFO: &str = "v=0\r\no=bob 2890844730 2890844731 IN IP4 host.example.com\r\ns=\r\nc=IN IP4 host.example.com\r\nt=0 0\r\nm=audio 54344 RTP/AVP 0 4\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=inactive\r\n";
/// Example from <https://datatracker.ietf.org/doc/rfc9143/>
/// with `s= ` replaced with `s=-`.
///
/// `s=` cannot be empty according to RFC 3264,
/// so it is more clear as `s=-`.
const PLACE_INFO_VIDEO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP6 2001:db8::3\r\ns=-\r\nc=IN IP6 2001:db8::3\r\nt=0 0\r\na=group:BUNDLE foo bar\r\n\r\nm=audio 10000 RTP/AVP 0 8 97\r\nb=AS:200\r\na=mid:foo\r\na=rtcp-mux\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:97 iLBC/8000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n\r\nm=video 10002 RTP/AVP 31 32\r\nb=AS:1000\r\na=mid:bar\r\na=rtcp-mux\r\na=rtpmap:31 H261/90000\r\na=rtpmap:32 MPV/90000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n";
async fn setup_call() -> Result<CallSetup> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
@@ -52,7 +46,7 @@ async fn setup_call() -> Result<CallSetup> {
bob2.create_chat(&alice).await;
let test_msg_id = alice
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string())
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true)
.await?;
let sent1 = alice.pop_sent_msg().await;
assert_eq!(sent1.sender_msg_id, test_msg_id);
@@ -68,7 +62,8 @@ async fn setup_call() -> Result<CallSetup> {
assert!(!info.is_incoming());
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_text(t, m.id, "Outgoing call").await?;
assert_eq!(info.has_video_initially(), true);
assert_text(t, m.id, "Outgoing video call").await?;
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
}
@@ -89,7 +84,8 @@ async fn setup_call() -> Result<CallSetup> {
assert!(info.is_incoming());
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_text(t, m.id, "Incoming call").await?;
assert_eq!(info.has_video_initially(), true);
assert_text(t, m.id, "Incoming video call").await?;
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
}
@@ -120,9 +116,28 @@ async fn accept_call() -> Result<CallSetup> {
// Bob accepts the incoming call
bob.accept_incoming_call(bob_call.id, ACCEPT_INFO.to_string())
.await?;
assert_text(&bob, bob_call.id, "Incoming call").await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Incoming video call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.get_matching(|evt| {
matches!(
evt,
EventType::IncomingCallAccepted {
from_this_device: true,
..
}
)
})
.await;
let sent2 = bob.pop_sent_msg().await;
let info = bob
@@ -134,9 +149,17 @@ async fn accept_call() -> Result<CallSetup> {
assert_eq!(call_state(&bob, bob_call.id).await?, CallState::Active);
bob2.recv_msg_trash(&sent2).await;
assert_text(&bob, bob_call.id, "Incoming call").await?;
assert_text(&bob, bob_call.id, "Incoming video call").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.get_matching(|evt| {
matches!(
evt,
EventType::IncomingCallAccepted {
from_this_device: false,
..
}
)
})
.await;
let info = bob2
.load_call_by_id(bob2_call.id)
@@ -147,7 +170,7 @@ async fn accept_call() -> Result<CallSetup> {
// Alice receives the acceptance message
alice.recv_msg_trash(&sent2).await;
assert_text(&alice, alice_call.id, "Outgoing call").await?;
assert_text(&alice, alice_call.id, "Outgoing video call").await?;
let ev = alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
@@ -169,7 +192,7 @@ async fn accept_call() -> Result<CallSetup> {
assert_eq!(call_state(&alice, alice_call.id).await?, CallState::Active);
alice2.recv_msg_trash(&sent2).await;
assert_text(&alice2, alice2_call.id, "Outgoing call").await?;
assert_text(&alice2, alice2_call.id, "Outgoing video call").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
@@ -205,10 +228,21 @@ async fn test_accept_call_callee_ends() -> Result<()> {
bob2_call,
..
} = accept_call().await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob has accepted the call and also ends it
bob.end_call(bob_call.id).await?;
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -219,7 +253,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
));
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
assert_text(&bob2, bob2_call.id, "Incoming video call\n<1 minute").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -230,7 +264,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
// Alice receives the ending message
alice.recv_msg_trash(&sent3).await;
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
assert_text(&alice, alice_call.id, "Outgoing video call\n<1 minute").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -241,7 +275,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
));
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
assert_text(&alice2, alice2_call.id, "Outgoing video call\n<1 minute").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -271,7 +305,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
// Bob has accepted the call but Alice ends it
alice.end_call(alice_call.id).await?;
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
assert_text(&alice, alice_call.id, "Outgoing video call\n<1 minute").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -283,7 +317,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
));
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
assert_text(&alice2, alice2_call.id, "Outgoing video call\n<1 minute").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -295,7 +329,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
// Bob receives the ending message
bob.recv_msg_trash(&sent3).await;
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -305,7 +339,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
));
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
assert_text(&bob2, bob2_call.id, "Incoming video call\n<1 minute").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -333,8 +367,18 @@ async fn test_callee_rejects_call() -> Result<()> {
} = setup_call().await?;
// Bob has accepted Alice before, but does not want to talk with Alice
bob_call.chat_id.accept(&bob).await?;
bob.end_call(bob_call.id).await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Declined call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -375,6 +419,35 @@ async fn test_callee_rejects_call() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_callee_sees_contact_request_call() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
alice
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true)
.await?;
let sent1 = alice.pop_sent_msg().await;
let bob_call = bob.recv_msg(&sent1).await;
// Bob can't end_call() because the contact request isn't accepted, but he can mark the call as
// seen.
markseen_msgs(bob, vec![bob_call.id]).await?;
assert_eq!(bob_call.id.get_state(bob).await?, MessageState::InSeen);
// Bob sends an MDN only to self so that an unaccepted contact can't know anything.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, ContactId::SELF)
)
.await?,
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_caller_cancels_call() -> Result<()> {
// Alice calls Bob
@@ -425,7 +498,7 @@ async fn test_caller_cancels_call() -> Result<()> {
// Test that message summary says it is a missed call.
let bob_call_msg = Message::load_from_db(&bob, bob_call.id).await?;
let summary = bob_call_msg.get_summary(&bob, None).await?;
assert_eq!(summary.text, "📞 Missed call");
assert_eq!(summary.text, "🎥 Missed call");
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Missed call").await?;
@@ -525,13 +598,6 @@ async fn test_update_call_text() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sdp_has_video() {
assert!(sdp_has_video("foobar").is_err());
assert_eq!(sdp_has_video(PLACE_INFO).unwrap(), false);
assert_eq!(sdp_has_video(PLACE_INFO_VIDEO).unwrap(), true);
}
/// Tests that calls are forwarded as text messages.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_forward_call() -> Result<()> {
@@ -542,7 +608,7 @@ async fn test_forward_call() -> Result<()> {
let alice_bob_chat = alice.create_chat(bob).await;
let alice_msg_id = alice
.place_outgoing_call(alice_bob_chat.id, PLACE_INFO.to_string())
.place_outgoing_call(alice_bob_chat.id, PLACE_INFO.to_string(), true)
.await
.context("Failed to place a call")?;
let alice_call = Message::load_from_db(alice, alice_msg_id).await?;

View File

@@ -1,7 +1,7 @@
//! # Chat module.
use std::cmp;
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fmt;
use std::io::Cursor;
use std::marker::Sync;
@@ -19,6 +19,7 @@ use strum_macros::EnumIter;
use crate::blob::BlobObject;
use crate::chatlist::Chatlist;
use crate::chatlist_events;
use crate::color::str_to_color;
use crate::config::Config;
use crate::constants::{
@@ -41,8 +42,9 @@ use crate::message::{self, Message, MessageState, MsgId, Viewtype};
use crate::mimefactory::{MimeFactory, RenderedEmail};
use crate::mimeparser::SystemMessage;
use crate::param::{Param, Params};
use crate::pgp::addresses_from_public_key;
use crate::receive_imf::ReceivedMsg;
use crate::smtp::send_msg_to_smtp;
use crate::smtp::{self, send_msg_to_smtp};
use crate::stock_str;
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::{
@@ -51,7 +53,6 @@ use crate::tools::{
gm2local_offset, normalize_text, smeared_time, time, truncate_msg_text,
};
use crate::webxdc::StatusUpdateSerial;
use crate::{chatlist_events, imap};
pub(crate) const PARAM_BROADCAST_SECRET: Param = Param::Arg3;
@@ -257,7 +258,11 @@ impl ChatId {
ChatIdBlocked::get_for_contact(context, contact_id, create_blocked)
.await
.map(|chat| chat.id)?;
ContactId::scaleup_origin(context, &[contact_id], Origin::CreateChat).await?;
if create_blocked != Blocked::Yes {
info!(context, "Scale up origin of {contact_id} to CreateChat.");
ContactId::scaleup_origin(context, &[contact_id], Origin::CreateChat)
.await?;
}
chat_id
} else {
warn!(
@@ -471,7 +476,7 @@ impl ChatId {
/// Adds message "Messages are end-to-end encrypted".
pub(crate) async fn add_e2ee_notice(self, context: &Context, timestamp: i64) -> Result<()> {
let text = stock_str::messages_e2e_encrypted(context).await;
let text = stock_str::messages_e2ee_info_msg(context).await;
add_info_msg_with_cmd(
context,
self,
@@ -618,7 +623,6 @@ impl ChatId {
);
let chat = Chat::load_from_db(context, self).await?;
let delete_msgs_target = context.get_delete_msgs_target().await?;
let sync_id = match sync {
Nosync => None,
Sync => chat.get_sync_id(context).await?,
@@ -628,15 +632,11 @@ impl ChatId {
.sql
.transaction(|transaction| {
transaction.execute(
"UPDATE imap SET target=? WHERE rfc724_mid IN (SELECT rfc724_mid FROM msgs WHERE chat_id=? AND rfc724_mid!='')",
(&delete_msgs_target, self,),
"UPDATE imap SET target='' WHERE rfc724_mid IN (SELECT rfc724_mid FROM msgs WHERE chat_id=? AND rfc724_mid!='')",
(self,),
)?;
transaction.execute(
"UPDATE imap SET target=? WHERE rfc724_mid IN (SELECT pre_rfc724_mid FROM msgs WHERE chat_id=? AND pre_rfc724_mid!='')",
(&delete_msgs_target, self,),
)?;
transaction.execute(
"DELETE FROM smtp WHERE msg_id IN (SELECT id FROM msgs WHERE chat_id=?)",
"UPDATE imap SET target='' WHERE rfc724_mid IN (SELECT pre_rfc724_mid FROM msgs WHERE chat_id=? AND pre_rfc724_mid!='')",
(self,),
)?;
transaction.execute(
@@ -946,6 +946,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
/// Jaccard similarity coefficient is used to estimate similarity of chat member sets.
///
/// Chat is considered active if something was posted there within the last 42 days.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_similar_chat_ids(self, context: &Context) -> Result<Vec<(ChatId, f64)>> {
// Count number of common members in this and other chats.
let intersection = context
@@ -1150,13 +1151,14 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
/// prefer plaintext emails.
///
/// To get more verbose summary for a contact, including its key fingerprint, use [`Contact::get_encrinfo`].
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_encryption_info(self, context: &Context) -> Result<String> {
let chat = Chat::load_from_db(context, self).await?;
if !chat.is_encrypted(context).await? {
return Ok(stock_str::encr_none(context).await);
}
let mut ret = stock_str::e2e_available(context).await + "\n";
let mut ret = stock_str::messages_are_e2ee(context).await + "\n";
for &contact_id in get_chat_contacts(context, self)
.await?
@@ -1173,8 +1175,13 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
let fingerprint = contact
.fingerprint()
.context("Contact does not have a fingerprint in encrypted chat")?;
if contact.public_key(context).await?.is_some() {
ret += &format!("\n{addr}\n{fingerprint}\n");
if let Some(public_key) = contact.public_key(context).await? {
if let Some(relay_addrs) = addresses_from_public_key(&public_key) {
let relays = relay_addrs.join(",");
ret += &format!("\n{addr}({relays})\n{fingerprint}\n");
} else {
ret += &format!("\n{addr}\n{fingerprint}\n");
}
} else {
ret += &format!("\n{addr}\n(key missing)\n{fingerprint}\n");
}
@@ -1735,6 +1742,7 @@ impl Chat {
///
/// If `update_msg_id` is set, that record is reused;
/// if `update_msg_id` is None, a new record is created.
#[expect(clippy::arithmetic_side_effects)]
async fn prepare_msg_raw(
&mut self,
context: &Context,
@@ -1768,21 +1776,12 @@ impl Chat {
} else if matches!(self.typ, Chattype::Group | Chattype::OutBroadcast)
&& self.param.get_int(Param::Unpromoted).unwrap_or_default() == 1
{
msg.param.set_int(Param::AttachGroupImage, 1);
msg.param.set_int(Param::AttachChatAvatarAndDescription, 1);
self.param
.remove(Param::Unpromoted)
.set_i64(Param::GroupNameTimestamp, msg.timestamp_sort);
.set_i64(Param::GroupNameTimestamp, msg.timestamp_sort)
.set_i64(Param::GroupDescriptionTimestamp, msg.timestamp_sort);
self.update_param(context).await?;
// TODO: Remove this compat code needed because Core <= v1.143:
// - doesn't accept synchronization of QR code tokens for unpromoted groups, so we also
// send them when the group is promoted.
// - doesn't sync QR code tokens for unpromoted groups and the group might be created
// before an upgrade.
context
.sync_qr_code_tokens(Some(self.grpid.as_str()))
.await
.log_err(context)
.ok();
}
let is_bot = context.get_config_bool(Config::Bot).await?;
@@ -1890,11 +1889,7 @@ impl Chat {
let (msg_text, was_truncated) = truncate_msg_text(context, msg.text.clone()).await?;
let new_mime_headers = if msg.has_html() {
if msg.param.exists(Param::Forwarded) {
msg.get_id().get_html(context).await?
} else {
msg.param.get(Param::SendHtml).map(|s| s.to_string())
}
msg.param.get(Param::SendHtml).map(|s| s.to_string())
} else {
None
};
@@ -2135,10 +2130,11 @@ pub(crate) async fn sync(context: &Context, id: SyncId, action: SyncAction) -> R
}
/// Whether the chat is pinned or archived.
#[derive(Debug, Copy, Eq, PartialEq, Clone, Serialize, Deserialize, EnumIter)]
#[derive(Debug, Copy, Eq, PartialEq, Clone, Serialize, Deserialize, EnumIter, Default)]
#[repr(i8)]
pub enum ChatVisibility {
/// Chat is neither archived nor pinned.
#[default]
Normal = 0,
/// Chat is archived.
@@ -2815,9 +2811,18 @@ async fn render_mime_message_and_pre_message(
///
/// The caller has to interrupt SMTP loop or otherwise process new rows.
pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Vec<i64>> {
if msg.param.get_cmd() == SystemMessage::GroupNameChanged {
let cmd = msg.param.get_cmd();
if cmd == SystemMessage::GroupNameChanged || cmd == SystemMessage::GroupDescriptionChanged {
msg.chat_id
.update_timestamp(context, Param::GroupNameTimestamp, msg.timestamp_sort)
.update_timestamp(
context,
if cmd == SystemMessage::GroupNameChanged {
Param::GroupNameTimestamp
} else {
Param::GroupDescriptionTimestamp
},
msg.timestamp_sort,
)
.await?;
}
@@ -2838,41 +2843,13 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
let from = context.get_primary_self_addr().await?;
let lowercase_from = from.to_lowercase();
// Send BCC to self if it is enabled.
//
// Previous versions of Delta Chat did not send BCC self
// if DeleteServerAfter was set to immediately delete messages
// from the server. This is not the case anymore
// because BCC-self messages are also used to detect
// that message was sent if SMTP server is slow to respond
// and connection is frequently lost
// before receiving status line. NB: This is not a problem for chatmail servers, so `BccSelf`
// disabled by default is fine.
//
// `from` must be the last addr, see `receive_imf_inner()` why.
recipients.retain(|x| x.to_lowercase() != lowercase_from);
if (context.get_config_bool(Config::BccSelf).await?
|| msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage)
&& (context.get_config_delete_server_after().await? != Some(0) || !recipients.is_empty())
// Default Webxdc integrations are hidden messages and must not be sent out:
if (msg.param.get_int(Param::WebxdcIntegration).is_some() && msg.hidden)
// This may happen eg. for groups with only SELF and bcc_self disabled:
|| (!context.get_config_bool(Config::BccSelf).await? && recipients.is_empty())
{
// Avoid sending unencrypted messages to all transports, chatmail relays won't accept
// them. Normally the user should have a non-chatmail primary transport to send unencrypted
// messages.
if needs_encryption {
for addr in context.get_secondary_self_addrs().await? {
recipients.push(addr);
}
}
recipients.push(from);
}
// Default Webxdc integrations are hidden messages and must not be sent out
if msg.param.get_int(Param::WebxdcIntegration).is_some() && msg.hidden {
recipients.clear();
}
if recipients.is_empty() {
// may happen eg. for groups with only SELF and bcc_self disabled
info!(
context,
"Message {} has no recipient, skipping smtp-send.", msg.id
@@ -2911,6 +2888,10 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
);
}
if context.get_config_bool(Config::BccSelf).await? {
smtp::add_self_recipients(context, &mut recipients, rendered_msg.is_encrypted).await?;
}
if needs_encryption && !rendered_msg.is_encrypted {
/* unrecoverable */
message::set_msg_failed(
@@ -3014,6 +2995,7 @@ pub async fn send_text_msg(
}
/// Sends chat members a request to edit the given message's text.
#[expect(clippy::arithmetic_side_effects)]
pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: String) -> Result<()> {
let mut original_msg = Message::load_from_db(context, msg_id).await?;
ensure!(
@@ -3119,6 +3101,7 @@ pub async fn get_chat_msgs(context: &Context, chat_id: ChatId) -> Result<Vec<Cha
}
/// Returns messages belonging to the chat according to the given options.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_chat_msgs_ex(
context: &Context,
chat_id: ChatId,
@@ -3230,6 +3213,36 @@ pub async fn get_chat_msgs_ex(
Ok(items)
}
/// Marks all unread messages in all chats as noticed.
/// Ignores messages from blocked contacts, but does not ignore messages in muted chats.
pub async fn marknoticed_all_chats(context: &Context) -> Result<()> {
// The sql statement here is similar to the one in get_fresh_msgs
let list = context
.sql
.query_map_vec(
"SELECT DISTINCT(c.id)
FROM msgs m
INNER JOIN chats c
ON m.chat_id=c.id
WHERE m.state=?
AND m.hidden=0
AND m.chat_id>9
AND c.blocked=0;",
(MessageState::InFresh,),
|row| {
let msg_id: ChatId = row.get(0)?;
Ok(msg_id)
},
)
.await?;
for chat_id in list {
marknoticed_chat(context, chat_id).await?;
}
Ok(())
}
/// Marks all messages in the chat as noticed.
/// If the given chat-id is the archive-link, marks all messages in all archived chats as noticed.
pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()> {
@@ -3291,7 +3304,7 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()>
let hidden_messages = context
.sql
.query_map_vec(
"SELECT id, rfc724_mid FROM msgs
"SELECT id FROM msgs
WHERE state=?
AND hidden=1
AND chat_id=?
@@ -3299,16 +3312,11 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()>
(MessageState::InFresh, chat_id), // No need to check for InNoticed messages, because reactions are never InNoticed
|row| {
let msg_id: MsgId = row.get(0)?;
let rfc724_mid: String = row.get(1)?;
Ok((msg_id, rfc724_mid))
Ok(msg_id)
},
)
.await?;
for (msg_id, rfc724_mid) in &hidden_messages {
message::update_msg_state(context, *msg_id, MessageState::InSeen).await?;
imap::markseen_on_imap_table(context, rfc724_mid).await?;
}
message::markseen_msgs(context, hidden_messages).await?;
if noticed_msgs_count == 0 {
return Ok(());
}
@@ -3330,6 +3338,10 @@ pub(crate) async fn mark_old_messages_as_noticed(
context: &Context,
mut msgs: Vec<ReceivedMsg>,
) -> Result<()> {
if context.get_config_bool(Config::TeamProfile).await? {
return Ok(());
}
msgs.retain(|m| m.state.is_outgoing());
if msgs.is_empty() {
return Ok(());
@@ -3391,6 +3403,38 @@ pub(crate) async fn mark_old_messages_as_noticed(
Ok(())
}
/// Marks last incoming message in a chat as fresh.
pub async fn markfresh_chat(context: &Context, chat_id: ChatId) -> Result<()> {
let affected_rows = context
.sql
.execute(
"UPDATE msgs
SET state=?1
WHERE id=(SELECT id
FROM msgs
WHERE state IN (?1, ?2, ?3) AND hidden=0 AND chat_id=?4
ORDER BY timestamp DESC, id DESC
LIMIT 1)
AND state!=?1",
(
MessageState::InFresh,
MessageState::InNoticed,
MessageState::InSeen,
chat_id,
),
)
.await?;
if affected_rows == 0 {
return Ok(());
}
context.emit_msgs_changed_without_msg_id(chat_id);
chatlist_events::emit_chatlist_item_changed(context, chat_id);
Ok(())
}
/// Returns all database message IDs of the given types.
///
/// If `chat_id` is None, return messages from any chat.
@@ -3879,18 +3923,14 @@ pub(crate) async fn add_contact_to_chat_ex(
);
return Ok(false);
}
let sync_qr_code_tokens;
if from_handshake && chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 {
let smeared_time = smeared_time(context);
chat.param
.remove(Param::Unpromoted)
.set_i64(Param::GroupNameTimestamp, smeared_time(context));
.set_i64(Param::GroupNameTimestamp, smeared_time)
.set_i64(Param::GroupDescriptionTimestamp, smeared_time);
chat.update_param(context).await?;
sync_qr_code_tokens = true;
} else {
sync_qr_code_tokens = false;
}
if context.is_self_addr(contact.get_addr()).await? {
// ourself is added using ContactId::SELF, do not add this address explicitly.
// if SELF is not in the group, members cannot be added at all.
@@ -3939,20 +3979,6 @@ pub(crate) async fn add_contact_to_chat_ex(
send_msg(context, chat_id, &mut msg).await?;
sync = Nosync;
// TODO: Remove this compat code needed because Core <= v1.143:
// - doesn't accept synchronization of QR code tokens for unpromoted groups, so we also send
// them when the group is promoted.
// - doesn't sync QR code tokens for unpromoted groups and the group might be created before
// an upgrade.
if sync_qr_code_tokens
&& context
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
.await
.log_err(context)
.is_ok()
{
context.scheduler.interrupt_smtp().await;
}
}
context.emit_event(EventType::ChatModified(chat_id));
if sync.into() {
@@ -3966,6 +3992,7 @@ pub(crate) async fn add_contact_to_chat_ex(
/// This function does not check if the avatar is set.
/// If avatar is not set and this function returns `true`,
/// a `Chat-User-Avatar: 0` header should be sent to reset the avatar.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn shall_attach_selfavatar(context: &Context, chat_id: ChatId) -> Result<bool> {
let timestamp_some_days_ago = time() - DC_RESEND_USER_AVATAR_DAYS * 24 * 60 * 60;
let needs_attach = context
@@ -4112,7 +4139,7 @@ pub async fn remove_contact_from_chat(
} else {
let mut sync = Nosync;
if chat.is_promoted() {
if chat.is_promoted() && chat.typ != Chattype::OutBroadcast {
remove_from_chat_contacts_table(context, chat_id, contact_id).await?;
} else {
remove_from_chat_contacts_table_without_trace(context, chat_id, contact_id).await?;
@@ -4187,7 +4214,106 @@ async fn send_member_removal_msg(
send_msg(context, chat.id, &mut msg).await
}
/// Sets group or mailing list chat name.
/// Set group or broadcast channel description.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
///
/// See also [`get_chat_description`]
pub async fn set_chat_description(
context: &Context,
chat_id: ChatId,
new_description: &str,
) -> Result<()> {
set_chat_description_ex(context, Sync, chat_id, new_description).await
}
async fn set_chat_description_ex(
context: &Context,
mut sync: sync::Sync,
chat_id: ChatId,
new_description: &str,
) -> Result<()> {
let new_description = sanitize_bidi_characters(new_description.trim());
ensure!(!chat_id.is_special(), "Invalid chat ID");
let chat = Chat::load_from_db(context, chat_id).await?;
ensure!(
chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast,
"Can only set description for groups / broadcasts"
);
ensure!(
!chat.grpid.is_empty(),
"Cannot set description for ad hoc groups"
);
if !chat.is_self_in_chat(context).await? {
context.emit_event(EventType::ErrorSelfNotInGroup(
"Cannot set chat description; self not in group".into(),
));
bail!("Cannot set chat description; self not in group");
}
let old_description = get_chat_description(context, chat_id).await?;
if old_description == new_description {
return Ok(());
}
context
.sql
.execute(
"INSERT OR REPLACE INTO chats_descriptions(chat_id, description) VALUES(?, ?)",
(chat_id, &new_description),
)
.await?;
if chat.is_promoted() {
let mut msg = Message::new(Viewtype::Text);
msg.text = stock_str::msg_chat_description_changed(context, ContactId::SELF).await;
msg.param.set_cmd(SystemMessage::GroupDescriptionChanged);
msg.id = send_msg(context, chat_id, &mut msg).await?;
context.emit_msgs_changed(chat_id, msg.id);
sync = Nosync;
}
context.emit_event(EventType::ChatModified(chat_id));
if sync.into() {
chat.sync(context, SyncAction::SetDescription(new_description))
.await
.log_err(context)
.ok();
}
Ok(())
}
/// Load the chat description from the database.
///
/// UIs show this in the profile page of the chat,
/// it is settable by [`set_chat_description`]
pub async fn get_chat_description(context: &Context, chat_id: ChatId) -> Result<String> {
let description = context
.sql
.query_get_value(
"SELECT description FROM chats_descriptions WHERE chat_id=?",
(chat_id,),
)
.await?
.unwrap_or_default();
Ok(description)
}
/// Sets group, mailing list, or broadcast channel chat name.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
pub async fn set_chat_name(context: &Context, chat_id: ChatId, new_name: &str) -> Result<()> {
rename_ex(context, Sync, chat_id, new_name).await
}
@@ -4231,8 +4357,11 @@ async fn rename_ex(
&& sanitize_single_line(&chat.name) != new_name
{
msg.viewtype = Viewtype::Text;
msg.text =
stock_str::msg_grp_name(context, &chat.name, &new_name, ContactId::SELF).await;
msg.text = if chat.typ == Chattype::OutBroadcast {
stock_str::msg_broadcast_name_changed(context, &chat.name, &new_name).await
} else {
stock_str::msg_grp_name(context, &chat.name, &new_name, ContactId::SELF).await
};
msg.param.set_cmd(SystemMessage::GroupNameChanged);
if !chat.name.is_empty() {
msg.param.set(Param::Arg, &chat.name);
@@ -4293,7 +4422,11 @@ pub async fn set_chat_profile_image(
if new_image.is_empty() {
chat.param.remove(Param::ProfileImage);
msg.param.remove(Param::Arg);
msg.text = stock_str::msg_grp_img_deleted(context, ContactId::SELF).await;
msg.text = if chat.typ == Chattype::OutBroadcast {
stock_str::msg_broadcast_img_changed(context).await
} else {
stock_str::msg_grp_img_deleted(context, ContactId::SELF).await
};
} else {
let mut image_blob = BlobObject::create_and_deduplicate(
context,
@@ -4303,7 +4436,11 @@ pub async fn set_chat_profile_image(
image_blob.recode_to_avatar_size(context).await?;
chat.param.set(Param::ProfileImage, image_blob.as_name());
msg.param.set(Param::Arg, image_blob.as_name());
msg.text = stock_str::msg_grp_img_changed(context, ContactId::SELF).await;
msg.text = if chat.typ == Chattype::OutBroadcast {
stock_str::msg_broadcast_img_changed(context).await
} else {
stock_str::msg_grp_img_changed(context, ContactId::SELF).await
};
}
chat.update_param(context).await?;
if chat.is_promoted() {
@@ -4321,6 +4458,7 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
}
/// Forwards multiple messages to a chat in another context.
#[expect(clippy::arithmetic_side_effects)]
pub async fn forward_msgs_2ctx(
ctx_src: &Context,
msg_ids: &[MsgId],
@@ -4362,20 +4500,30 @@ pub async fn forward_msgs_2ctx(
msg.param = Params::new();
if msg.get_viewtype() != Viewtype::Sticker {
let forwarded_msg_id = match ctx_src.blobdir == ctx_dst.blobdir {
true => src_msg_id,
false => MsgId::new_unset(),
};
msg.param
.set_int(Param::Forwarded, src_msg_id.to_u32() as i32);
.set_int(Param::Forwarded, forwarded_msg_id.to_u32() as i32);
}
if msg.get_viewtype() == Viewtype::Call {
msg.viewtype = Viewtype::Text;
}
if msg.download_state != DownloadState::Done {
msg.text += &msg.additional_text;
}
msg.text += &msg.additional_text;
let param = &mut param;
msg.param.steal(param, Param::File);
// When forwarding between different accounts, blob files must be physically copied
// because each account has its own blob directory.
if ctx_src.blobdir == ctx_dst.blobdir {
msg.param.steal(param, Param::File);
} else if let Some(src_path) = param.get_file_path(ctx_src)? {
let new_blob = BlobObject::create_and_deduplicate(ctx_dst, &src_path, &src_path)
.context("Failed to copy blob file to destination account")?;
msg.param.set(Param::File, new_blob.as_name());
}
msg.param.steal(param, Param::Filename);
msg.param.steal(param, Param::Width);
msg.param.steal(param, Param::Height);
@@ -4384,6 +4532,9 @@ pub async fn forward_msgs_2ctx(
msg.param.steal(param, Param::ProtectQuote);
msg.param.steal(param, Param::Quote);
msg.param.steal(param, Param::Summary1);
if msg.has_html() {
msg.set_html(src_msg_id.get_html(ctx_src).await?);
}
msg.in_reply_to = None;
// do not leak data as group names; a default subject is generated by mimefactory
@@ -4438,6 +4589,7 @@ pub async fn save_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
/// the copy contains a reference to the original message
/// as well as to the original chat in case the original message gets deleted.
/// Returns data needed to add a `SaveMessage` sync item.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn save_copy_in_self_talk(
context: &Context,
src_msg_id: MsgId,
@@ -4453,9 +4605,7 @@ pub(crate) async fn save_copy_in_self_talk(
msg.param.remove(Param::PostMessageFileBytes);
msg.param.remove(Param::PostMessageViewtype);
if msg.download_state != DownloadState::Done {
msg.text += &msg.additional_text;
}
msg.text += &msg.additional_text;
if !msg.original_msg_id.is_unset() {
bail!("message already saved.");
@@ -4532,7 +4682,6 @@ pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
}
msg_state => bail!("Unexpected message state {msg_state}"),
}
msg.timestamp_sort = create_smeared_timestamp(context);
if create_send_msg_jobs(context, &mut msg).await?.is_empty() {
continue;
}
@@ -4618,6 +4767,7 @@ pub(crate) async fn get_chat_id_by_grpid(
///
/// Optional `label` can be provided to ensure that message is added only once.
/// If `important` is true, a notification will be sent.
#[expect(clippy::arithmetic_side_effects)]
pub async fn add_device_msg_with_importance(
context: &Context,
label: Option<&str>,
@@ -4927,18 +5077,18 @@ async fn set_contacts_by_fingerprints(
matches!(chat.typ, Chattype::Group | Chattype::OutBroadcast),
"{id} is not a group or broadcast",
);
let mut contacts = HashSet::new();
let mut contacts = BTreeSet::new();
for (fingerprint, addr) in fingerprint_addrs {
let contact = Contact::add_or_lookup_ex(context, "", addr, fingerprint, Origin::Hidden)
.await?
.0;
contacts.insert(contact);
}
let contacts_old = HashSet::<ContactId>::from_iter(get_chat_contacts(context, id).await?);
let contacts_old = BTreeSet::<ContactId>::from_iter(get_chat_contacts(context, id).await?);
if contacts == contacts_old {
return Ok(());
}
context
let broadcast_contacts_added = context
.sql
.transaction(move |transaction| {
// For broadcast channels, we only add members,
@@ -4955,12 +5105,31 @@ async fn set_contacts_by_fingerprints(
let mut statement = transaction.prepare(
"INSERT OR IGNORE INTO chats_contacts (chat_id, contact_id) VALUES (?, ?)",
)?;
let mut broadcast_contacts_added = Vec::new();
for contact_id in &contacts {
statement.execute((id, contact_id))?;
if statement.execute((id, contact_id))? > 0 && chat.typ == Chattype::OutBroadcast {
broadcast_contacts_added.push(*contact_id);
}
}
Ok(())
Ok(broadcast_contacts_added)
})
.await?;
let timestamp = smeared_time(context);
for added_id in broadcast_contacts_added {
let msg = stock_str::msg_add_member_local(context, added_id, ContactId::UNDEFINED).await;
add_info_msg_with_cmd(
context,
id,
&msg,
SystemMessage::MemberAddedToGroup,
Some(timestamp),
timestamp,
None,
Some(ContactId::SELF),
Some(added_id),
)
.await?;
}
context.emit_event(EventType::ChatModified(id));
Ok(())
}
@@ -5004,6 +5173,7 @@ pub(crate) enum SyncAction {
///
/// The list is a list of pairs of fingerprint and address.
SetPgpContacts(Vec<(String, String)>),
SetDescription(String),
Delete,
}
@@ -5102,6 +5272,9 @@ impl Context {
Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request."))
}
SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await,
SyncAction::SetDescription(to) => {
set_chat_description_ex(self, Nosync, chat_id, to).await
}
SyncAction::SetContacts(addrs) => set_contacts_by_addrs(self, chat_id, addrs).await,
SyncAction::SetPgpContacts(fingerprint_addrs) => {
set_contacts_by_fingerprints(self, chat_id, fingerprint_addrs).await

View File

@@ -1,6 +1,7 @@
use std::sync::Arc;
use super::*;
use crate::Event;
use crate::chatlist::get_archived_cnt;
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS};
use crate::ephemeral::Timer;
@@ -865,7 +866,6 @@ async fn test_add_device_msg_unlabelled() {
assert_eq!(msg1.from_id, ContactId::DEVICE);
assert_eq!(msg1.to_id, ContactId::SELF);
assert!(!msg1.is_info());
assert!(!msg1.is_setupmessage());
let msg2 = message::Message::load_from_db(&t, msg2_id.unwrap()).await;
assert!(msg2.is_ok());
@@ -898,7 +898,6 @@ async fn test_add_device_msg_labelled() -> Result<()> {
assert_eq!(msg1.from_id, ContactId::DEVICE);
assert_eq!(msg1.to_id, ContactId::SELF);
assert!(!msg1.is_info());
assert!(!msg1.is_setupmessage());
// check device chat
let chat_id = msg1.chat_id;
@@ -1240,6 +1239,144 @@ async fn test_unarchive_if_muted() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_marknoticed_all_chats() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
tcm.section("alice: create chats & promote them by sending a message");
let alice_chat_normal = alice
.create_group_with_members("Chat (normal)", &[alice, bob])
.await;
send_text_msg(alice, alice_chat_normal, "Hi".to_string()).await?;
let alice_chat_muted = alice
.create_group_with_members("Chat (muted)", &[alice, bob])
.await;
send_text_msg(alice, alice_chat_muted, "Hi".to_string()).await?;
set_muted(&alice.ctx, alice_chat_muted, MuteDuration::Forever).await?;
let alice_chat_archived_and_muted = alice
.create_group_with_members("Chat (archived and muted)", &[alice, bob])
.await;
send_text_msg(alice, alice_chat_archived_and_muted, "Hi".to_string()).await?;
set_muted(
&alice.ctx,
alice_chat_archived_and_muted,
MuteDuration::Forever,
)
.await?;
alice_chat_archived_and_muted
.set_visibility(&alice.ctx, ChatVisibility::Archived)
.await?;
tcm.section("bob: receive messages, accept all chats and send a reply to each messsage");
while let Some(sent_msg) = alice.pop_sent_msg_opt(Duration::default()).await {
let bob_message = bob.recv_msg(&sent_msg).await;
let bob_chat_id = bob_message.chat_id;
bob_chat_id.accept(bob).await?;
send_text_msg(bob, bob_chat_id, "reply".to_string()).await?;
}
tcm.section("alice: receive replies from bob");
while let Some(sent_msg) = bob.pop_sent_msg_opt(Duration::default()).await {
alice.recv_msg(&sent_msg).await;
}
// ensure chats have unread messages
assert_eq!(alice_chat_normal.get_fresh_msg_cnt(alice).await?, 1);
assert_eq!(alice_chat_muted.get_fresh_msg_cnt(alice).await?, 1);
assert_eq!(
alice_chat_archived_and_muted
.get_fresh_msg_cnt(alice)
.await?,
1
);
tcm.section("alice: mark as read");
alice.evtracker.clear_events();
marknoticed_all_chats(alice).await?;
tcm.section("alice: check that chats are no longer unread and that chatlist update events were received");
assert_eq!(alice_chat_normal.get_fresh_msg_cnt(alice).await?, 0);
assert_eq!(alice_chat_muted.get_fresh_msg_cnt(alice).await?, 0);
assert_eq!(
alice_chat_archived_and_muted
.get_fresh_msg_cnt(alice)
.await?,
0
);
let emitted_events = alice.evtracker.take_events();
for event in &[
EventType::ChatlistItemChanged {
chat_id: Some(alice_chat_normal),
},
EventType::ChatlistItemChanged {
chat_id: Some(alice_chat_muted),
},
EventType::ChatlistItemChanged {
chat_id: Some(alice_chat_archived_and_muted),
},
EventType::ChatlistItemChanged {
chat_id: Some(DC_CHAT_ID_ARCHIVED_LINK),
},
] {
assert!(emitted_events.iter().any(|Event { typ, .. }| typ == event));
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markfresh_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// alice sends a message to Bob
let alice_chat = alice.create_chat(bob).await;
let sent_msg1 = alice.send_text(alice_chat.id, "hi bob!").await;
// bob received the message, fresh count is 1
let bob_msg1 = bob.recv_msg(&sent_msg1).await;
let bob_chat_id = bob_msg1.chat_id;
bob_chat_id.accept(bob).await?;
assert_eq!(bob_msg1.state, MessageState::InFresh);
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 1);
assert_eq!(bob.get_fresh_msgs().await?.len(), 1);
// alice sends another message to bob, fresh count is 2
let sent_msg2 = alice.send_text(alice_chat.id, "howdy?").await;
let bob_msg2 = bob.recv_msg(&sent_msg2).await;
let bob_msg1 = Message::load_from_db(bob, bob_msg1.id).await?;
assert_eq!(bob_msg1.state, MessageState::InFresh);
assert_eq!(bob_msg2.state, MessageState::InFresh);
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 2);
assert_eq!(bob.get_fresh_msgs().await?.len(), 2);
// bob marks the chat as noticed, messages are no longer fresh, fresh count is 0
marknoticed_chat(bob, bob_chat_id).await?;
let bob_msg1 = Message::load_from_db(bob, bob_msg1.id).await?;
let bob_msg2 = Message::load_from_db(bob, bob_msg2.id).await?;
assert_ne!(bob_msg1.state, MessageState::InFresh);
assert_ne!(bob_msg2.state, MessageState::InFresh);
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 0);
assert_eq!(bob.get_fresh_msgs().await?.len(), 0);
// bob marks the chat as fresh again, fresh count is 1 again
markfresh_chat(bob, bob_chat_id).await?;
let bob_msg1 = Message::load_from_db(bob, bob_msg1.id).await?;
let bob_msg2 = Message::load_from_db(bob, bob_msg2.id).await?;
assert_ne!(bob_msg1.state, MessageState::InFresh);
assert_eq!(bob_msg2.state, MessageState::InFresh);
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 1);
assert_eq!(bob.get_fresh_msgs().await?.len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_archive_fresh_msgs() -> Result<()> {
let t = TestContext::new_alice().await;
@@ -2550,7 +2687,7 @@ async fn test_resend_own_message() -> Result<()> {
);
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);
assert!(sent1_ts_sent == msg.timestamp_sent);
Ok(())
}
@@ -2640,27 +2777,24 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
join_securejoin(charlie, &qr).await.unwrap();
let request = charlie.pop_sent_msg().await;
assert_eq!(request.recipients, "alice@example.org charlie@example.net");
assert_eq!(request.recipients, "alice@example.org");
alice.recv_msg_trash(&request).await;
}
tcm.section("Alice sends auth-required");
tcm.section("Alice sends vc-pubkey");
{
let auth_required = alice.pop_sent_msg().await;
assert_eq!(
auth_required.recipients,
"charlie@example.net alice@example.org"
);
let parsed = charlie.parse_msg(&auth_required).await;
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_some());
assert!(parsed.decoded_data_contains("charlie@example.net"));
let vc_pubkey = alice.pop_sent_msg().await;
assert_eq!(vc_pubkey.recipients, "charlie@example.net");
let parsed = charlie.parse_msg(&vc_pubkey).await;
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_none());
assert_eq!(parsed.decoded_data_contains("charlie@example.net"), false);
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&auth_required).await;
let parsed_by_bob = bob.parse_msg(&vc_pubkey).await;
assert!(parsed_by_bob.decrypting_failed);
charlie.recv_msg_trash(&auth_required).await;
charlie.recv_msg_trash(&vc_pubkey).await;
}
tcm.section("Charlie sends request-with-auth");
@@ -2901,27 +3035,49 @@ async fn test_broadcast_recipients_sync1() -> Result<()> {
alice1.recv_msg_trash(&request).await;
alice2.recv_msg_trash(&request).await;
let auth_required = alice1.pop_sent_msg().await;
charlie.recv_msg_trash(&auth_required).await;
alice2.recv_msg_trash(&auth_required).await;
let vc_pubkey = alice1.pop_sent_msg().await;
charlie.recv_msg_trash(&vc_pubkey).await;
let request_with_auth = charlie.pop_sent_msg().await;
alice1.recv_msg_trash(&request_with_auth).await;
alice2.recv_msg_trash(&request_with_auth).await;
let member_added = alice1.pop_sent_msg().await;
let a2_member_added = alice2.recv_msg(&member_added).await;
let a2_charlie_added = alice2.recv_msg(&member_added).await;
let _c_member_added = charlie.recv_msg(&member_added).await;
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
assert_eq!(a2_chatlist.get_msg_id(0)?.unwrap(), a2_charlie_added.id);
// Alice1 will now sync the full member list to Alice2:
sync(alice1, alice2).await;
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
assert_eq!(a2_chatlist.get_msg_id(0)?.unwrap(), a2_member_added.id);
let a2_bob_contact = alice2.add_or_lookup_contact_id(bob).await;
let a2_charlie_contact = alice2.add_or_lookup_contact_id(charlie).await;
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
let msg_id = a2_chatlist.get_msg_id(0)?.unwrap();
let a2_bob_added = Message::load_from_db(alice2, msg_id).await?;
assert_ne!(a2_bob_added.id, a2_charlie_added.id);
assert_eq!(
a2_bob_added.text,
stock_str::msg_add_member_local(alice2, a2_bob_contact, ContactId::UNDEFINED).await
);
assert_eq!(a2_bob_added.from_id, ContactId::SELF);
assert_eq!(
a2_bob_added.param.get_cmd(),
SystemMessage::MemberAddedToGroup
);
assert_eq!(
ContactId::new(
a2_bob_added
.param
.get_int(Param::ContactAddedRemoved)
.unwrap()
.try_into()
.unwrap()
),
a2_bob_contact
);
let a2_chat_members = get_chat_contacts(alice2, a2_member_added.chat_id).await?;
let a2_chat_members = get_chat_contacts(alice2, a2_charlie_added.chat_id).await?;
assert!(a2_chat_members.contains(&a2_bob_contact));
assert!(a2_chat_members.contains(&a2_charlie_contact));
assert_eq!(a2_chat_members.len(), 2);
@@ -3027,7 +3183,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupNameChanged);
assert_eq!(
rcvd.text,
r#"Group name changed from "My Channel" to "New Channel name" by Alice."#
r#"Channel name changed from "My Channel" to "New Channel name"."#
);
let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?;
assert_eq!(bob_chat.name, "New Channel name");
@@ -3044,7 +3200,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
let rcvd = bob.recv_msg(&sent).await;
assert!(rcvd.get_override_sender_name().is_none());
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupImageChanged);
assert_eq!(rcvd.text, "Group image changed by Alice.");
assert_eq!(rcvd.text, "Channel image changed.");
assert_eq!(rcvd.chat_id, bob_chat.id);
let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?;
@@ -3065,6 +3221,201 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_description_basic() {
test_chat_description("", false, Chattype::Group)
.await
.unwrap();
// Don't test with broadcast channels,
// because broadcast channels can only be joined via a QR code
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_description_unpromoted_description() {
test_chat_description(
"Unpromoted description in the beginning",
false,
Chattype::Group,
)
.await
.unwrap();
// Don't test with broadcast channels,
// because broadcast channels can only be joined via a QR code
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_description_qr() {
test_chat_description("", true, Chattype::Group)
.await
.unwrap();
test_chat_description("", true, Chattype::OutBroadcast)
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_description_unpromoted_description_qr() {
test_chat_description(
"Unpromoted description in the beginning",
true,
Chattype::Group,
)
.await
.unwrap();
test_chat_description(
"Unpromoted description in the beginning",
true,
Chattype::OutBroadcast,
)
.await
.unwrap();
}
async fn test_chat_description(
initial_description: &str,
join_via_qr: bool,
chattype: Chattype,
) -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await;
let bob = &tcm.bob().await;
alice.set_config_bool(Config::SyncMsgs, true).await?;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
tcm.section("Create a group chat, and add Bob");
let alice_chat_id = if chattype == Chattype::Group {
create_group(alice, "My Group").await?
} else {
create_broadcast(alice, "My Channel".to_string()).await?
};
sync(alice, alice2).await;
if !initial_description.is_empty() {
set_chat_description(alice, alice_chat_id, initial_description).await?;
if chattype == Chattype::OutBroadcast {
// Broadcast channels are always promoted, so, a message is sent:
let sent = alice.pop_sent_msg().await;
assert_eq!(
sent.load_from_db().await.text,
"You changed the chat description."
);
let rcvd = alice2.recv_msg(&sent).await;
assert_eq!(rcvd.text, "You changed the chat description.");
} else {
sync(alice, alice2).await;
}
}
let alice2_chat_id = get_chat_id_by_grpid(
alice2,
&Chat::load_from_db(alice, alice_chat_id).await?.grpid,
)
.await?
.unwrap()
.0;
assert_eq!(
get_chat_description(alice2, alice2_chat_id).await?,
initial_description
);
let bob_chat_id = if join_via_qr {
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await
} else {
let alice_bob_id = alice.add_or_lookup_contact_id(bob).await;
add_contact_to_chat(alice, alice_chat_id, alice_bob_id).await?;
let sent = alice.send_text(alice_chat_id, "promoting the group").await;
bob.recv_msg(&sent).await.chat_id
};
assert_eq!(
get_chat_description(bob, bob_chat_id).await?,
initial_description
);
for description in ["This is a cool chat", "", "ä ẟ 😂"] {
tcm.section(&format!(
"Alice sets the chat description to '{description}'"
));
set_chat_description(alice, alice_chat_id, description).await?;
let sent = alice.pop_sent_msg().await;
assert_eq!(
sent.load_from_db().await.text,
"You changed the chat description."
);
tcm.section("Bob receives the description change");
let parsed = MimeMessage::from_bytes(bob, sent.payload().as_bytes()).await?;
assert_eq!(
parsed.parts[0].msg,
"[Chat description changed. To see this and other new features, please update the app]"
);
let rcvd = bob.recv_msg(&sent).await;
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupDescriptionChanged);
assert_eq!(rcvd.text, "Chat description changed by alice@example.org.");
assert_eq!(get_chat_description(bob, rcvd.chat_id).await?, description);
tcm.section("Check Alice's second device");
alice2.recv_msg(&sent).await;
let alice2_chat_id = get_chat_id_by_grpid(
alice2,
&Chat::load_from_db(alice, alice_chat_id).await?.grpid,
)
.await?
.unwrap()
.0;
assert_eq!(
get_chat_description(alice2, alice2_chat_id).await?,
description
);
}
tcm.section("Alice calls set_chat_description() without actually changing the description");
set_chat_description(alice, alice_chat_id, "ä ẟ 😂").await?;
assert!(
alice
.pop_sent_msg_opt(Duration::from_secs(0))
.await
.is_none()
);
Ok(())
}
/// Tests explicitly setting an empty chat description
/// doesn't trigger sending out a message
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setting_empty_chat_description() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
tcm.section("Create a group chat, and add Bob in order to promote it");
let alice_chat_id = create_group(alice, "My Group").await?;
add_contact_to_chat(
alice,
alice_chat_id,
alice.add_or_lookup_contact_id(bob).await,
)
.await?;
let _hi = alice.send_text(alice_chat_id, "hi").await;
set_chat_description(alice, alice_chat_id, "").await?;
assert!(
alice
.pop_sent_msg_opt(Duration::from_secs(0))
.await
.is_none()
);
Ok(())
}
/// Tests that directly after broadcast-securejoin,
/// the brodacast is shown correctly on both devices.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -3095,14 +3446,17 @@ async fn test_broadcast_joining_golden() -> Result<()> {
.await;
let alice_bob_contact = alice.add_or_lookup_contact_no_key(bob).await;
let private_chat = ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact.id)
.await?
.unwrap();
// The 1:1 chat with Bob should not be visible to the user:
assert_eq!(private_chat.blocked, Blocked::Yes);
assert!(
ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact.id)
.await?
.is_none()
);
let private_chat_id =
ChatId::create_for_contact_with_blocked(alice, alice_bob_contact.id, Blocked::Not).await?;
alice
.golden_test_chat(
private_chat.id,
private_chat_id,
"test_broadcast_joining_golden_private_chat",
)
.await;
@@ -3344,6 +3698,11 @@ async fn test_remove_member_from_broadcast() -> Result<()> {
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
remove_contact_from_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
// Alice must not remember old members,
// because we would like to remember the minimum information possible
let past_contacts = get_past_chat_contacts(alice, alice_chat_id).await?;
assert_eq!(past_contacts.len(), 0);
let remove_msg = alice.pop_sent_msg().await;
let rcvd = bob.recv_msg(&remove_msg).await;
assert_eq!(rcvd.text, "Member Me removed by alice@example.org.");
@@ -3374,16 +3733,13 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
join_securejoin(bob0, &qr).await.unwrap();
let request = bob0.pop_sent_msg().await;
assert_eq!(request.recipients, "alice@example.org bob@example.net");
assert_eq!(request.recipients, "alice@example.org");
alice.recv_msg_trash(&request).await;
let auth_required = alice.pop_sent_msg().await;
assert_eq!(
auth_required.recipients,
"bob@example.net alice@example.org"
);
let vc_pubkey = alice.pop_sent_msg().await;
assert_eq!(vc_pubkey.recipients, "bob@example.net");
bob0.recv_msg_trash(&auth_required).await;
bob0.recv_msg_trash(&vc_pubkey).await;
let request_with_auth = bob0.pop_sent_msg().await;
assert_eq!(
request_with_auth.recipients,
@@ -3399,7 +3755,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberAddedToGroup);
tcm.section("Bob's second device also receives these messages");
bob1.recv_msg_trash(&auth_required).await;
bob1.recv_msg_trash(&vc_pubkey).await;
bob1.recv_msg_trash(&request_with_auth).await;
bob1.recv_msg(&member_added).await;
@@ -3429,7 +3785,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
let leave_msg = bob0.pop_sent_msg().await;
let parsed = MimeMessage::from_bytes(bob1, leave_msg.payload().as_bytes()).await?;
assert_eq!(parsed.parts[0].msg, "I left the group.");
assert_eq!(parsed.parts[0].msg, "bob@example.net left the group.");
let rcvd = bob1.recv_msg(&leave_msg).await;
@@ -3496,7 +3852,7 @@ async fn test_only_broadcast_owner_can_send_1() -> Result<()> {
"Bob receives an answer, but shows it in 1:1 chat because of a fingerprint mismatch",
);
let rcvd = bob.recv_msg(&member_added).await;
assert_eq!(rcvd.text, "I added member bob@example.net.");
assert_eq!(rcvd.text, "Member bob@example.net was added.");
let bob_alice_chat_id = bob.get_chat(alice).await.id;
assert_eq!(rcvd.chat_id, bob_alice_chat_id);
@@ -3546,6 +3902,7 @@ async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
tcm.section("Now, Alice's fingerprint changes");
alice.sql.execute("DELETE FROM keypairs", ()).await?;
*alice.self_public_key.lock().await = None;
alice
.sql
.execute("DELETE FROM config WHERE keyname='key_id'", ())
@@ -3556,14 +3913,20 @@ async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
.self_fingerprint
.take();
tcm.section(
"Alice sends a message, which is not put into the broadcast chat but into a 1:1 chat",
);
tcm.section("Alice sends a message, which is trashed");
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
let rcvd = bob.recv_msg(&sent).await;
assert_eq!(rcvd.text, "Hi");
let bob_alice_chat_id = bob.get_chat(alice).await.id;
assert_eq!(rcvd.chat_id, bob_alice_chat_id);
bob.recv_msg_trash(&sent).await;
let EventType::Warning(warning) = bob
.evtracker
.get_matching(|ev| matches!(ev, EventType::Warning(_)))
.await
else {
unreachable!()
};
assert!(
warning.contains("This sender is not allowed to encrypt with this secret key"),
"Wrong warning: {warning}"
);
Ok(())
}
@@ -3598,7 +3961,7 @@ async fn test_sync_broadcast_avatar_and_name() -> Result<()> {
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged);
assert_eq!(
rcvd.text,
r#"You changed group name from "foo" to "New name"."#
r#"Channel name changed from "foo" to "New name"."#
);
let a2_broadcast_chat = Chat::load_from_db(alice2, a2_broadcast_id).await?;
@@ -3612,7 +3975,7 @@ async fn test_sync_broadcast_avatar_and_name() -> Result<()> {
let rcvd = alice1.recv_msg(&sent).await;
assert_eq!(rcvd.chat_id, a1_broadcast_id);
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupImageChanged);
assert_eq!(rcvd.text, "You changed the group image.");
assert_eq!(rcvd.text, "Channel image changed.");
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
let avatar = a1_broadcast_chat.get_profile_image(alice1).await?.unwrap();
@@ -3632,6 +3995,7 @@ async fn test_encrypt_decrypt_broadcast() -> Result<()> {
let grpid = "grpid";
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
let bob_alice_contact_id = bob.add_or_lookup_contact_id(alice).await;
tcm.section("Create a broadcast channel with Bob, and send a message");
let alice_chat_id = create_out_broadcast_ex(
@@ -3655,6 +4019,7 @@ async fn test_encrypt_decrypt_broadcast() -> Result<()> {
)
.await?;
save_broadcast_secret(bob, bob_chat_id, secret).await?;
add_to_chat_contacts_table(bob, time(), bob_chat_id, &[bob_alice_contact_id]).await?;
let sent = alice
.send_text(alice_chat_id, "Symmetrically encrypted message")
@@ -3720,15 +4085,15 @@ async fn test_chat_get_encryption_info() -> Result<()> {
let chat_id = create_group(alice, "Group").await?;
assert_eq!(
chat_id.get_encryption_info(alice).await?,
"End-to-end encryption available"
"Messages are end-to-end encrypted."
);
add_contact_to_chat(alice, chat_id, contact_bob).await?;
assert_eq!(
chat_id.get_encryption_info(alice).await?,
"End-to-end encryption available\n\
"Messages are end-to-end encrypted.\n\
\n\
bob@example.net\n\
bob@example.net(bob@example.net)\n\
CCCB 5AA9 F6E1 141C 9431\n\
65F1 DB18 B18C BCF7 0487"
);
@@ -3736,13 +4101,13 @@ async fn test_chat_get_encryption_info() -> Result<()> {
add_contact_to_chat(alice, chat_id, contact_fiona).await?;
assert_eq!(
chat_id.get_encryption_info(alice).await?,
"End-to-end encryption available\n\
"Messages are end-to-end encrypted.\n\
\n\
fiona@example.net\n\
fiona@example.net(fiona@example.net)\n\
C8BA 50BF 4AC1 2FAF 38D7\n\
F657 DDFC 8E9F 3C79 9195\n\
\n\
bob@example.net\n\
bob@example.net(bob@example.net)\n\
CCCB 5AA9 F6E1 141C 9431\n\
65F1 DB18 B18C BCF7 0487"
);
@@ -3750,13 +4115,13 @@ async fn test_chat_get_encryption_info() -> Result<()> {
let email_chat = alice.create_email_chat(bob).await;
assert_eq!(
email_chat.id.get_encryption_info(alice).await?,
"No encryption"
"No encryption."
);
alice.sql.execute("DELETE FROM public_keys", ()).await?;
assert_eq!(
chat_id.get_encryption_info(alice).await?,
"End-to-end encryption available\n\
"Messages are end-to-end encrypted.\n\
\n\
fiona@example.net\n\
(key missing)\n\
@@ -4414,6 +4779,10 @@ async fn test_sync_broadcast_and_send_message() -> Result<()> {
vec![a2b_contact_id]
);
// alice2's smeared clock may be behind alice1's one, so we need to work around "hi" appearing
// before "You joined the channel." for bob. alice1 makes 3 more calls of
// create_smeared_timestamp() than alice2 does as of 2026-03-10.
SystemTime::shift(Duration::from_secs(3));
tcm.section("Alice's second device sends a message to the channel");
let sent_msg = alice2.send_text(a2_broadcast_id, "hi").await;
let msg = bob.recv_msg(&sent_msg).await;
@@ -4478,7 +4847,7 @@ async fn test_sync_name() -> Result<()> {
assert_eq!(rcvd.to_id, ContactId::SELF);
assert_eq!(
rcvd.text,
"You changed group name from \"Channel\" to \"Broadcast channel 42\"."
"Channel name changed from \"Channel\" to \"Broadcast channel 42\"."
);
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged);
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)
@@ -4548,6 +4917,22 @@ async fn test_sync_create_group() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_contacts_are_hidden() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
tcm.exec_securejoin_qr(bob, alice, &qr).await;
send_text_msg(alice, alice_chat_id, "hello".to_string()).await?;
bob.recv_msg(&alice.pop_sent_msg().await).await;
assert_eq!(Contact::get_all(alice, 0, None).await?.len(), 0);
assert_eq!(Contact::get_all(bob, 0, None).await?.len(), 0);
Ok(())
}
/// Tests sending JPEG image with .png extension.
///
/// This is a regression test, previously sending failed
@@ -5395,6 +5780,97 @@ async fn test_forward_msgs_2ctx() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_forward_msgs_2ctx_with_file() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// First, establish a chat between Alice and Bob to have the chat IDs
let alice_chat = alice.create_chat(bob).await;
let alice_initial = alice.send_text(alice_chat.id, "hi").await;
let bob_alice_msg = bob.recv_msg(&alice_initial).await;
let bob_chat_id = bob_alice_msg.chat_id;
bob_chat_id.accept(bob).await?;
// Alice sends a message with an attached file to her self-chat
let alice_self_chat = alice.get_self_chat().await;
let file_bytes = b"test file content";
let file = alice.get_blobdir().join("test.txt");
tokio::fs::write(&file, file_bytes).await?;
let mut msg = Message::new(Viewtype::File);
msg.set_file_and_deduplicate(alice, &file, Some("test.txt"), Some("text/plain"))?;
msg.set_text("Here's a file".to_string());
alice.send_msg(alice_self_chat.id, &mut msg).await;
let alice_self_msg = alice.get_last_msg().await;
// Verify the file exists in Alice's blobdir
assert_eq!(alice_self_msg.viewtype, Viewtype::File);
let alice_original_file_path = alice_self_msg.get_file(alice).unwrap();
let alice_original_content = tokio::fs::read(&alice_original_file_path).await?;
assert_eq!(alice_original_content, file_bytes);
// Alice forwards the message to Bob using forward_msgs_2ctx
forward_msgs_2ctx(alice, &[alice_self_msg.id], bob, bob_chat_id).await?;
// Bob should have the forwarded message with the file in his database
let bob_msg = bob.get_last_msg().await;
assert_eq!(bob_msg.viewtype, Viewtype::File);
assert!(bob_msg.is_forwarded());
assert_eq!(bob_msg.text, "Here's a file");
assert_eq!(bob_msg.from_id, ContactId::SELF);
// Verify Bob has the file in his blobdir with correct content
let bob_file_path = bob_msg.get_file(bob).unwrap();
let bob_file_content = tokio::fs::read(&bob_file_path).await?;
assert_eq!(bob_file_content, file_bytes);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_forward_msgs_2ctx_missing_blob() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
let alice_initial = alice.send_text(alice_chat.id, "hi").await;
let bob_alice_msg = bob.recv_msg(&alice_initial).await;
let bob_chat_id = bob_alice_msg.chat_id;
bob_chat_id.accept(bob).await?;
// Alice sends a file to her self-chat
let alice_self_chat = alice.get_self_chat().await;
let file_bytes = b"test content";
let file = alice.get_blobdir().join("test.txt");
tokio::fs::write(&file, file_bytes).await?;
let mut msg = Message::new(Viewtype::File);
msg.set_file_and_deduplicate(alice, &file, Some("test.txt"), Some("text/plain"))?;
msg.set_text("File message".to_string());
alice.send_msg(alice_self_chat.id, &mut msg).await;
let alice_self_msg = alice.get_last_msg().await;
// Delete the blob file from Alice's blobdir to simulate a missing file
let alice_file_path = alice_self_msg.get_file(alice).unwrap();
tokio::fs::remove_file(&alice_file_path).await?;
// Alice tries to forward the message - this should fail with an error
let result = forward_msgs_2ctx(alice, &[alice_self_msg.id], bob, bob_chat_id).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to copy blob file")
);
Ok(())
}
/// Tests that in multi-device setup
/// second device learns the key of a contact
/// via Autocrypt-Gossip in 1:1 chats.

View File

@@ -7,6 +7,7 @@ use colorutils_rs::{Oklch, Rgb, TransferFunction};
use sha1::{Digest, Sha1};
/// Converts an identifier to Hue angle.
#[expect(clippy::arithmetic_side_effects)]
fn str_to_angle(s: &str) -> f32 {
let bytes = s.as_bytes();
let result = Sha1::digest(bytes);
@@ -19,6 +20,7 @@ fn str_to_angle(s: &str) -> f32 {
///
/// Returns a 24-bit number with 8 least significant bits corresponding to the blue color and 8
/// most significant bits corresponding to the red color.
#[expect(clippy::arithmetic_side_effects)]
fn rgb_to_u32(rgb: Rgb<u8>) -> u32 {
65536 * u32::from(rgb.r) + 256 * u32::from(rgb.g) + u32::from(rgb.b)
}

View File

@@ -19,7 +19,7 @@ use crate::log::LogExt;
use crate::mimefactory::RECOMMENDED_FILE_SIZE;
use crate::provider::Provider;
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::get_abs_path;
use crate::tools::{get_abs_path, time};
use crate::transport::{ConfiguredLoginParam, add_pseudo_transport, send_sync_transports};
use crate::{constants, stats};
@@ -175,11 +175,6 @@ pub enum Config {
#[strum(props(default = "0"))] // also change MediaQuality.default() on changes
MediaQuality,
/// If set to "1", then existing messages are considered to be already fetched.
/// This flag is reset after successful configuration.
#[strum(props(default = "1"))]
FetchedExistingMsgs,
/// Timer in seconds after which the message is deleted from the
/// server.
///
@@ -199,10 +194,6 @@ pub enum Config {
#[strum(props(default = "0"))]
DeleteDeviceAfter,
/// Move messages to the Trash folder instead of marking them "\Deleted". Overrides
/// `ProviderOptions::delete_to_trash`.
DeleteToTrash,
/// The primary email address.
ConfiguredAddr,
@@ -280,9 +271,6 @@ pub enum Config {
/// Configured folder for chat messages.
ConfiguredMvboxFolder,
/// Configured "Trash" folder.
ConfiguredTrashFolder,
/// Unix timestamp of the last successful configuration.
ConfiguredTimestamp,
@@ -340,10 +328,6 @@ pub enum Config {
/// Timestamp of the last `CantDecryptOutgoingMsgs` notification.
LastCantDecryptOutgoingMsgs,
/// To how many seconds to debounce scan_all_folders. Used mainly in tests, to disable debouncing completely.
#[strum(props(default = "60"))]
ScanAllFoldersDebounceSecs,
/// Whether to avoid using IMAP IDLE even if the server supports it.
///
/// This is a developer option for testing "fake idle".
@@ -607,8 +591,7 @@ impl Context {
.get_config(key)
.await?
.and_then(|s| s.parse::<i32>().ok())
.map(|x| x != 0)
.unwrap_or_default())
.is_some_and(|x| x != 0))
}
/// Returns true if movebox ("DeltaChat" folder) should be watched.
@@ -701,7 +684,6 @@ impl Context {
| Config::MdnsEnabled
| Config::MvboxMove
| Config::OnlyFetchMvbox
| Config::DeleteToTrash
| Config::Configured
| Config::Bot
| Config::NotifyAboutWrongPw
@@ -725,12 +707,7 @@ impl Context {
Self::check_config(key, value)?;
let n_transports = self.count_transports().await?;
if n_transports > 1
&& matches!(
key,
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ShowEmails
)
{
if n_transports > 1 && matches!(key, Config::MvboxMove | Config::OnlyFetchMvbox) {
bail!("Cannot reconfigure {key} when multiple transports are configured");
}
@@ -851,6 +828,22 @@ impl Context {
(addr,),
)?;
// Update the timestamp for the primary transport
// so it becomes the first in `get_all_self_addrs()` list
// and the list of relays distributed in the public key.
// This ensures that messages will be sent
// to the primary relay by the contacts
// and will be fetched in background_fetch()
// which only fetches from the primary transport.
transaction
.execute(
"UPDATE transports SET add_timestamp=?, is_published=1 WHERE addr=?",
(time(), addr),
)
.context(
"Failed to update add_timestamp for the new primary transport",
)?;
// Clean up SMTP and IMAP APPEND queue.
//
// The messages in the queue have a different
@@ -967,12 +960,33 @@ impl Context {
Ok(())
}
/// Returns the primary self address followed by all secondary ones.
/// Returns all self addresses, newest first.
pub(crate) async fn get_all_self_addrs(&self) -> Result<Vec<String>> {
let primary_addrs = self.get_config(Config::ConfiguredAddr).await?.into_iter();
let secondary_addrs = self.get_secondary_self_addrs().await?.into_iter();
self.sql
.query_map_vec(
"SELECT addr FROM transports ORDER BY add_timestamp DESC, id DESC",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)
.await
}
Ok(primary_addrs.chain(secondary_addrs).collect())
/// Returns all published self addresses, newest first.
/// See `[Context::set_transport_unpublished]`
pub(crate) async fn get_published_self_addrs(&self) -> Result<Vec<String>> {
self.sql
.query_map_vec(
"SELECT addr FROM transports WHERE is_published=1 ORDER BY add_timestamp DESC, id DESC",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)
.await
}
/// Returns all secondary self addresses.
@@ -983,6 +997,24 @@ impl Context {
}).await
}
/// Returns all published secondary self addresses.
/// See `[Context::set_transport_unpublished]`
pub(crate) async fn get_published_secondary_self_addrs(&self) -> Result<Vec<String>> {
self.sql
.query_map_vec(
"SELECT addr FROM transports
WHERE is_published
AND addr NOT IN (SELECT value FROM config WHERE keyname='configured_addr')
ORDER BY add_timestamp DESC, id DESC",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)
.await
}
/// Returns the primary self address.
/// Returns an error if no self addr is configured.
pub async fn get_primary_self_addr(&self) -> Result<String> {

View File

@@ -23,13 +23,13 @@ use percent_encoding::utf8_percent_encode;
use server_params::{ServerParams, expand_param_vector};
use tokio::task;
use crate::config::{self, Config};
use crate::config::Config;
use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
use crate::context::Context;
use crate::imap::Imap;
use crate::log::warn;
use crate::login_param::EnteredCertificateChecks;
pub use crate::login_param::EnteredLoginParam;
use crate::login_param::{EnteredCertificateChecks, TransportListEntry};
use crate::message::Message;
use crate::net::proxy::ProxyConfig;
use crate::oauth2::get_oauth2_addr;
@@ -110,6 +110,7 @@ impl Context {
/// from a server encoded in a QR code.
/// - [Self::list_transports()] to get a list of all configured transports.
/// - [Self::delete_transport()] to remove a transport.
/// - [Self::set_transport_unpublished()] to set whether contacts see this transport.
pub async fn add_or_update_transport(&self, param: &mut EnteredLoginParam) -> Result<()> {
self.stop_io().await;
let result = self.add_transport_inner(param).await;
@@ -188,14 +189,22 @@ impl Context {
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to delete a transport.
pub async fn list_transports(&self) -> Result<Vec<EnteredLoginParam>> {
pub async fn list_transports(&self) -> Result<Vec<TransportListEntry>> {
let transports = self
.sql
.query_map_vec("SELECT entered_param FROM transports", (), |row| {
let entered_param: String = row.get(0)?;
let transport: EnteredLoginParam = serde_json::from_str(&entered_param)?;
Ok(transport)
})
.query_map_vec(
"SELECT entered_param, is_published FROM transports",
(),
|row| {
let param: String = row.get(0)?;
let param: EnteredLoginParam = serde_json::from_str(&param)?;
let is_published: bool = row.get(1)?;
Ok(TransportListEntry {
param,
is_unpublished: !is_published,
})
},
)
.await?;
Ok(transports)
@@ -261,6 +270,44 @@ impl Context {
Ok(())
}
/// Change whether the transport is unpublished.
///
/// Unpublished transports are not advertised to contacts,
/// and self-sent messages are not sent there,
/// so that we don't cause extra messages to the corresponding inbox,
/// but can still receive messages from contacts who don't know our new transport addresses yet.
///
/// The default is false, but when the user updates from a version that didn't have this flag,
/// existing secondary transports are set to unpublished,
/// so that an existing transport address doesn't suddenly get spammed with a lot of messages.
pub async fn set_transport_unpublished(&self, addr: &str, unpublished: bool) -> Result<()> {
self.sql
.transaction(|trans| {
let primary_addr: String = trans
.query_row(
"SELECT value FROM config WHERE keyname='configured_addr'",
(),
|row| row.get(0),
)
.context("Select primary address")?;
if primary_addr == addr && unpublished {
bail!("Can't set primary relay as unpublished");
}
// We need to update the timestamp so that the key's timestamp changes
// and is recognized as newer by our peers
trans
.execute(
"UPDATE transports SET is_published=?, add_timestamp=? WHERE addr=? AND is_published!=?1",
(!unpublished, time(), addr),
)
.context("Update transports")?;
Ok(())
})
.await?;
send_sync_transports(self).await?;
Ok(())
}
async fn inner_configure(&self, param: &EnteredLoginParam) -> Result<()> {
info!(self, "Configure ...");
@@ -286,11 +333,6 @@ impl Context {
"To use additional relays, disable the legacy option \"Settings / Advanced / Move automatically to DeltaChat Folder\"."
);
}
if self.get_config(Config::ShowEmails).await?.as_deref() != Some("2") {
bail!(
"To use additional relays, set the legacy option \"Settings / Advanced / Show Classic Emails\" to \"All\"."
);
}
if self
.sql
@@ -554,9 +596,6 @@ async fn get_configured_param(
async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'static Provider>> {
progress!(ctx, 1);
let ctx2 = ctx.clone();
let update_device_chats_handle = task::spawn(async move { ctx2.update_device_chats().await });
let configured_param = get_configured_param(ctx, param).await?;
let proxy_config = ProxyConfig::load(ctx).await?;
let strict_tls = configured_param.strict_tls(proxy_config.is_some());
@@ -595,11 +634,14 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
let (_s, r) = async_channel::bounded(1);
let mut imap = Imap::new(ctx, transport_id, configured_param.clone(), r).await?;
let configuring = true;
if let Err(err) = imap.connect(ctx, configuring).await {
bail!(
"{}",
nicer_configuration_error(ctx, format!("{err:#}")).await
);
let imap_session = match imap.connect(ctx, configuring).await {
Ok(imap_session) => imap_session,
Err(err) => {
bail!(
"{}",
nicer_configuration_error(ctx, format!("{err:#}")).await
);
}
};
progress!(ctx, 850);
@@ -614,7 +656,17 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
ctx.sql.set_raw_config("mvbox_move", Some("0")).await?;
ctx.sql.set_raw_config("only_fetch_mvbox", None).await?;
}
if !ctx.get_config_bool(Config::FixIsChatmail).await? {
if imap_session.is_chatmail() {
ctx.sql.set_raw_config("is_chatmail", Some("1")).await?;
} else if !is_configured {
// Reset the setting that may have been set
// during failed configuration.
ctx.sql.set_raw_config("is_chatmail", Some("0")).await?;
}
}
drop(imap_session);
drop(imap);
progress!(ctx, 910);
@@ -631,12 +683,12 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
progress!(ctx, 920);
ctx.set_config_internal(Config::FetchedExistingMsgs, config::from_bool(false))
.await?;
ctx.scheduler.interrupt_inbox().await;
progress!(ctx, 940);
update_device_chats_handle.await??;
ctx.update_device_chats()
.await
.context("Failed to update device chats")?;
ctx.sql.set_raw_config_bool("configured", true).await?;
ctx.emit_event(EventType::AccountsItemChanged);

View File

@@ -234,19 +234,6 @@ pub(crate) const TIMESTAMP_SENT_TOLERANCE: i64 = 60;
// Newer Delta Chats will remove the prefix as needed.
pub(crate) const EDITED_PREFIX: &str = "✏️";
// Strings needed to render the Autocrypt Setup Message.
// Left untranslated as not being supported/recommended workflow and as translations would require deep knowledge.
pub(crate) const ASM_SUBJECT: &str = "Autocrypt Setup Message";
pub(crate) const ASM_BODY: &str = "This is the Autocrypt Setup Message \
used to transfer your end-to-end setup between clients.
To decrypt and use your setup, \
open the message in an Autocrypt-compliant client \
and enter the setup code presented on the generating device.
If you see this message in a chatmail client (Delta Chat, Arcane Chat, Delta Touch ...), \
use \"Settings / Add Second Device\" instead.";
/// Period between `sql::housekeeping()` runs.
pub(crate) const HOUSEKEEPING_PERIOD: i64 = 24 * 60 * 60;

View File

@@ -35,6 +35,7 @@ use crate::log::{LogExt, warn};
use crate::message::MessageState;
use crate::mimeparser::AvatarAction;
use crate::param::{Param, Params};
use crate::pgp::{addresses_from_public_key, merge_openpgp_certificates};
use crate::sync::{self, Sync::*};
use crate::tools::{SystemTime, duration_to_str, get_abs_path, normalize_text, time, to_lowercase};
use crate::{chat, chatlist_events, ensure_and_debug_assert_ne, stock_str};
@@ -314,6 +315,67 @@ pub async fn make_vcard(context: &Context, contacts: &[ContactId]) -> Result<Str
.to_string())
}
/// Imports public key into the public key store.
///
/// They key may come from Autocrypt header,
/// Autocrypt-Gossip header or a vCard.
///
/// If the key with the same fingerprint already exists,
/// it is updated by merging the new key.
pub(crate) async fn import_public_key(
context: &Context,
public_key: &SignedPublicKey,
) -> Result<()> {
public_key
.verify_bindings()
.context("Attempt to import broken public key")?;
let fingerprint = public_key.dc_fingerprint().hex();
let merged_public_key;
let merged_public_key_ref = if let Some(public_key_bytes) = context
.sql
.query_row_optional(
"SELECT public_key
FROM public_keys
WHERE fingerprint=?",
(&fingerprint,),
|row| {
let bytes: Vec<u8> = row.get(0)?;
Ok(bytes)
},
)
.await?
{
let old_public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
merged_public_key = merge_openpgp_certificates(public_key.clone(), old_public_key)
.context("Failed to merge public keys")?;
&merged_public_key
} else {
public_key
};
let inserted = context
.sql
.execute(
"INSERT INTO public_keys (fingerprint, public_key)
VALUES (?, ?)
ON CONFLICT (fingerprint)
DO UPDATE SET public_key=excluded.public_key
WHERE public_key!=excluded.public_key",
(&fingerprint, merged_public_key_ref.to_bytes()),
)
.await?;
if inserted > 0 {
info!(
context,
"Saved key with fingerprint {fingerprint} from the Autocrypt header"
);
}
Ok(())
}
/// Imports contacts from the given vCard.
///
/// Returns the ids of successfully processed contacts in the order they appear in `vcard`,
@@ -352,23 +414,14 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu
.ok()
});
let fingerprint;
if let Some(public_key) = key {
fingerprint = public_key.dc_fingerprint().hex();
context
.sql
.execute(
"INSERT INTO public_keys (fingerprint, public_key)
VALUES (?, ?)
ON CONFLICT (fingerprint)
DO NOTHING",
(&fingerprint, public_key.to_bytes()),
)
.await?;
let fingerprint = if let Some(public_key) = key {
import_public_key(context, &public_key)
.await
.context("Failed to import public key from vCard")?;
public_key.dc_fingerprint().hex()
} else {
fingerprint = String::new();
}
String::new()
};
let (id, modified) =
match Contact::add_or_lookup_ex(context, &contact.authname, &addr, &fingerprint, origin)
@@ -673,6 +726,7 @@ impl Contact {
}
/// Returns `true` if this contact was seen recently.
#[expect(clippy::arithmetic_side_effects)]
pub fn was_seen_recently(&self) -> bool {
time() - self.last_seen <= SEEN_RECENTLY_SECONDS
}
@@ -1071,6 +1125,7 @@ VALUES (?, ?, ?, ?, ?, ?)
/// The `addr_book` is a multiline string in the format `Name one\nAddress one\nName two\nAddress two`.
///
/// Returns the number of modified contacts.
#[expect(clippy::arithmetic_side_effects)]
pub async fn add_address_book(context: &Context, addr_book: &str) -> Result<usize> {
let mut modify_cnt = 0;
@@ -1299,18 +1354,6 @@ WHERE addr=?
Ok(())
}
/// Returns number of blocked contacts.
pub async fn get_blocked_cnt(context: &Context) -> Result<usize> {
let count = context
.sql
.count(
"SELECT COUNT(*) FROM contacts WHERE id>? AND blocked!=0",
(ContactId::LAST_SPECIAL,),
)
.await?;
Ok(count)
}
/// Get blocked contacts.
pub async fn get_all_blocked(context: &Context) -> Result<Vec<ContactId>> {
Contact::update_blocked_mailinglist_contacts(context)
@@ -1354,13 +1397,13 @@ WHERE addr=?
let fingerprint_other = fingerprint_other.to_string();
let stock_message = if contact.public_key(context).await?.is_some() {
stock_str::e2e_available(context).await
stock_str::messages_are_e2ee(context).await
} else {
stock_str::encr_none(context).await
};
let finger_prints = stock_str::finger_prints(context).await;
let mut ret = format!("{stock_message}.\n{finger_prints}:");
let mut ret = format!("{stock_message}\n{finger_prints}:");
let fingerprint_self = load_self_public_key(context)
.await?
@@ -1394,6 +1437,16 @@ WHERE addr=?
);
}
if let Some(public_key) = contact.public_key(context).await?
&& let Some(relay_addrs) = addresses_from_public_key(&public_key)
{
ret += "\n\nRelays:";
for relay in &relay_addrs {
ret += "\n";
ret += relay;
}
}
Ok(ret)
}
@@ -1466,7 +1519,7 @@ WHERE addr=?
/// Returns true if the contact is a key-contact.
/// Otherwise it is an addresss-contact.
pub fn is_key_contact(&self) -> bool {
self.fingerprint.is_some()
self.fingerprint.is_some() || self.id == ContactId::SELF
}
/// Returns OpenPGP fingerprint of a contact.
@@ -1921,6 +1974,7 @@ pub(crate) async fn set_status(
}
/// Updates last seen timestamp of the contact if it is earlier than the given `timestamp`.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn update_last_seen(
context: &Context,
contact_id: ContactId,
@@ -2012,6 +2066,7 @@ pub(crate) async fn mark_contact_id_as_verified(
Ok(())
}
#[expect(clippy::arithmetic_side_effects)]
fn cat_fingerprint(ret: &mut String, name: &str, addr: &str, fingerprint: &str) {
*ret += &format!("\n\n{name} ({addr}):\n{fingerprint}");
}
@@ -2053,6 +2108,7 @@ impl RecentlySeenLoop {
}
}
#[expect(clippy::arithmetic_side_effects)]
async fn run(context: Context, interrupt: Receiver<RecentlySeenInterrupt>) {
type MyHeapElem = (Reverse<i64>, ContactId);

View File

@@ -823,7 +823,7 @@ async fn test_contact_get_encrinfo() -> Result<()> {
let address_contact_bob_id = alice.add_or_lookup_address_contact_id(bob).await;
let encrinfo = Contact::get_encrinfo(alice, address_contact_bob_id).await?;
assert_eq!(encrinfo, "No encryption");
assert_eq!(encrinfo, "No encryption.");
let contact = Contact::get_by_id(alice, address_contact_bob_id).await?;
assert!(!contact.e2ee_avail(alice).await?);
@@ -832,7 +832,7 @@ async fn test_contact_get_encrinfo() -> Result<()> {
let encrinfo = Contact::get_encrinfo(alice, contact_bob_id).await?;
assert_eq!(
encrinfo,
"End-to-end encryption available.
"Messages are end-to-end encrypted.
Fingerprints:
Me (alice@example.org):
@@ -841,7 +841,10 @@ Me (alice@example.org):
bob@example.net (bob@example.net):
CCCB 5AA9 F6E1 141C 9431
65F1 DB18 B18C BCF7 0487"
65F1 DB18 B18C BCF7 0487
Relays:
bob@example.net"
);
let contact = Contact::get_by_id(alice, contact_bob_id).await?;
assert!(contact.e2ee_avail(alice).await?);
@@ -1145,8 +1148,11 @@ async fn test_make_n_import_vcard() -> Result<()> {
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
bob.set_config(Config::Displayname, Some("Bob")).await?;
bob.set_config(Config::Selfstatus, Some("It's me, bob"))
.await?;
bob.set_config(
Config::Selfstatus,
Some("It's me,\nbob; and here's a backslash: \\"),
)
.await?;
let avatar_path = bob.dir.path().join("avatar.png");
let avatar_bytes = include_bytes!("../../test-data/image/avatar64x64.png");
let avatar_base64 = base64::engine::general_purpose::STANDARD.encode(avatar_bytes);

View File

@@ -8,8 +8,9 @@ use std::sync::atomic::AtomicBool;
use std::sync::{Arc, OnceLock, Weak};
use std::time::Duration;
use anyhow::{Context as _, Result, bail, ensure};
use anyhow::{Result, bail, ensure};
use async_channel::{self as channel, Receiver, Sender};
use pgp::composed::SignedPublicKey;
use ratelimit::Ratelimit;
use tokio::sync::{Mutex, Notify, RwLock};
@@ -36,6 +37,8 @@ use crate::tools::{self, duration_to_str, time, time_elapsed};
use crate::transport::ConfiguredLoginParam;
use crate::{chatlist_events, stats};
pub use crate::scheduler::connectivity::Connectivity;
/// Builder for the [`Context`].
///
/// Many arguments to the [`Context`] are kind of optional and only needed to handle
@@ -231,12 +234,21 @@ pub struct InnerContext {
/// This is a global mutex-like state for operations which should be modal in the
/// clients.
running_state: RwLock<RunningState>,
/// Mutex to avoid generating the key for the user more than once.
pub(crate) generating_key_mutex: Mutex<()>,
/// Mutex to enforce only a single running oauth2 is running.
pub(crate) oauth2_mutex: Mutex<()>,
/// Mutex to prevent a race condition when a "your pw is wrong" warning is sent, resulting in multiple messages being sent.
pub(crate) wrong_pw_warning_mutex: Mutex<()>,
/// Mutex to prevent running housekeeping from multiple threads at once.
pub(crate) housekeeping_mutex: Mutex<()>,
/// Mutex to prevent multiple IMAP loops from fetching the messages at once.
///
/// Without this mutex IMAP loops may waste traffic downloading the same message
/// from multiple IMAP servers and create multiple copies of the same message
/// in the database if the check for duplicates and creating a message
/// happens in separate database transactions.
pub(crate) fetch_msgs_mutex: Mutex<()>,
pub(crate) translated_stockstrings: StockStrings,
pub(crate) events: Events,
@@ -304,6 +316,13 @@ pub struct InnerContext {
/// the standard library's OnceLock is enough, and it's a lot smaller in memory.
pub(crate) self_fingerprint: OnceLock<String>,
/// OpenPGP certificate aka Transferrable Public Key.
///
/// It is generated on first use from the secret key stored in the database.
///
/// Mutex is also held while generating the key to avoid generating the key twice.
pub(crate) self_public_key: Mutex<Option<SignedPublicKey>>,
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
/// see [`Context::get_connectivity()`].
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
@@ -340,6 +359,7 @@ enum RunningState {
/// actual keys and their values which will be present are not
/// guaranteed. Calling [Context::get_info] also includes information
/// about the context on top of the information here.
#[expect(clippy::arithmetic_side_effects)]
pub fn get_info() -> BTreeMap<&'static str, String> {
let mut res = BTreeMap::new();
@@ -472,9 +492,10 @@ impl Context {
running_state: RwLock::new(Default::default()),
sql: Sql::new(dbfile),
smeared_timestamp: SmearedTimestamp::new(),
generating_key_mutex: Mutex::new(()),
oauth2_mutex: Mutex::new(()),
wrong_pw_warning_mutex: Mutex::new(()),
housekeeping_mutex: Mutex::new(()),
fetch_msgs_mutex: Mutex::new(()),
translated_stockstrings: stockstrings,
events,
scheduler: SchedulerState::new(),
@@ -492,6 +513,7 @@ impl Context {
tls_session_store: TlsSessionStore::new(),
iroh: Arc::new(RwLock::new(None)),
self_fingerprint: OnceLock::new(),
self_public_key: Mutex::new(None),
connectivities: parking_lot::Mutex::new(Vec::new()),
pre_encrypt_mime_hook: None.into(),
};
@@ -878,10 +900,6 @@ impl Context {
.get_config(Config::ConfiguredMvboxFolder)
.await?
.unwrap_or_else(|| "<unset>".to_string());
let configured_trash_folder = self
.get_config(Config::ConfiguredTrashFolder)
.await?
.unwrap_or_else(|| "<unset>".to_string());
let mut res = get_info();
@@ -944,12 +962,6 @@ impl Context {
}
res.insert("secondary_addrs", secondary_addrs);
res.insert(
"fetched_existing_msgs",
self.get_config_bool(Config::FetchedExistingMsgs)
.await?
.to_string(),
);
res.insert(
"show_emails",
self.get_config_int(Config::ShowEmails).await?.to_string(),
@@ -972,7 +984,6 @@ impl Context {
);
res.insert("configured_inbox_folder", configured_inbox_folder);
res.insert("configured_mvbox_folder", configured_mvbox_folder);
res.insert("configured_trash_folder", configured_trash_folder);
res.insert("mdns_enabled", mdns_enabled.to_string());
res.insert("bcc_self", bcc_self.to_string());
res.insert("sync_msgs", sync_msgs.to_string());
@@ -996,12 +1007,6 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"delete_to_trash",
self.get_config(Config::DeleteToTrash)
.await?
.unwrap_or_else(|| "<unset>".to_string()),
);
res.insert(
"last_housekeeping",
self.get_config_int(Config::LastHousekeeping)
@@ -1014,12 +1019,6 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"scan_all_folders_debounce_secs",
self.get_config_int(Config::ScanAllFoldersDebounceSecs)
.await?
.to_string(),
);
res.insert(
"quota_exceeding",
self.get_config_int(Config::QuotaExceeding)
@@ -1284,45 +1283,12 @@ ORDER BY m.timestamp DESC,m.id DESC",
Ok(list)
}
/// Returns true if given folder name is the name of the inbox.
pub async fn is_inbox(&self, folder_name: &str) -> Result<bool> {
let inbox = self.get_config(Config::ConfiguredInboxFolder).await?;
Ok(inbox.as_deref() == Some(folder_name))
}
/// Returns true if given folder name is the name of the "DeltaChat" folder.
pub async fn is_mvbox(&self, folder_name: &str) -> Result<bool> {
let mvbox = self.get_config(Config::ConfiguredMvboxFolder).await?;
Ok(mvbox.as_deref() == Some(folder_name))
}
/// Returns true if given folder name is the name of the trash folder.
pub async fn is_trash(&self, folder_name: &str) -> Result<bool> {
let trash = self.get_config(Config::ConfiguredTrashFolder).await?;
Ok(trash.as_deref() == Some(folder_name))
}
pub(crate) async fn should_delete_to_trash(&self) -> Result<bool> {
if let Some(v) = self.get_config_bool_opt(Config::DeleteToTrash).await? {
return Ok(v);
}
if let Some(provider) = self.get_configured_provider().await? {
return Ok(provider.opt.delete_to_trash);
}
Ok(false)
}
/// Returns `target` for deleted messages as per `imap` table. Empty string means "delete w/o
/// moving to trash".
pub(crate) async fn get_delete_msgs_target(&self) -> Result<String> {
if !self.should_delete_to_trash().await? {
return Ok("".into());
}
self.get_config(Config::ConfiguredTrashFolder)
.await?
.context("No configured trash folder")
}
pub(crate) fn derive_blobdir(dbfile: &Path) -> PathBuf {
let mut blob_fname = OsString::new();
blob_fname.push(dbfile.file_name().unwrap_or_default());

View File

@@ -1,34 +1,256 @@
//! End-to-end decryption support.
//! Helper functions for decryption.
//! The actual decryption is done in the [`crate::pgp`] module.
use std::collections::HashSet;
use std::io::Cursor;
use anyhow::Result;
use anyhow::{Context as _, Result, bail};
use mailparse::ParsedMail;
use pgp::composed::Esk;
use pgp::composed::Message;
use pgp::composed::PlainSessionKey;
use pgp::composed::SignedSecretKey;
use pgp::composed::decrypt_session_key_with_password;
use pgp::packet::SymKeyEncryptedSessionKey;
use pgp::types::Password;
use pgp::types::StringToKey;
use crate::key::{Fingerprint, SignedPublicKey, SignedSecretKey};
use crate::pgp;
use crate::chat::ChatId;
use crate::constants::Chattype;
use crate::contact::ContactId;
use crate::context::Context;
use crate::key::self_fingerprint;
use crate::key::{Fingerprint, SignedPublicKey, load_self_secret_keyring};
use crate::token::Namespace;
/// Tries to decrypt a message, but only if it is structured as an Autocrypt message.
/// Tries to decrypt the message,
/// returning a tuple of `(decrypted message, fingerprint)`.
///
/// If successful and the message was encrypted,
/// returns the decrypted and decompressed message.
pub fn try_decrypt<'a>(
/// If the message wasn't encrypted, returns `Ok(None)`.
///
/// If the message was asymmetrically encrypted, returns `Ok((decrypted message, None))`.
///
/// If the message was symmetrically encrypted, returns `Ok((decrypted message, Some(fingerprint)))`,
/// where `fingerprint` denotes which contact is allowed to send encrypted with this symmetric secret.
/// If the message is not signed by `fingerprint`, it must be dropped.
///
/// Otherwise, Eve could send a message to Alice
/// encrypted with the symmetric secret of someone else's broadcast channel.
/// If Alice sends an answer (or read receipt),
/// then Eve would know that Alice is in the broadcast channel.
pub(crate) async fn decrypt(
context: &Context,
mail: &mailparse::ParsedMail<'_>,
) -> Result<Option<(Message<'static>, Option<String>)>> {
// `pgp::composed::Message` is huge (>4kb), so, make sure that it is in a Box when held over an await point
let Some(msg) = get_encrypted_pgp_message_boxed(mail)? else {
return Ok(None);
};
let expected_sender_fingerprint: Option<String>;
let plain = if let Message::Encrypted { esk, .. } = &*msg
// We only allow one ESK for symmetrically encrypted messages
// to avoid dealing with messages that are encrypted to multiple symmetric keys
// or a mix of symmetric and asymmetric keys:
&& let [Esk::SymKeyEncryptedSessionKey(esk)] = &esk[..]
{
check_symmetric_encryption(esk)?;
let (psk, fingerprint) = decrypt_session_key_symmetrically(context, esk)
.await
.context("decrypt_session_key_symmetrically")?;
expected_sender_fingerprint = fingerprint;
tokio::task::spawn_blocking(move || -> Result<Message<'_>> {
let plain = msg
.decrypt_with_session_key(psk)
.context("decrypt_with_session_key")?;
let plain: Message<'static> = plain.decompress()?;
Ok(plain)
})
.await??
} else {
// Message is asymmetrically encrypted
let secret_keys: Vec<SignedSecretKey> = load_self_secret_keyring(context).await?;
expected_sender_fingerprint = None;
tokio::task::spawn_blocking(move || -> Result<Message<'_>> {
let empty_pw = Password::empty();
let secret_keys: Vec<&SignedSecretKey> = secret_keys.iter().collect();
let plain = msg
.decrypt_with_keys(vec![&empty_pw], secret_keys)
.context("decrypt_with_keys")?;
let plain: Message<'static> = plain.decompress()?;
Ok(plain)
})
.await??
};
Ok(Some((plain, expected_sender_fingerprint)))
}
async fn decrypt_session_key_symmetrically(
context: &Context,
esk: &SymKeyEncryptedSessionKey,
) -> Result<(PlainSessionKey, Option<String>)> {
let self_fp = self_fingerprint(context).await?;
let query_only = true;
context
.sql
.call(query_only, |conn| {
// First, try decrypting using AUTH tokens from scanned QR codes, stored in the bobstate,
// because usually there will only be 1 or 2 of it, so, it should be fast
let res: Option<(PlainSessionKey, String)> = try_decrypt_with_bobstate(esk, conn)?;
if let Some((plain_session_key, fingerprint)) = res {
return Ok((plain_session_key, Some(fingerprint)));
}
// Then, try decrypting using broadcast secrets
let res: Option<(PlainSessionKey, Option<String>)> =
try_decrypt_with_broadcast_secret(esk, conn)?;
if let Some((plain_session_key, fingerprint)) = res {
return Ok((plain_session_key, fingerprint));
}
// Finally, try decrypting using own AUTH tokens
// There can be a lot of AUTH tokens,
// because a new one is generated every time a QR code is shown
let res: Option<PlainSessionKey> = try_decrypt_with_auth_token(esk, conn, self_fp)?;
if let Some(plain_session_key) = res {
return Ok((plain_session_key, None));
}
bail!("Could not find symmetric secret for session key")
})
.await
}
fn try_decrypt_with_bobstate(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
) -> Result<Option<(PlainSessionKey, String)>> {
let mut stmt = conn.prepare("SELECT invite FROM bobstate")?;
let mut rows = stmt.query(())?;
while let Some(row) = rows.next()? {
let invite: crate::securejoin::QrInvite = row.get(0)?;
let authcode = invite.authcode().to_string();
let alice_fp = invite.fingerprint().hex();
let shared_secret = format!("securejoin/{alice_fp}/{authcode}");
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(shared_secret)) {
let fingerprint = invite.fingerprint().hex();
return Ok(Some((psk, fingerprint)));
}
}
Ok(None)
}
fn try_decrypt_with_broadcast_secret(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
) -> Result<Option<(PlainSessionKey, Option<String>)>> {
let Some((psk, chat_id)) = try_decrypt_with_broadcast_secret_inner(esk, conn)? else {
return Ok(None);
};
let chat_type: Chattype =
conn.query_one("SELECT type FROM chats WHERE id=?", (chat_id,), |row| {
row.get(0)
})?;
let fp: Option<String> = if chat_type == Chattype::OutBroadcast {
// An attacker who knows the secret will also know who owns it,
// and it's easiest code-wise to just return None here.
// But we could alternatively return the self fingerprint here
None
} else if chat_type == Chattype::InBroadcast {
let contact_id: ContactId = conn
.query_one(
"SELECT contact_id FROM chats_contacts WHERE chat_id=? AND contact_id>9",
(chat_id,),
|row| row.get(0),
)
.context("Find InBroadcast owner")?;
let fp = conn
.query_one(
"SELECT fingerprint FROM contacts WHERE id=?",
(contact_id,),
|row| row.get(0),
)
.context("Find owner fingerprint")?;
Some(fp)
} else {
bail!("Chat {chat_id} is not a broadcast but {chat_type}")
};
Ok(Some((psk, fp)))
}
fn try_decrypt_with_broadcast_secret_inner(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
) -> Result<Option<(PlainSessionKey, ChatId)>> {
let mut stmt = conn.prepare("SELECT secret, chat_id FROM broadcast_secrets")?;
let mut rows = stmt.query(())?;
while let Some(row) = rows.next()? {
let secret: String = row.get(0)?;
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(secret)) {
let chat_id: ChatId = row.get(1)?;
return Ok(Some((psk, chat_id)));
}
}
Ok(None)
}
fn try_decrypt_with_auth_token(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
self_fingerprint: &str,
) -> Result<Option<PlainSessionKey>> {
// ORDER BY id DESC to query the most-recently saved tokens are returned first.
// This improves performance when Bob scans a QR code that was just created.
let mut stmt = conn.prepare("SELECT token FROM tokens WHERE namespc=? ORDER BY id DESC")?;
let mut rows = stmt.query((Namespace::Auth,))?;
while let Some(row) = rows.next()? {
let token: String = row.get(0)?;
let shared_secret = format!("securejoin/{self_fingerprint}/{token}");
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(shared_secret)) {
return Ok(Some(psk));
}
}
Ok(None)
}
/// Returns Ok(()) if we want to try symmetrically decrypting the message,
/// and Err with a reason if symmetric decryption should not be tried.
///
/// A DoS attacker could send a message with a lot of encrypted session keys,
/// all of which use a very hard-to-compute string2key algorithm.
/// We would then try to decrypt all of the encrypted session keys
/// with all of the known shared secrets.
/// In order to prevent this, we do not try to symmetrically decrypt messages
/// that use a string2key algorithm other than 'Salted'.
pub(crate) fn check_symmetric_encryption(esk: &SymKeyEncryptedSessionKey) -> Result<()> {
match esk.s2k() {
Some(StringToKey::Salted { .. }) => Ok(()),
_ => bail!("unsupported string2key algorithm"),
}
}
/// Turns a [`ParsedMail`] into [`pgp::composed::Message`].
/// [`pgp::composed::Message`] is huge (over 4kb),
/// so, it is put on the heap using [`Box`].
pub fn get_encrypted_pgp_message_boxed<'a>(
mail: &'a ParsedMail<'a>,
private_keyring: &'a [SignedSecretKey],
shared_secrets: &[String],
) -> Result<Option<::pgp::composed::Message<'static>>> {
) -> Result<Option<Box<Message<'static>>>> {
let Some(encrypted_data_part) = get_encrypted_mime(mail) else {
return Ok(None);
};
let data = encrypted_data_part.get_body_raw()?;
let msg = pgp::decrypt(data, private_keyring, shared_secrets)?;
Ok(Some(msg))
let cursor = Cursor::new(data);
let (msg, _headers) = Message::from_armor(cursor)?;
Ok(Some(Box::new(msg)))
}
/// Returns a reference to the encrypted payload of a message.
pub(crate) fn get_encrypted_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
pub fn get_encrypted_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
get_autocrypt_mime(mail)
.or_else(|| get_mixed_up_mime(mail))
.or_else(|| get_attachment_mime(mail))
@@ -131,8 +353,10 @@ pub(crate) fn validate_detached_signature<'a, 'b>(
// First part is the content, second part is the signature.
let content = first_part.raw_bytes;
let ret_valid_signatures = match second_part.get_body_raw() {
Ok(signature) => pgp::pk_validate(content, &signature, public_keyring_for_validate)
.unwrap_or_default(),
Ok(signature) => {
crate::pgp::pk_validate(content, &signature, public_keyring_for_validate)
.unwrap_or_default()
}
Err(_) => Default::default(),
};
Some((first_part, ret_valid_signatures))

View File

@@ -235,6 +235,7 @@ fn str_cb(event_str: &str, dehtml: &mut Dehtml) {
}
}
#[expect(clippy::arithmetic_side_effects)]
fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
let tag = String::from_utf8_lossy(event.name().as_ref())
.trim()
@@ -280,6 +281,7 @@ fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
}
}
#[expect(clippy::arithmetic_side_effects)]
fn dehtml_starttag_cb<B: std::io::BufRead>(
event: &BytesStart,
dehtml: &mut Dehtml,
@@ -356,6 +358,7 @@ fn dehtml_starttag_cb<B: std::io::BufRead>(
/// In order to know when a specific tag is closed, we need to count the opening and closing tags.
/// The `counts`s are stored in the `Dehtml` struct.
#[expect(clippy::arithmetic_side_effects)]
fn pop_tag(count: &mut u32) {
if *count > 0 {
*count -= 1;
@@ -364,6 +367,7 @@ fn pop_tag(count: &mut u32) {
/// In order to know when a specific tag is closed, we need to count the opening and closing tags.
/// The `counts`s are stored in the `Dehtml` struct.
#[expect(clippy::arithmetic_side_effects)]
fn maybe_push_tag(
event: &BytesStart,
reader: &Reader<impl BufRead>,

View File

@@ -99,7 +99,8 @@ impl MsgId {
Ok(())
}
/// Updates the message download state. Returns `Ok` if the message doesn't exist anymore.
/// Updates the message download state. Returns `Ok` if the message doesn't exist anymore or has
/// the download state up to date.
pub(crate) async fn update_download_state(
self,
context: &Context,
@@ -108,7 +109,7 @@ impl MsgId {
if context
.sql
.execute(
"UPDATE msgs SET download_state=? WHERE id=?;",
"UPDATE msgs SET download_state=? WHERE id=? AND download_state<>?1",
(download_state, self),
)
.await?
@@ -135,42 +136,46 @@ impl Message {
}
}
/// Actually download a message partially downloaded before.
/// Actually downloads a message partially downloaded before if the message is available on the
/// session transport, in which case returns `Some`. If the message is available on another
/// transport, returns `None`.
///
/// Most messages are downloaded automatically on fetch instead.
pub(crate) async fn download_msg(
context: &Context,
rfc724_mid: String,
session: &mut Session,
) -> Result<()> {
) -> Result<Option<()>> {
let transport_id = session.transport_id();
let row = context
.sql
.query_row_optional(
"SELECT uid, folder FROM imap
WHERE rfc724_mid=?
AND transport_id=?
AND target!=''",
"SELECT uid, folder, transport_id FROM imap
WHERE rfc724_mid=? AND target!=''
ORDER BY transport_id=? DESC LIMIT 1",
(&rfc724_mid, transport_id),
|row| {
let server_uid: u32 = row.get(0)?;
let server_folder: String = row.get(1)?;
Ok((server_uid, server_folder))
let msg_transport_id: u32 = row.get(2)?;
Ok((server_uid, server_folder, msg_transport_id))
},
)
.await?;
let Some((server_uid, server_folder)) = row else {
let Some((server_uid, server_folder, msg_transport_id)) = row else {
// No IMAP record found, we don't know the UID and folder.
return Err(anyhow!(
"IMAP location for {rfc724_mid:?} post-message is unknown"
));
};
if msg_transport_id != transport_id {
return Ok(None);
}
session
.fetch_single_msg(context, &server_folder, server_uid, rfc724_mid)
.await?;
Ok(())
Ok(Some(()))
}
impl Session {
@@ -189,10 +194,7 @@ impl Session {
bail!("Attempt to fetch UID 0");
}
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
ensure!(folder_exists, "No folder {folder}");
// we are connected, and the folder is selected
@@ -275,7 +277,7 @@ pub(crate) async fn download_msgs(context: &Context, session: &mut Session) -> R
for rfc724_mid in &rfc724_mids {
let res = download_msg(context, rfc724_mid.clone(), session).await;
if res.is_ok() {
if let Ok(Some(())) = res {
delete_from_downloads(context, rfc724_mid).await?;
delete_from_available_post_msgs(context, rfc724_mid).await?;
}
@@ -330,7 +332,7 @@ pub(crate) async fn download_known_post_messages_without_pre_message(
// The message may be in the wrong order,
// but at least we have it at all.
let res = download_msg(context, rfc724_mid.clone(), session).await;
if res.is_ok() {
if let Ok(Some(())) = res {
delete_from_available_post_msgs(context, rfc724_mid).await?;
}
if let Err(err) = res {

View File

@@ -41,6 +41,14 @@ impl PostMsgMetadata {
.get(Param::Filename)
.unwrap_or_default()
.to_owned();
let filename = match message.viewtype {
Viewtype::Webxdc => message
.get_webxdc_info(context)
.await
.map(|info| info.name)
.unwrap_or_else(|_| filename),
_ => filename,
};
let wh = {
match (
message.param.get_int(Param::Width),

View File

@@ -40,7 +40,6 @@ impl EncryptHelper {
keyring: Vec<SignedPublicKey>,
mail_to_encrypt: MimePart<'static>,
compress: bool,
anonymous_recipients: bool,
seipd_version: SeipdVersion,
) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;
@@ -49,15 +48,8 @@ impl EncryptHelper {
let cursor = Cursor::new(&mut raw_message);
mail_to_encrypt.clone().write_part(cursor).ok();
let ctext = pgp::pk_encrypt(
raw_message,
keyring,
sign_key,
compress,
anonymous_recipients,
seipd_version,
)
.await?;
let ctext =
pgp::pk_encrypt(raw_message, keyring, sign_key, compress, seipd_version).await?;
Ok(ctext)
}
@@ -70,8 +62,13 @@ impl EncryptHelper {
shared_secret: &str,
mail_to_encrypt: MimePart<'static>,
compress: bool,
sign: bool,
) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;
let sign_key = if sign {
Some(load_self_secret_key(context).await?)
} else {
None
};
let mut raw_message = Vec::new();
let cursor = Cursor::new(&mut raw_message);

View File

@@ -593,6 +593,7 @@ async fn next_expiration_timestamp(context: &Context) -> Option<i64> {
.min()
}
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiver<()>) {
loop {
let ephemeral_timestamp = next_expiration_timestamp(context).await;
@@ -650,6 +651,7 @@ pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiv
}
/// Schedules expired IMAP messages for deletion.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn delete_expired_imap_messages(context: &Context) -> Result<()> {
let now = time();
@@ -665,25 +667,19 @@ pub(crate) async fn delete_expired_imap_messages(context: &Context) -> Result<()
now - max(delete_server_after, MIN_DELETE_SERVER_AFTER),
),
};
let target = context.get_delete_msgs_target().await?;
context
.sql
.execute(
"UPDATE imap
SET target=?
SET target=''
WHERE rfc724_mid IN (
SELECT rfc724_mid FROM msgs
WHERE ((download_state = 0 AND timestamp < ?) OR
(download_state != 0 AND timestamp < ?) OR
(ephemeral_timestamp != 0 AND ephemeral_timestamp <= ?))
)",
(
&target,
threshold_timestamp,
threshold_timestamp_extended,
now,
),
(threshold_timestamp, threshold_timestamp_extended, now),
)
.await?;

View File

@@ -71,7 +71,7 @@ impl EventEmitter {
/// [`try_recv`]: Self::try_recv
pub async fn recv(&self) -> Option<Event> {
let mut lock = self.0.lock().await;
match lock.recv().await {
match lock.recv_direct().await {
Err(async_broadcast::RecvError::Overflowed(n)) => Some(Event {
id: 0,
typ: EventType::EventChannelOverflow { n },
@@ -107,6 +107,39 @@ impl EventEmitter {
| Ok(_)) => Ok(res?),
}
}
/// Waits until there is at least one event available
/// and then returns a vector of at least one event.
///
/// Returns empty vector if the sender has been dropped.
pub async fn recv_batch(&self) -> Vec<Event> {
let mut lock = self.0.lock().await;
let mut res = match lock.recv_direct().await {
Err(async_broadcast::RecvError::Overflowed(n)) => vec![Event {
id: 0,
typ: EventType::EventChannelOverflow { n },
}],
Err(async_broadcast::RecvError::Closed) => return Vec::new(),
Ok(event) => vec![event],
};
// Return up to 100 events in a single batch
// to have a limit on used memory if events arrive too fast.
for _ in 0..100 {
match lock.try_recv() {
Err(async_broadcast::TryRecvError::Overflowed(n)) => res.push(Event {
id: 0,
typ: EventType::EventChannelOverflow { n },
}),
Ok(event) => res.push(event),
Err(async_broadcast::TryRecvError::Empty)
| Err(async_broadcast::TryRecvError::Closed) => {
break;
}
}
}
res
}
}
/// The event emitted by a [`Context`] from an [`EventEmitter`].

View File

@@ -57,7 +57,7 @@ pub enum EventType {
/// should not be disturbed by a dialog or so. Instead, use a bubble or so.
///
/// However, for ongoing processes (eg. configure())
/// or for functions that are expected to fail (eg. dc_continue_key_transfer())
/// or for functions that are expected to fail
/// it might be better to delay showing these events until the function has really
/// failed (returned false). It should be sufficient to report only the *last* error
/// in a message box then.
@@ -397,6 +397,8 @@ pub enum EventType {
msg_id: MsgId,
/// ID of the chat which the message belongs to.
chat_id: ChatId,
/// The call was accepted from this device (process).
from_this_device: bool,
},
/// Outgoing call accepted.

View File

@@ -60,6 +60,9 @@ pub enum HeaderDef {
ChatGroupName,
ChatGroupNameChanged,
ChatGroupNameTimestamp,
ChatGroupDescription,
ChatGroupDescriptionChanged,
ChatGroupDescriptionTimestamp,
ChatVerified,
ChatGroupAvatar,
ChatUserAvatar,
@@ -91,6 +94,7 @@ pub enum HeaderDef {
ChatDispositionNotificationTo,
ChatWebrtcRoom,
ChatWebrtcAccepted,
ChatWebrtcHasVideoInitially,
/// This message deletes the messages listed in the value by rfc724_mid.
ChatDelete,
@@ -204,10 +208,10 @@ mod tests {
/// Test that headers are parsed case-insensitively
fn test_get_header_value_case() {
let (headers, _) =
mailparse::parse_headers(b"fRoM: Bob\naUtoCryPt-SeTup-MessAge: v99").unwrap();
mailparse::parse_headers(b"fRoM: Bob\naUtoCryPt-GoSsIp: fooBaR").unwrap();
assert_eq!(
headers.get_header_value(HeaderDef::AutocryptSetupMessage),
Some("v99".to_string())
headers.get_header_value(HeaderDef::AutocryptGossip),
Some("fooBaR".to_string())
);
assert_eq!(
headers.get_header_value(HeaderDef::From_),

View File

@@ -86,6 +86,7 @@ impl HtmlMsgParser {
/// Function takes a raw mime-message string,
/// searches for the main-text part
/// and returns that as parser.html
#[expect(clippy::arithmetic_side_effects)]
pub async fn from_bytes<'a>(
context: &Context,
rawmime: &'a [u8],
@@ -119,6 +120,7 @@ impl HtmlMsgParser {
/// Usually, there is at most one plain-text and one HTML-text part,
/// multiple plain-text parts might be used for mailinglist-footers,
/// therefore we use the first one.
#[expect(clippy::arithmetic_side_effects)]
async fn collect_texts_recursive<'a>(
&'a mut self,
context: &'a Context,
@@ -254,13 +256,20 @@ fn mimepart_to_data_url(mail: &mailparse::ParsedMail<'_>) -> Result<String> {
impl MsgId {
/// Get HTML by database message id.
/// This requires `mime_headers` field to be set for the message;
/// this is the case at least when `Message.has_html()` returns true
/// (we do not save raw mime unconditionally in the database to save space).
/// Returns `Some` at least if `Message.has_html()` is true.
/// NB: we do not save raw mime unconditionally in the database to save space.
/// The corresponding ffi-function is `dc_get_msg_html()`.
pub async fn get_html(self, context: &Context) -> Result<Option<String>> {
let rawmime = message::get_mime_headers(context, self).await?;
// If there are many concurrent db readers, going to the queue earlier makes sense.
let (param, rawmime) = tokio::join!(
self.get_param(context),
message::get_mime_headers(context, self)
);
if let Some(html) = param?.get(SendHtml) {
return Ok(Some(html.to_string()));
}
let rawmime = rawmime?;
if !rawmime.is_empty() {
match HtmlMsgParser::from_bytes(context, &rawmime).await {
Err(err) => {
@@ -279,9 +288,9 @@ impl MsgId {
#[cfg(test)]
mod tests {
use super::*;
use crate::chat;
use crate::chat::{forward_msgs, save_msgs};
use crate::chat::{self, Chat, forward_msgs, save_msgs};
use crate::config::Config;
use crate::constants;
use crate::contact::ContactId;
use crate::message::{MessengerMessage, Viewtype};
use crate::receive_imf::receive_imf;
@@ -440,7 +449,7 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_html_forwarding() {
async fn test_html_forwarding() -> Result<()> {
// alice receives a non-delta html-message
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
@@ -459,31 +468,57 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
assert!(html.contains("this is <b>html</b>"));
// alice: create chat with bob and forward received html-message there
let chat = alice.create_chat_with_contact("", "bob@example.net").await;
forward_msgs(alice, &[msg.get_id()], chat.get_id())
let chat_alice = alice.create_chat_with_contact("", "bob@example.net").await;
forward_msgs(alice, &[msg.get_id()], chat_alice.get_id())
.await
.unwrap();
let msg = alice.get_last_msg_in(chat.get_id()).await;
assert_eq!(msg.get_from_id(), ContactId::SELF);
assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
assert!(msg.is_forwarded());
assert!(msg.get_text().contains("this is plain"));
assert!(msg.has_html());
let html = msg.get_id().get_html(alice).await.unwrap().unwrap();
assert!(html.contains("this is <b>html</b>"));
async fn check_sender(ctx: &TestContext, chat: &Chat) {
let msg = ctx.get_last_msg_in(chat.get_id()).await;
assert_eq!(msg.get_from_id(), ContactId::SELF);
assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
assert!(msg.is_forwarded());
assert!(msg.get_text().contains("this is plain"));
assert!(msg.has_html());
let html = msg.get_id().get_html(ctx).await.unwrap().unwrap();
assert!(html.contains("this is <b>html</b>"));
}
check_sender(alice, &chat_alice).await;
// bob: check that bob also got the html-part of the forwarded message
let bob = &tcm.bob().await;
let chat = bob.create_chat_with_contact("", "alice@example.org").await;
let msg = bob.recv_msg(&alice.pop_sent_msg().await).await;
assert_eq!(chat.id, msg.chat_id);
assert_ne!(msg.get_from_id(), ContactId::SELF);
assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
assert!(msg.is_forwarded());
assert!(msg.get_text().contains("this is plain"));
let chat_bob = bob.create_chat_with_contact("", "alice@example.org").await;
async fn check_receiver(ctx: &TestContext, chat: &Chat, sender: &TestContext) {
let msg = ctx.recv_msg(&sender.pop_sent_msg().await).await;
assert_eq!(chat.id, msg.chat_id);
assert_ne!(msg.get_from_id(), ContactId::SELF);
assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
assert!(msg.is_forwarded());
assert!(msg.get_text().contains("this is plain"));
assert!(msg.has_html());
let html = msg.get_id().get_html(ctx).await.unwrap().unwrap();
assert!(html.contains("this is <b>html</b>"));
}
check_receiver(bob, &chat_bob, alice).await;
// Let's say that the alice and bob profiles are on the same device,
// so alice can forward the message to herself via bob profile!
chat::forward_msgs_2ctx(alice, &[msg.get_id()], bob, chat_bob.get_id()).await?;
check_sender(bob, &chat_bob).await;
check_receiver(alice, &chat_alice, bob).await;
// Check cross-profile forwarding of long outgoing messages.
let line = "this text with 42 chars is just repeated.\n";
let long_txt = line.repeat(constants::DC_DESIRED_TEXT_LEN / line.len() + 2);
let mut msg = Message::new_text(long_txt);
alice.send_msg(chat_alice.id, &mut msg).await;
let msg = alice.get_last_msg_in(chat_alice.id).await;
assert!(msg.has_html());
let html = msg.get_id().get_html(bob).await.unwrap().unwrap();
assert!(html.contains("this is <b>html</b>"));
let html = msg.id.get_html(alice).await?.unwrap();
chat::forward_msgs_2ctx(alice, &[msg.get_id()], bob, chat_bob.get_id()).await?;
let msg = bob.get_last_msg_in(chat_bob.id).await;
assert!(msg.has_html());
assert_eq!(msg.id.get_html(bob).await?.unwrap(), html);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View File

@@ -16,19 +16,19 @@ use std::{
use anyhow::{Context as _, Result, bail, ensure, format_err};
use async_channel::{self, Receiver, Sender};
use async_imap::types::{Fetch, Flag, Name, NameAttribute, UnsolicitedResponse};
use deltachat_contact_tools::ContactAddress;
use futures::{FutureExt as _, TryStreamExt};
use futures_lite::FutureExt;
use num_traits::FromPrimitive;
use ratelimit::Ratelimit;
use url::Url;
use crate::calls::{create_fallback_ice_servers, create_ice_servers_from_metadata};
use crate::calls::{
UnresolvedIceServer, create_fallback_ice_servers, create_ice_servers_from_metadata,
};
use crate::chat::{self, ChatId, ChatIdBlocked, add_device_msg};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::{self, Blocked, DC_VERSION_STR, ShowEmails};
use crate::contact::{Contact, ContactId, Modifier, Origin};
use crate::constants::{self, Blocked, DC_VERSION_STR};
use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
@@ -52,12 +52,10 @@ use crate::transport::{
pub(crate) mod capabilities;
mod client;
mod idle;
pub mod scan_folders;
pub mod select_folder;
pub(crate) mod session;
use client::{Client, determine_capabilities};
use mailparse::SingleInfo;
use session::Session;
pub(crate) const GENERATED_PREFIX: &str = "GEN_";
@@ -134,16 +132,15 @@ pub(crate) struct ServerMetadata {
pub iroh_relay: Option<Url>,
/// JSON with ICE servers for WebRTC calls
/// and the expiration timestamp.
///
/// If JSON is about to expire, new TURN credentials
/// should be fetched from the server
/// to be ready for WebRTC calls.
pub ice_servers: String,
/// ICE servers for WebRTC calls.
pub ice_servers: Vec<UnresolvedIceServer>,
/// Timestamp when ICE servers are considered
/// expired and should be updated.
///
/// If ICE servers are about to expire, new TURN credentials
/// should be fetched from the server
/// to be ready for WebRTC calls.
pub ice_servers_expiration_timestamp: i64,
}
@@ -184,7 +181,7 @@ impl FolderMeaning {
FolderMeaning::Spam => None,
FolderMeaning::Inbox => Some(Config::ConfiguredInboxFolder),
FolderMeaning::Mvbox => Some(Config::ConfiguredMvboxFolder),
FolderMeaning::Trash => Some(Config::ConfiguredTrashFolder),
FolderMeaning::Trash => None,
FolderMeaning::Virtual => None,
}
}
@@ -210,6 +207,7 @@ impl<T: Iterator<Item = (i64, u32, String)>> Iterator for UidGrouper<T> {
// Tuple of folder, row IDs, and UID range as a string.
type Item = (String, Vec<i64>, String);
#[expect(clippy::arithmetic_side_effects)]
fn next(&mut self) -> Option<Self::Item> {
let (_, _, folder) = self.inner.peek().cloned()?;
@@ -358,10 +356,10 @@ impl Imap {
context,
self.proxy_config.clone(),
self.strict_tls,
connection_candidate,
&connection_candidate,
)
.await
.context("IMAP failed to connect")
.with_context(|| format!("IMAP failed to connect to {connection_candidate}"))
{
Ok(client) => client,
Err(err) => {
@@ -500,13 +498,7 @@ impl Imap {
.get_raw_config_int(constants::DC_FOLDERS_CONFIGURED_KEY)
.await?;
if folders_configured.unwrap_or_default() < constants::DC_FOLDERS_CONFIGURED_VERSION {
let is_chatmail = match context.get_config_bool(Config::FixIsChatmail).await? {
false => session.is_chatmail(),
true => context.get_config_bool(Config::IsChatmail).await?,
};
let create_mvbox = !is_chatmail || context.get_config_bool(Config::MvboxMove).await?;
self.configure_folders(context, &mut session, create_mvbox)
.await?;
self.configure_folders(context, &mut session).await?;
}
Ok(session)
@@ -551,6 +543,7 @@ impl Imap {
/// Fetches new messages.
///
/// Returns true if at least one message was fetched.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn fetch_new_messages(
&mut self,
context: &Context,
@@ -564,9 +557,8 @@ impl Imap {
return Ok(false);
}
let create = false;
let folder_exists = session
.select_with_uidvalidity(context, folder, create)
.select_with_uidvalidity(context, folder)
.await
.with_context(|| format!("Failed to select folder {folder:?}"))?;
if !folder_exists {
@@ -592,6 +584,7 @@ impl Imap {
}
/// Returns number of messages processed and whether the function should be called again.
#[expect(clippy::arithmetic_side_effects)]
async fn fetch_new_msg_batch(
&mut self,
context: &Context,
@@ -613,13 +606,13 @@ impl Imap {
.await
.context("prefetch")?;
let read_cnt = msgs.len();
let _fetch_msgs_lock_guard = context.fetch_msgs_mutex.lock().await;
let mut uids_fetch: Vec<u32> = Vec::new();
let mut available_post_msgs: Vec<String> = Vec::new();
let mut download_later: Vec<String> = Vec::new();
let mut uid_message_ids = BTreeMap::new();
let mut largest_uid_skipped = None;
let delete_target = context.get_delete_msgs_target().await?;
let download_limit: Option<u32> = context
.get_config_parsed(Config::DownloadLimit)
@@ -669,7 +662,7 @@ impl Imap {
let _target;
let target = if delete {
&delete_target
""
} else {
_target = target_folder(context, folder, folder_meaning, &headers).await?;
&_target
@@ -836,27 +829,6 @@ impl Imap {
Ok((read_cnt, fetch_more))
}
/// Read the recipients from old emails sent by the user and add them as contacts.
/// This way, we can already offer them some email addresses they can write to.
///
/// Then, Fetch the last messages DC_FETCH_EXISTING_MSGS_COUNT emails from the server
/// and show them in the chat list.
pub(crate) async fn fetch_existing_msgs(
&mut self,
context: &Context,
session: &mut Session,
) -> Result<()> {
add_all_recipients_as_contacts(context, session, Config::ConfiguredMvboxFolder)
.await
.context("failed to get recipients from the movebox")?;
add_all_recipients_as_contacts(context, session, Config::ConfiguredInboxFolder)
.await
.context("failed to get recipients from the inbox")?;
info!(context, "Done fetching existing messages.");
Ok(())
}
}
impl Session {
@@ -895,10 +867,7 @@ impl Session {
// Collect pairs of UID and Message-ID.
let mut msgs = BTreeMap::new();
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
let transport_id = self.transport_id();
if folder_exists {
let mut list = self
@@ -1020,17 +989,6 @@ impl Session {
return Ok(());
}
Err(err) => {
if context.should_delete_to_trash().await? {
error!(
context,
"Cannot move messages {} to {}, no fallback to COPY/DELETE because \
delete_to_trash is set. Error: {:#}",
set,
target,
err,
);
return Err(err.into());
}
warn!(
context,
"Cannot move messages, fallback to COPY/DELETE {} to {}: {}",
@@ -1044,19 +1002,11 @@ impl Session {
// Server does not support MOVE or MOVE failed.
// Copy messages to the destination folder if needed and mark records for deletion.
let copy = !context.is_trash(target).await?;
if copy {
info!(
context,
"Server does not support MOVE, fallback to COPY/DELETE {} to {}", set, target
);
self.uid_copy(&set, &target).await?;
} else {
error!(
context,
"Server does not support MOVE, fallback to DELETE {} to {}", set, target,
);
}
info!(
context,
"Server does not support MOVE, fallback to COPY/DELETE {} to {}", set, target
);
self.uid_copy(&set, &target).await?;
context
.sql
.transaction(|transaction| {
@@ -1068,11 +1018,9 @@ impl Session {
})
.await
.context("Cannot plan deletion of messages")?;
if copy {
context.emit_event(EventType::ImapMessageMoved(format!(
"IMAP messages {set} copied to {target}"
)));
}
context.emit_event(EventType::ImapMessageMoved(format!(
"IMAP messages {set} copied to {target}"
)));
Ok(())
}
@@ -1104,10 +1052,7 @@ impl Session {
// MOVE/DELETE operations. This does not result in multiple SELECT commands
// being sent because `select_folder()` does nothing if the folder is already
// selected.
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
ensure!(folder_exists, "No folder {folder}");
// Empty target folder name means messages should be deleted.
@@ -1139,7 +1084,6 @@ impl Session {
/// Stores pending `\Seen` flags for messages in `imap_markseen` table.
pub(crate) async fn store_seen_flags_on_imap(&mut self, context: &Context) -> Result<()> {
if context.get_config_bool(Config::TeamProfile).await? {
info!(context, "Team profile, skipping seen flag synchronization.");
return Ok(());
}
@@ -1163,8 +1107,7 @@ impl Session {
.await?;
for (folder, rowid_set, uid_set) in UidGrouper::from(rows) {
let create = false;
let folder_exists = match self.select_with_uidvalidity(context, &folder, create).await {
let folder_exists = match self.select_with_uidvalidity(context, &folder).await {
Err(err) => {
warn!(
context,
@@ -1215,13 +1158,11 @@ impl Session {
}
if context.get_config_bool(Config::TeamProfile).await? {
info!(context, "Team profile, skipping seen flag synchronization.");
return Ok(());
}
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.select_with_uidvalidity(context, folder)
.await
.context("Failed to select folder")?;
if !folder_exists {
@@ -1313,41 +1254,6 @@ impl Session {
Ok(())
}
/// Gets the from, to and bcc addresses from all existing outgoing emails.
pub async fn get_all_recipients(&mut self, context: &Context) -> Result<Vec<SingleInfo>> {
let mut uids: Vec<_> = self
.uid_search(get_imap_self_sent_search_command(context).await?)
.await?
.into_iter()
.collect();
uids.sort_unstable();
let mut result = Vec::new();
for (_, uid_set) in build_sequence_sets(&uids)? {
let mut list = self
.uid_fetch(uid_set, "(UID BODY.PEEK[HEADER.FIELDS (FROM TO CC BCC)])")
.await
.context("IMAP Could not fetch")?;
while let Some(msg) = list.try_next().await? {
match get_fetch_headers(&msg) {
Ok(headers) => {
if let Some(from) = mimeparser::get_from(&headers)
&& context.is_self_addr(&from.addr).await?
{
result.extend(mimeparser::get_recipients(&headers));
}
}
Err(err) => {
warn!(context, "{}", err);
continue;
}
};
}
}
Ok(result)
}
/// Fetches a list of messages by server UID.
///
/// Sends pairs of UID and info about each downloaded message to the provided channel.
@@ -1362,6 +1268,7 @@ impl Session {
///
/// 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::arithmetic_side_effects)]
pub(crate) async fn fetch_many_msgs(
&mut self,
context: &Context,
@@ -1526,6 +1433,7 @@ impl Session {
/// We get [`/shared/comment`](https://www.rfc-editor.org/rfc/rfc5464#section-6.2.1)
/// and [`/shared/admin`](https://www.rfc-editor.org/rfc/rfc5464#section-6.2.2)
/// metadata.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn update_metadata(&mut self, context: &Context) -> Result<()> {
let mut lock = context.metadata.write().await;
@@ -1552,7 +1460,7 @@ impl Session {
if m.entry == "/shared/vendor/deltachat/turn"
&& let Some(value) = m.value
{
match create_ice_servers_from_metadata(context, &value).await {
match create_ice_servers_from_metadata(&value).await {
Ok((parsed_timestamp, parsed_ice_servers)) => {
old_metadata.ice_servers_expiration_timestamp = parsed_timestamp;
old_metadata.ice_servers = parsed_ice_servers;
@@ -1569,7 +1477,7 @@ impl Session {
info!(context, "Will use fallback ICE servers.");
// Set expiration timestamp 7 days in the future so we don't request it again.
old_metadata.ice_servers_expiration_timestamp = time() + 3600 * 24 * 7;
old_metadata.ice_servers = create_fallback_ice_servers(context).await?;
old_metadata.ice_servers = create_fallback_ice_servers();
}
return Ok(());
}
@@ -1616,7 +1524,7 @@ impl Session {
}
"/shared/vendor/deltachat/turn" => {
if let Some(value) = m.value {
match create_ice_servers_from_metadata(context, &value).await {
match create_ice_servers_from_metadata(&value).await {
Ok((parsed_timestamp, parsed_ice_servers)) => {
ice_servers_expiration_timestamp = parsed_timestamp;
ice_servers = Some(parsed_ice_servers);
@@ -1635,7 +1543,7 @@ impl Session {
} else {
// Set expiration timestamp 7 days in the future so we don't request it again.
ice_servers_expiration_timestamp = time() + 3600 * 24 * 7;
create_fallback_ice_servers(context).await?
create_fallback_ice_servers()
};
*lock = Some(ServerMetadata {
@@ -1767,17 +1675,16 @@ impl Session {
/// Attempts to configure mvbox.
///
/// Tries to find any folder examining `folders` in the order they go. If none is found, tries
/// to create any folder in the same order. This method does not use LIST command to ensure that
/// Tries to find any folder examining `folders` in the order they go.
/// This method does not use LIST command to ensure that
/// configuration works even if mailbox lookup is forbidden via Access Control List (see
/// <https://datatracker.ietf.org/doc/html/rfc4314>).
///
/// Returns first found or created folder name.
/// Returns first found folder name.
async fn configure_mvbox<'a>(
&mut self,
context: &Context,
folders: &[&'a str],
create_mvbox: bool,
) -> Result<Option<&'a str>> {
// Close currently selected folder if needed.
// We are going to select folders using low-level EXAMINE operations below.
@@ -1794,34 +1701,12 @@ impl Session {
self.close().await?;
// Before moving emails to the mvbox we need to remember its UIDVALIDITY, otherwise
// emails moved before that wouldn't be fetched but considered "old" instead.
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
ensure!(folder_exists, "No MVBOX folder {:?}??", &folder);
return Ok(Some(folder));
}
}
if !create_mvbox {
return Ok(None);
}
// Some servers require namespace-style folder names like "INBOX.DeltaChat", so we try all
// the variants here.
for folder in folders {
match self
.select_with_uidvalidity(context, folder, create_mvbox)
.await
{
Ok(_) => {
info!(context, "MVBOX-folder {} created.", folder);
return Ok(Some(folder));
}
Err(err) => {
warn!(context, "Cannot create MVBOX-folder {:?}: {}", folder, err);
}
}
}
Ok(None)
}
}
@@ -1831,7 +1716,6 @@ impl Imap {
&mut self,
context: &Context,
session: &mut Session,
create_mvbox: bool,
) -> Result<()> {
let mut folders = session
.list(Some(""), Some("*"))
@@ -1872,7 +1756,7 @@ impl Imap {
let fallback_folder = format!("INBOX{delimiter}DeltaChat");
let mvbox_folder = session
.configure_mvbox(context, &["DeltaChat", &fallback_folder], create_mvbox)
.configure_mvbox(context, &["DeltaChat", &fallback_folder])
.await
.context("failed to configure mvbox")?;
@@ -2090,15 +1974,6 @@ async fn needs_move_to_mvbox(
return Ok(false);
}
if headers
.get_header_value(HeaderDef::AutocryptSetupMessage)
.is_some()
{
// do not move setup messages;
// there may be a non-delta device that wants to handle it
return Ok(false);
}
if has_chat_version {
Ok(true)
} else if let Some(parent) = get_prefetch_parent_message(context, headers).await? {
@@ -2247,16 +2122,11 @@ pub(crate) async fn prefetch_should_download(
false
};
// Autocrypt Setup Message should be shown even if it is from non-chat client.
let is_autocrypt_setup_message = headers
.get_header_value(HeaderDef::AutocryptSetupMessage)
.is_some();
let from = match mimeparser::get_from(headers) {
Some(f) => f,
None => return Ok(false),
};
let (_from_id, blocked_contact, origin) =
let (_from_id, blocked_contact, _origin) =
match from_field_to_contact_id(context, &from, None, true, true).await? {
Some(res) => res,
None => return Ok(false),
@@ -2269,29 +2139,7 @@ pub(crate) async fn prefetch_should_download(
return Ok(false);
}
let is_chat_message = headers.get_header_value(HeaderDef::ChatVersion).is_some();
let accepted_contact = origin.is_known();
let is_reply_to_chat_message = get_prefetch_parent_message(context, headers)
.await?
.map(|parent| match parent.is_dc_message {
MessengerMessage::No => false,
MessengerMessage::Yes | MessengerMessage::Reply => true,
})
.unwrap_or_default();
let show_emails =
ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?).unwrap_or_default();
let show = is_autocrypt_setup_message
|| match show_emails {
ShowEmails::Off => is_chat_message || is_reply_to_chat_message,
ShowEmails::AcceptedContacts => {
is_chat_message || is_reply_to_chat_message || accepted_contact
}
ShowEmails::All => true,
};
let should_download = (show && !blocked_contact) || maybe_ndn;
let should_download = (!blocked_contact) || maybe_ndn;
Ok(should_download)
}
@@ -2468,18 +2316,6 @@ async fn get_modseq(context: &Context, transport_id: u32, folder: &str) -> Resul
.unwrap_or(0))
}
/// Compute the imap search expression for all self-sent mails (for all self addresses)
pub(crate) async fn get_imap_self_sent_search_command(context: &Context) -> Result<String> {
// See https://www.rfc-editor.org/rfc/rfc3501#section-6.4.4 for syntax of SEARCH and OR
let mut search_command = format!("FROM \"{}\"", context.get_primary_self_addr().await?);
for item in context.get_secondary_self_addrs().await? {
search_command = format!("OR ({search_command}) (FROM \"{item}\")");
}
Ok(search_command)
}
/// Whether to ignore fetching messages from a folder.
///
/// This caters for the [`Config::OnlyFetchMvbox`] setting which means mails from folders
@@ -2498,6 +2334,7 @@ async fn should_ignore_folder(
/// Builds a list of sequence/uid sets. The returned sets have each no more than around 1000
/// characters because according to <https://tools.ietf.org/html/rfc2683#section-3.2.1.5>
/// command lines should not be much more than 1000 chars (servers should allow at least 8000 chars)
#[expect(clippy::arithmetic_side_effects)]
fn build_sequence_sets(uids: &[u32]) -> Result<Vec<(Vec<u32>, String)>> {
// first, try to find consecutive ranges:
let mut ranges: Vec<UidRange> = vec![];
@@ -2552,65 +2389,23 @@ impl std::fmt::Display for UidRange {
}
}
}
async fn add_all_recipients_as_contacts(
context: &Context,
session: &mut Session,
folder: Config,
) -> Result<()> {
let mailbox = if let Some(m) = context.get_config(folder).await? {
m
} else {
info!(
context,
"Folder {} is not configured, skipping fetching contacts from it.", folder
);
return Ok(());
};
let create = false;
let folder_exists = session
.select_with_uidvalidity(context, &mailbox, create)
.await
.with_context(|| format!("could not select {mailbox}"))?;
if !folder_exists {
return Ok(());
pub(crate) async fn get_watched_folder_configs(context: &Context) -> Result<Vec<Config>> {
let mut res = vec![Config::ConfiguredInboxFolder];
if context.should_watch_mvbox().await? {
res.push(Config::ConfiguredMvboxFolder);
}
Ok(res)
}
let recipients = session
.get_all_recipients(context)
.await
.context("could not get recipients")?;
let mut any_modified = false;
for recipient in recipients {
let recipient_addr = match ContactAddress::new(&recipient.addr) {
Err(err) => {
warn!(
context,
"Could not add contact for recipient with address {:?}: {:#}",
recipient.addr,
err
);
continue;
}
Ok(recipient_addr) => recipient_addr,
};
let (_, modified) = Contact::add_or_lookup(
context,
&recipient.display_name.unwrap_or_default(),
&recipient_addr,
Origin::OutgoingTo,
)
.await?;
if modified != Modifier::None {
any_modified = true;
pub(crate) async fn get_watched_folders(context: &Context) -> Result<Vec<String>> {
let mut res = Vec::new();
for folder_config in get_watched_folder_configs(context).await? {
if let Some(folder) = context.get_config(folder_config).await? {
res.push(folder);
}
}
if any_modified {
context.emit_event(EventType::ContactsChanged(None));
}
Ok(())
Ok(res)
}
#[cfg(test)]

View File

@@ -150,7 +150,7 @@ impl Client {
Err(err) => {
warn!(
context,
"Failed to connect to {host} ({resolved_addr}): {err:#}."
"IMAP failed to connect to {host} ({resolved_addr}): {err:#}."
);
Err(err)
}
@@ -161,7 +161,7 @@ impl Client {
context: &Context,
proxy_config: Option<ProxyConfig>,
strict_tls: bool,
candidate: ConnectionCandidate,
candidate: &ConnectionCandidate,
) -> Result<Self> {
let host = &candidate.host;
let port = candidate.port;

View File

@@ -27,9 +27,7 @@ impl Session {
idle_interrupt_receiver: Receiver<()>,
folder: &str,
) -> Result<Self> {
let create = true;
self.select_with_uidvalidity(context, folder, create)
.await?;
self.select_with_uidvalidity(context, folder).await?;
if self.drain_unsolicited_responses(context)? {
self.new_mail = true;

View File

@@ -1,6 +1,6 @@
use super::*;
use crate::contact::Contact;
use crate::test_utils::TestContext;
use crate::transport::add_pseudo_transport;
#[test]
fn test_get_folder_meaning_by_name() {
@@ -105,10 +105,9 @@ async fn check_target_folder_combination(
expected_destination: &str,
accepted_chat: bool,
outgoing: bool,
setupmessage: bool,
) -> Result<()> {
println!(
"Testing: For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}"
"Testing: For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}"
);
let t = TestContext::new_alice().await;
@@ -125,9 +124,7 @@ async fn check_target_folder_combination(
}
let temp;
let bytes = if setupmessage {
include_bytes!("../../test-data/message/AutocryptSetupMessage.eml")
} else {
let bytes = {
temp = format!(
"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
{}\
@@ -164,7 +161,7 @@ async fn check_target_folder_combination(
assert_eq!(
expected,
actual.as_deref(),
"For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}: expected {expected:?}, got {actual:?}"
"For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}: expected {expected:?}, got {actual:?}"
);
Ok(())
}
@@ -204,7 +201,6 @@ async fn test_target_folder_incoming_accepted() -> Result<()> {
expected_destination,
true,
false,
false,
)
.await?;
}
@@ -221,7 +217,6 @@ async fn test_target_folder_incoming_request() -> Result<()> {
expected_destination,
false,
false,
false,
)
.await?;
}
@@ -239,56 +234,12 @@ async fn test_target_folder_outgoing() -> Result<()> {
expected_destination,
true,
true,
false,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_setupmsg() -> Result<()> {
// Test setupmessages
for (folder, mvbox_move, chat_msg, _expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
if folder == &"Spam" { "INBOX" } else { folder }, // Never move setup messages, except if they are in "Spam"
false,
true,
true,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_imap_search_command() -> Result<()> {
let t = TestContext::new_alice().await;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,
r#"FROM "alice@example.org""#
);
add_pseudo_transport(&t, "alice@another.com").await?;
t.ctx.set_primary_self_addr("alice@another.com").await?;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,
r#"OR (FROM "alice@another.com") (FROM "alice@example.org")"#
);
add_pseudo_transport(&t, "alice@third.com").await?;
t.ctx.set_primary_self_addr("alice@third.com").await?;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,
r#"OR (OR (FROM "alice@third.com") (FROM "alice@another.com")) (FROM "alice@example.org")"#
);
Ok(())
}
#[test]
fn test_uid_grouper() {
// Input: sequence of (rowid: i64, uid: u32, target: String)

View File

@@ -1,119 +0,0 @@
use std::collections::BTreeMap;
use anyhow::{Context as _, Result};
use super::{get_folder_meaning_by_attrs, get_folder_meaning_by_name};
use crate::config::Config;
use crate::imap::{Imap, session::Session};
use crate::log::LogExt;
use crate::tools::{self, time_elapsed};
use crate::{context::Context, imap::FolderMeaning};
impl Imap {
/// Returns true if folders were scanned, false if scanning was postponed.
pub(crate) async fn scan_folders(
&mut self,
context: &Context,
session: &mut Session,
) -> Result<bool> {
// First of all, debounce to once per minute:
{
let mut last_scan = session.last_full_folder_scan.lock().await;
if let Some(last_scan) = *last_scan {
let elapsed_secs = time_elapsed(&last_scan).as_secs();
let debounce_secs = context
.get_config_u64(Config::ScanAllFoldersDebounceSecs)
.await?;
if elapsed_secs < debounce_secs {
return Ok(false);
}
}
// Update the timestamp before scanning the folders
// to avoid holding the lock for too long.
// This means next scan is delayed even if
// the current one fails.
last_scan.replace(tools::Time::now());
}
info!(context, "Starting full folder scan");
let folders = session.list_folders().await?;
let watched_folders = get_watched_folders(context).await?;
let mut folder_configs = BTreeMap::new();
let mut folder_names = Vec::new();
for folder in folders {
let folder_meaning = get_folder_meaning_by_attrs(folder.attributes());
if folder_meaning == FolderMeaning::Virtual {
// Gmail has virtual folders that should be skipped. For example,
// emails appear in the inbox and under "All Mail" as soon as it is
// received. The code used to wrongly conclude that the email had
// already been moved and left it in the inbox.
continue;
}
folder_names.push(folder.name().to_string());
let folder_name_meaning = get_folder_meaning_by_name(folder.name());
if let Some(config) = folder_meaning.to_config() {
// Always takes precedence
folder_configs.insert(config, folder.name().to_string());
} else if let Some(config) = folder_name_meaning.to_config() {
// only set if none has been already set
folder_configs
.entry(config)
.or_insert_with(|| folder.name().to_string());
}
let folder_meaning = match folder_meaning {
FolderMeaning::Unknown => folder_name_meaning,
_ => folder_meaning,
};
// Don't scan folders that are watched anyway
if !watched_folders.contains(&folder.name().to_string())
&& folder_meaning != FolderMeaning::Trash
&& folder_meaning != FolderMeaning::Unknown
{
self.fetch_move_delete(context, session, folder.name(), folder_meaning)
.await
.context("Can't fetch new msgs in scanned folder")
.log_err(context)
.ok();
}
}
// Set config for the Trash folder. Or reset if the folder was deleted.
let conf = Config::ConfiguredTrashFolder;
let val = folder_configs.get(&conf).map(|s| s.as_str());
let interrupt = val.is_some() && context.get_config(conf).await?.is_none();
context.set_config_internal(conf, val).await?;
if interrupt {
// `Imap::fetch_move_delete()`, particularly message deletion, is possible now for other
// folders (NB: we are in the Inbox loop).
context.scheduler.interrupt_oboxes().await;
}
info!(context, "Found folders: {folder_names:?}.");
Ok(true)
}
}
pub(crate) async fn get_watched_folder_configs(context: &Context) -> Result<Vec<Config>> {
let mut res = vec![Config::ConfiguredInboxFolder];
if context.should_watch_mvbox().await? {
res.push(Config::ConfiguredMvboxFolder);
}
Ok(res)
}
pub(crate) async fn get_watched_folders(context: &Context) -> Result<Vec<String>> {
let mut res = Vec::new();
for folder_config in get_watched_folder_configs(context).await? {
if let Some(folder) = context.get_config(folder_config).await? {
res.push(folder);
}
}
Ok(res)
}

View File

@@ -89,33 +89,6 @@ impl ImapSession {
}
}
/// Selects a folder. Tries to create it once and select again if the folder does not exist.
pub(super) async fn select_or_create_folder(
&mut self,
context: &Context,
folder: &str,
) -> anyhow::Result<NewlySelected> {
match self.select_folder(context, folder).await {
Ok(newly_selected) => Ok(newly_selected),
Err(err) => match err {
Error::NoFolder(..) => {
info!(context, "Failed to select folder {folder:?} because it does not exist, trying to create it.");
let create_res = self.create(folder).await;
if let Err(ref err) = create_res {
info!(context, "Couldn't select folder, then create() failed: {err:#}.");
// Need to recheck, could have been created in parallel.
}
let select_res = self.select_folder(context, folder).await.with_context(|| format!("failed to select newely created folder {folder}"));
if select_res.is_err() {
create_res?;
}
select_res
}
_ => Err(err).with_context(|| format!("failed to select folder {folder} with error other than NO, not trying to create it")),
},
}
}
/// Selects a folder optionally creating it and takes care of UIDVALIDITY changes. Returns false
/// iff `folder` doesn't exist.
///
@@ -129,23 +102,16 @@ impl ImapSession {
&mut self,
context: &Context,
folder: &str,
create: bool,
) -> anyhow::Result<bool> {
let newly_selected = if create {
self.select_or_create_folder(context, folder)
.await
.with_context(|| format!("Failed to select or create folder {folder:?}"))?
} else {
match self.select_folder(context, folder).await {
Ok(newly_selected) => newly_selected,
Err(err) => match err {
Error::NoFolder(..) => return Ok(false),
_ => {
return Err(err)
.with_context(|| format!("Failed to select folder {folder:?}"))?;
}
},
}
let newly_selected = match self.select_folder(context, folder).await {
Ok(newly_selected) => newly_selected,
Err(err) => match err {
Error::NoFolder(..) => return Ok(false),
_ => {
return Err(err)
.with_context(|| format!("Failed to select folder {folder:?}"))?;
}
},
};
let transport_id = self.transport_id();

View File

@@ -5,11 +5,9 @@ use anyhow::{Context as _, Result};
use async_imap::Session as ImapSession;
use async_imap::types::Mailbox;
use futures::TryStreamExt;
use tokio::sync::Mutex;
use crate::imap::capabilities::Capabilities;
use crate::net::session::SessionStream;
use crate::tools;
/// Prefetch:
/// - Message-ID to check if we already have the message.
@@ -23,10 +21,8 @@ const PREFETCH_FLAGS: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIE
DATE \
X-MICROSOFT-ORIGINAL-MESSAGE-ID \
FROM \
IN-REPLY-TO REFERENCES \
CHAT-VERSION \
CHAT-IS-POST-MESSAGE \
AUTO-SUBMITTED \
AUTOCRYPT-SETUP-MESSAGE\
)])";
@@ -46,8 +42,6 @@ pub(crate) struct Session {
pub selected_folder_needs_expunge: bool,
pub(crate) last_full_folder_scan: Mutex<Option<tools::Time>>,
/// True if currently selected folder has new messages.
///
/// Should be false if no folder is currently selected.
@@ -84,7 +78,6 @@ impl Session {
selected_folder: None,
selected_mailbox: None,
selected_folder_needs_expunge: false,
last_full_folder_scan: Mutex::new(None),
new_mail: false,
resync_request_sender,
}
@@ -132,6 +125,7 @@ impl Session {
/// Prefetch `n_uids` messages starting from `uid_next`. Returns a list of fetch results in the
/// order of ascending delivery time to the server (INTERNALDATE).
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn prefetch(
&mut self,
uid_next: u32,

View File

@@ -19,9 +19,8 @@ use crate::config::Config;
use crate::context::Context;
use crate::e2ee;
use crate::events::EventType;
use crate::key::{self, DcKey, DcSecretKey, SignedPublicKey, SignedSecretKey};
use crate::key::{self, DcKey, SignedSecretKey};
use crate::log::{LogExt, warn};
use crate::pgp;
use crate::qr::DCBACKUP_VERSION;
use crate::sql;
use crate::tools::{
@@ -29,11 +28,9 @@ use crate::tools::{
write_file,
};
mod key_transfer;
mod transfer;
use ::pgp::types::KeyDetails;
pub use key_transfer::{continue_key_transfer, initiate_key_transfer};
pub use transfer::{BackupProvider, get_backup};
// Name of the database file in the backup.
@@ -103,7 +100,8 @@ pub async fn imex(
if let Err(err) = res.as_ref() {
// We are using Anyhow's .context() and to show the inner error, too, we need the {:#}:
error!(context, "IMEX failed to complete: {:#}", err);
error!(context, "{:#}", err);
warn!(context, "IMEX failed to complete: {:#}", err);
context.emit_event(EventType::ImexProgress(0));
} else {
info!(context, "IMEX successfully completed");
@@ -141,19 +139,13 @@ pub async fn has_backup(_context: &Context, dir_name: &Path) -> Result<String> {
}
async fn set_self_key(context: &Context, armored: &str) -> Result<()> {
let private_key = SignedSecretKey::from_asc(armored)?;
let public_key = private_key.split_public_key()?;
let keypair = pgp::KeyPair {
public: public_key,
secret: private_key,
};
key::store_self_keypair(context, &keypair).await?;
let secret_key = SignedSecretKey::from_asc(armored)?;
key::store_self_keypair(context, &secret_key).await?;
info!(
context,
"stored self key: {:?}",
keypair.secret.public_key().key_id()
"Stored self key: {:?}.",
secret_key.public_key().fingerprint()
);
Ok(())
}
@@ -209,15 +201,6 @@ async fn import_backup(
backup_to_import: &Path,
passphrase: String,
) -> Result<()> {
ensure!(
!context.is_configured().await?,
"Cannot import backups to accounts in use."
);
ensure!(
!context.scheduler.is_running().await,
"cannot import backup, IO is running"
);
let backup_file = File::open(backup_to_import).await?;
let file_size = backup_file.metadata().await?.len();
info!(
@@ -251,6 +234,15 @@ pub(crate) async fn import_backup_stream<R: tokio::io::AsyncRead + Unpin>(
file_size: u64,
passphrase: String,
) -> Result<()> {
ensure!(
!context.is_configured().await?,
"Cannot import backups to accounts in use"
);
ensure!(
!context.scheduler.is_running().await,
"Cannot import backup, IO is running"
);
import_backup_stream_inner(context, backup_file, file_size, passphrase)
.await
.0
@@ -293,6 +285,7 @@ impl<R> AsyncRead for ProgressReader<R>
where
R: AsyncRead,
{
#[expect(clippy::arithmetic_side_effects)]
fn poll_read(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
@@ -316,6 +309,9 @@ where
}
}
// This function returns a tuple (Result<()>,) rather than Result<()>
// so that we don't accidentally early-return with `?`
// and forget to cleanup.
async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
context: &Context,
backup_file: R,
@@ -362,11 +358,6 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
}
}
};
if res.is_err() {
for blob in blobs {
fs::remove_file(&blob).await.log_err(context).ok();
}
}
let unpacked_database = context.get_blobdir().join(DBFILE_BACKUP_NAME);
if res.is_ok() {
@@ -379,17 +370,6 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
if res.is_ok() {
res = check_backup_version(context).await;
}
if res.is_ok() {
// All recent backups have `bcc_self` set to "1" before export.
//
// Setting `bcc_self` to "1" on export was introduced on 2024-12-17
// in commit 21664125d798021be75f47d5b0d5006d338b4531
//
// We additionally try to set `bcc_self` to "1" after import here
// for compatibility with older backups,
// but eventually this code can be removed.
res = context.set_config(Config::BccSelf, Some("1")).await;
}
fs::remove_file(unpacked_database)
.await
.context("cannot remove unpacked database")
@@ -400,6 +380,22 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
res = context.sql.run_migrations(context).await;
context.emit_event(EventType::AccountsItemChanged);
}
if res.is_err() {
context.sql.close().await;
fs::remove_file(context.sql.dbfile.as_path())
.await
.log_err(context)
.ok();
for blob in blobs {
fs::remove_file(&blob).await.log_err(context).ok();
}
context
.sql
.open(context, "".to_string())
.await
.log_err(context)
.ok();
}
if res.is_ok() {
delete_and_reset_all_device_msgs(context)
.await
@@ -449,6 +445,7 @@ fn get_next_backup_path(
/// Exports the database to a separate file with the given passphrase.
///
/// Set passphrase to empty string to export the database unencrypted.
#[expect(clippy::arithmetic_side_effects)]
async fn export_backup(context: &Context, dir: &Path, passphrase: String) -> Result<()> {
// get a fine backup file name (the name includes the date so that multiple backup instances are possible)
let now = time();
@@ -522,6 +519,7 @@ impl<W> AsyncWrite for ProgressWriter<W>
where
W: AsyncWrite,
{
#[expect(clippy::arithmetic_side_effects)]
fn poll_write(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
@@ -601,6 +599,7 @@ async fn import_secret_key(context: &Context, path: &Path) -> Result<()> {
/// containing secret keys are imported and the last successfully
/// imported which does not contain "legacy" in its filename
/// is set as the default.
#[expect(clippy::arithmetic_side_effects)]
async fn import_self_keys(context: &Context, path: &Path) -> Result<()> {
let attr = tokio::fs::metadata(path).await?;
@@ -654,44 +653,43 @@ async fn import_self_keys(context: &Context, path: &Path) -> Result<()> {
Ok(())
}
#[expect(clippy::arithmetic_side_effects)]
async fn export_self_keys(context: &Context, dir: &Path) -> Result<()> {
let mut export_errors = 0;
let keys = context
.sql
.query_map_vec(
"SELECT id, public_key, private_key, id=(SELECT value FROM config WHERE keyname='key_id') FROM keypairs;",
"SELECT id, private_key, id=(SELECT value FROM config WHERE keyname='key_id') FROM keypairs;",
(),
|row| {
let id = row.get(0)?;
let public_key_blob: Vec<u8> = row.get(1)?;
let public_key = SignedPublicKey::from_slice(&public_key_blob);
let private_key_blob: Vec<u8> = row.get(2)?;
let private_key_blob: Vec<u8> = row.get(1)?;
let private_key = SignedSecretKey::from_slice(&private_key_blob);
let is_default: i32 = row.get(3)?;
let is_default: i32 = row.get(2)?;
Ok((id, public_key, private_key, is_default))
Ok((id, private_key, is_default))
},
)
.await?;
let self_addr = context.get_primary_self_addr().await?;
for (id, public_key, private_key, is_default) in keys {
for (id, private_key, is_default) in keys {
let id = Some(id).filter(|_| is_default == 0);
if let Ok(key) = public_key {
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &key).await {
error!(context, "Failed to export public key: {:#}.", err);
export_errors += 1;
}
} else {
let Ok(private_key) = private_key else {
export_errors += 1;
continue;
};
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &private_key).await {
error!(context, "Failed to export private key: {:#}.", err);
export_errors += 1;
}
if let Ok(key) = private_key {
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &key).await {
error!(context, "Failed to export private key: {:#}.", err);
export_errors += 1;
}
} else {
let public_key = private_key.to_public_key();
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &public_key).await {
error!(context, "Failed to export public key: {:#}.", err);
export_errors += 1;
}
}
@@ -721,12 +719,7 @@ where
format!("{kind}-key-{addr}-{id}-{fp}.asc")
};
let path = dir.join(&file_name);
info!(
context,
"Exporting key {:?} to {}.",
key.key_id(),
path.display()
);
info!(context, "Exporting key to {}.", path.display());
// Delete the file if it already exists.
delete_file(context, &path).await.ok();
@@ -800,7 +793,7 @@ async fn check_backup_version(context: &Context) -> Result<()> {
let version = (context.sql.get_raw_config_int("backup_version").await?).unwrap_or(2);
ensure!(
version <= DCBACKUP_VERSION,
"Backup too new, please update Delta Chat"
"This profile is from a newer version of Delta Chat. Please update Delta Chat and try again (profile version is v{version}, the latest supported is v{DCBACKUP_VERSION})"
);
Ok(())
}
@@ -813,12 +806,12 @@ mod tests {
use super::*;
use crate::config::Config;
use crate::test_utils::{TestContext, alice_keypair};
use crate::test_utils::{TestContext, TestContextManager, alice_keypair};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_export_public_key_to_asc_file() {
let context = TestContext::new().await;
let key = alice_keypair().public;
let key = alice_keypair().to_public_key();
let blobdir = Path::new("$BLOBDIR");
let filename = export_key_to_asc_file(&context.ctx, blobdir, "a@b", None, &key)
.await
@@ -835,7 +828,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_import_private_key_exported_to_asc_file() {
let context = TestContext::new().await;
let key = alice_keypair().secret;
let key = alice_keypair();
let blobdir = Path::new("$BLOBDIR");
let filename = export_key_to_asc_file(&context.ctx, blobdir, "a@b", None, &key)
.await
@@ -1030,6 +1023,81 @@ mod tests {
Ok(())
}
/// Tests that [`crate::qr::DCBACKUP_VERSION`] is checked correctly.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_import_backup_fails_because_of_dcbackup_version() -> Result<()> {
let mut tcm = TestContextManager::new();
let context1 = tcm.alice().await;
let context2 = tcm.unconfigured().await;
assert!(context1.is_configured().await?);
assert!(!context2.is_configured().await?);
let backup_dir = tempfile::tempdir().unwrap();
tcm.section("export from context1");
assert!(
imex(&context1, ImexMode::ExportBackup, backup_dir.path(), None)
.await
.is_ok()
);
let _event = context1
.evtracker
.get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
.await;
let backup = has_backup(&context2, backup_dir.path()).await?;
let modified_backup = backup_dir.path().join("modified_backup.tar");
tcm.section("Change backup_version to be higher than DCBACKUP_VERSION");
{
let unpack_dir = tempfile::tempdir().unwrap();
let mut ar = Archive::new(File::open(&backup).await?);
ar.unpack(&unpack_dir).await?;
let sql = sql::Sql::new(unpack_dir.path().join(DBFILE_BACKUP_NAME));
sql.open(&context2, "".to_string()).await?;
assert_eq!(
sql.get_raw_config_int("backup_version").await?.unwrap(),
DCBACKUP_VERSION
);
sql.set_raw_config_int("backup_version", DCBACKUP_VERSION + 1)
.await?;
sql.close().await;
let modified_backup_file = File::create(&modified_backup).await?;
let mut builder = tokio_tar::Builder::new(modified_backup_file);
builder.append_dir_all("", unpack_dir.path()).await?;
builder.finish().await?;
}
tcm.section("import to context2");
let err = imex(&context2, ImexMode::ImportBackup, &modified_backup, None)
.await
.unwrap_err();
assert!(err.to_string().starts_with("This profile is from a newer version of Delta Chat. Please update Delta Chat and try again"));
// Some UIs show the error from the event to the user.
// Therefore, it must also be a user-facing string, rather than some technical info:
let err_event = context2
.evtracker
.get_matching(|evt| matches!(evt, EventType::Error(_)))
.await;
let EventType::Error(err_msg) = err_event else {
unreachable!()
};
assert!(err_msg.starts_with("This profile is from a newer version of Delta Chat. Please update Delta Chat and try again"));
context2
.evtracker
.get_matching(|evt| matches!(evt, EventType::ImexProgress(0)))
.await;
assert!(!context2.is_configured().await?);
assert_eq!(context2.get_config(Config::ConfiguredAddr).await?, None);
Ok(())
}
/// This is a regression test for
/// https://github.com/deltachat/deltachat-android/issues/2263
/// where the config cache wasn't reset properly after a backup.

View File

@@ -1,362 +0,0 @@
//! # Key transfer via Autocrypt Setup Message.
use std::io::BufReader;
use anyhow::{Result, bail, ensure};
use crate::blob::BlobObject;
use crate::chat::{self, ChatId};
use crate::config::Config;
use crate::constants::{ASM_BODY, ASM_SUBJECT};
use crate::contact::ContactId;
use crate::context::Context;
use crate::imex::set_self_key;
use crate::key::{DcKey, load_self_secret_key};
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::SystemMessage;
use crate::param::Param;
use crate::pgp;
use crate::tools::open_file_std;
/// Initiates key transfer via Autocrypt Setup Message.
///
/// Returns setup code.
pub async fn initiate_key_transfer(context: &Context) -> Result<String> {
let setup_code = create_setup_code(context);
/* this may require a keypair to be created. this may take a second ... */
let setup_file_content = render_setup_file(context, &setup_code).await?;
/* encrypting may also take a while ... */
let setup_file_blob = BlobObject::create_and_deduplicate_from_bytes(
context,
setup_file_content.as_bytes(),
"autocrypt-setup-message.html",
)?;
let chat_id = ChatId::create_for_contact(context, ContactId::SELF).await?;
let mut msg = Message::new(Viewtype::File);
msg.param.set(Param::File, setup_file_blob.as_name());
msg.param
.set(Param::Filename, "autocrypt-setup-message.html");
msg.subject = ASM_SUBJECT.to_owned();
msg.param
.set(Param::MimeType, "application/autocrypt-setup");
msg.param.set_cmd(SystemMessage::AutocryptSetupMessage);
msg.force_plaintext();
msg.param.set_int(Param::SkipAutocrypt, 1);
// Enable BCC-self, because transferring a key
// means we have a multi-device setup.
context.set_config_bool(Config::BccSelf, true).await?;
chat::send_msg(context, chat_id, &mut msg).await?;
Ok(setup_code)
}
/// Continue key transfer via Autocrypt Setup Message.
///
/// `msg_id` is the ID of the received Autocrypt Setup Message.
/// `setup_code` is the code entered by the user.
pub async fn continue_key_transfer(
context: &Context,
msg_id: MsgId,
setup_code: &str,
) -> Result<()> {
ensure!(!msg_id.is_special(), "wrong id");
let msg = Message::load_from_db(context, msg_id).await?;
ensure!(
msg.is_setupmessage(),
"Message is no Autocrypt Setup Message."
);
if let Some(filename) = msg.get_file(context) {
let file = open_file_std(context, filename)?;
let sc = normalize_setup_code(setup_code);
let armored_key = decrypt_setup_file(&sc, BufReader::new(file)).await?;
set_self_key(context, &armored_key).await?;
context.set_config_bool(Config::BccSelf, true).await?;
Ok(())
} else {
bail!("Message is no Autocrypt Setup Message.");
}
}
/// Renders HTML body of a setup file message.
///
/// The `passphrase` must be at least 2 characters long.
pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result<String> {
let passphrase_begin = if let Some(passphrase_begin) = passphrase.get(..2) {
passphrase_begin
} else {
bail!("Passphrase must be at least 2 chars long.");
};
let private_key = load_self_secret_key(context).await?;
let ac_headers = Some(("Autocrypt-Prefer-Encrypt", "mutual"));
let private_key_asc = private_key.to_asc(ac_headers);
let encr = pgp::symm_encrypt_autocrypt_setup(passphrase, private_key_asc.into_bytes())
.await?
.replace('\n', "\r\n");
let replacement = format!(
concat!(
"-----BEGIN PGP MESSAGE-----\r\n",
"Passphrase-Format: numeric9x4\r\n",
"Passphrase-Begin: {}"
),
passphrase_begin
);
let pgp_msg = encr.replace("-----BEGIN PGP MESSAGE-----", &replacement);
let msg_subj = ASM_SUBJECT;
let msg_body = ASM_BODY.to_string();
let msg_body_html = msg_body.replace('\r', "").replace('\n', "<br>");
Ok(format!(
concat!(
"<!DOCTYPE html>\r\n",
"<html>\r\n",
" <head>\r\n",
" <title>{}</title>\r\n",
" </head>\r\n",
" <body>\r\n",
" <h1>{}</h1>\r\n",
" <p>{}</p>\r\n",
" <pre>\r\n{}\r\n</pre>\r\n",
" </body>\r\n",
"</html>\r\n"
),
msg_subj, msg_subj, msg_body_html, pgp_msg
))
}
/// Creates a new setup code for Autocrypt Setup Message.
fn create_setup_code(_context: &Context) -> String {
let mut random_val: u16;
let mut ret = String::new();
for i in 0..9 {
loop {
random_val = rand::random();
if random_val as usize <= 60000 {
break;
}
}
random_val = (random_val as usize % 10000) as u16;
ret += &format!(
"{}{:04}",
if 0 != i { "-" } else { "" },
random_val as usize
);
}
ret
}
async fn decrypt_setup_file<T: std::fmt::Debug + std::io::BufRead + Send + 'static>(
passphrase: &str,
file: T,
) -> Result<String> {
let plain_bytes = pgp::symm_decrypt(passphrase, file).await?;
let plain_text = std::string::String::from_utf8(plain_bytes)?;
Ok(plain_text)
}
fn normalize_setup_code(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
if c.is_ascii_digit() {
out.push(c);
if let 4 | 9 | 14 | 19 | 24 | 29 | 34 | 39 = out.len() {
out += "-"
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pgp::{HEADER_AUTOCRYPT, HEADER_SETUPCODE, split_armored_data};
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager};
use ::pgp::armor::BlockType;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_render_setup_file() {
let t = TestContext::new_alice().await;
let msg = render_setup_file(&t, "hello").await.unwrap();
println!("{}", &msg);
// Check some substrings, indicating things got substituted.
assert!(msg.contains("<title>Autocrypt Setup Message</title"));
assert!(msg.contains("<h1>Autocrypt Setup Message</h1>"));
assert!(msg.contains("<p>This is the Autocrypt Setup Message used to"));
assert!(msg.contains("-----BEGIN PGP MESSAGE-----\r\n"));
assert!(msg.contains("Passphrase-Format: numeric9x4\r\n"));
assert!(msg.contains("Passphrase-Begin: he\r\n"));
assert!(msg.contains("-----END PGP MESSAGE-----\r\n"));
for line in msg.rsplit_terminator('\n') {
assert!(line.ends_with('\r'));
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_render_setup_file_newline_replace() {
let t = TestContext::new_alice().await;
let msg = render_setup_file(&t, "pw").await.unwrap();
println!("{}", &msg);
assert!(msg.contains("<p>This is the Autocrypt Setup Message used to transfer your end-to-end setup between clients.<br>"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_setup_code() {
let t = TestContext::new().await;
let setupcode = create_setup_code(&t);
assert_eq!(setupcode.len(), 44);
assert_eq!(setupcode.chars().nth(4).unwrap(), '-');
assert_eq!(setupcode.chars().nth(9).unwrap(), '-');
assert_eq!(setupcode.chars().nth(14).unwrap(), '-');
assert_eq!(setupcode.chars().nth(19).unwrap(), '-');
assert_eq!(setupcode.chars().nth(24).unwrap(), '-');
assert_eq!(setupcode.chars().nth(29).unwrap(), '-');
assert_eq!(setupcode.chars().nth(34).unwrap(), '-');
assert_eq!(setupcode.chars().nth(39).unwrap(), '-');
}
#[test]
fn test_normalize_setup_code() {
let norm = normalize_setup_code("123422343234423452346234723482349234");
assert_eq!(norm, "1234-2234-3234-4234-5234-6234-7234-8234-9234");
let norm =
normalize_setup_code("\t1 2 3422343234- foo bar-- 423-45 2 34 6234723482349234 ");
assert_eq!(norm, "1234-2234-3234-4234-5234-6234-7234-8234-9234");
}
/* S_EM_SETUPFILE is a AES-256 symm. encrypted setup message created by Enigmail
with an "encrypted session key", see RFC 4880. The code is in S_EM_SETUPCODE */
const S_EM_SETUPCODE: &str = "1742-0185-6197-1303-7016-8412-3581-4441-0597";
const S_EM_SETUPFILE: &str = include_str!("../../test-data/message/stress.txt");
// Autocrypt Setup Message payload "encrypted" with plaintext algorithm.
const S_PLAINTEXT_SETUPFILE: &str =
include_str!("../../test-data/message/plaintext-autocrypt-setup.txt");
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_split_and_decrypt() {
let buf_1 = S_EM_SETUPFILE.as_bytes().to_vec();
let (typ, headers, base64) = split_armored_data(&buf_1).unwrap();
assert_eq!(typ, BlockType::Message);
assert!(S_EM_SETUPCODE.starts_with(headers.get(HEADER_SETUPCODE).unwrap()));
assert!(!headers.contains_key(HEADER_AUTOCRYPT));
assert!(!base64.is_empty());
let setup_file = S_EM_SETUPFILE;
let decrypted = decrypt_setup_file(S_EM_SETUPCODE, setup_file.as_bytes())
.await
.unwrap();
let (typ, headers, _base64) = split_armored_data(decrypted.as_bytes()).unwrap();
assert_eq!(typ, BlockType::PrivateKey);
assert_eq!(headers.get(HEADER_AUTOCRYPT), Some(&"mutual".to_string()));
assert!(!headers.contains_key(HEADER_SETUPCODE));
}
/// Tests that Autocrypt Setup Message encrypted with "plaintext" algorithm cannot be
/// decrypted.
///
/// According to <https://datatracker.ietf.org/doc/html/rfc4880#section-13.4>
/// "Implementations MUST NOT use plaintext in Symmetrically Encrypted Data packets".
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decrypt_plaintext_autocrypt_setup_message() {
let setup_file = S_PLAINTEXT_SETUPFILE;
let incorrect_setupcode = "0000-0000-0000-0000-0000-0000-0000-0000-0000";
assert!(
decrypt_setup_file(incorrect_setupcode, setup_file.as_bytes(),)
.await
.is_err()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_key_transfer() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
tcm.section("Alice sends Autocrypt setup message");
alice.set_config(Config::BccSelf, Some("0")).await?;
let setup_code = initiate_key_transfer(alice).await?;
// Test that sending Autocrypt Setup Message enables `bcc_self`.
assert_eq!(alice.get_config_bool(Config::BccSelf).await?, true);
// Get Autocrypt Setup Message.
let sent = alice.pop_sent_msg().await;
tcm.section("Alice sets up a second device");
let alice2 = &tcm.unconfigured().await;
alice2.set_name("alice2");
alice2.configure_addr("alice@example.org").await;
alice2.recv_msg(&sent).await;
let msg = alice2.get_last_msg().await;
assert!(msg.is_setupmessage());
assert_eq!(crate::key::load_self_secret_keyring(alice2).await?.len(), 0);
// Transfer the key.
tcm.section("Alice imports a key from Autocrypt Setup Message");
alice2.set_config(Config::BccSelf, Some("0")).await?;
continue_key_transfer(alice2, msg.id, &setup_code).await?;
assert_eq!(alice2.get_config_bool(Config::BccSelf).await?, true);
assert_eq!(crate::key::load_self_secret_keyring(alice2).await?.len(), 1);
// Alice sends a message to self from the new device.
let sent = alice2.send_text(msg.chat_id, "Test").await;
let rcvd_msg = alice.recv_msg(&sent).await;
assert_eq!(rcvd_msg.get_text(), "Test");
Ok(())
}
/// Tests that Autocrypt Setup Messages is only clickable if it is self-sent.
/// This prevents Bob from tricking Alice into changing the key
/// by sending her an Autocrypt Setup Message as long as Alice's server
/// does not allow to forge the `From:` header.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_key_transfer_non_self_sent() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let _setup_code = initiate_key_transfer(&alice).await?;
// Get Autocrypt Setup Message.
let sent = alice.pop_sent_msg().await;
let rcvd = bob.recv_msg(&sent).await;
assert!(!rcvd.is_setupmessage());
Ok(())
}
/// Tests reception of Autocrypt Setup Message from K-9 6.802.
///
/// Unlike Autocrypt Setup Message sent by Delta Chat,
/// this message does not contain `Autocrypt-Prefer-Encrypt` header.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_key_transfer_k_9() -> Result<()> {
let t = &TestContext::new().await;
t.configure_addr("autocrypt@nine.testrun.org").await;
let raw = include_bytes!("../../test-data/message/k-9-autocrypt-setup-message.eml");
let received = receive_imf(t, raw, false).await?.unwrap();
let setup_code = "0655-9868-8252-5455-4232-5158-1237-5333-2638";
continue_key_transfer(t, *received.msg_ids.last().unwrap(), setup_code).await?;
Ok(())
}
}

View File

@@ -189,10 +189,11 @@ impl BackupProvider {
let blobdir = BlobDirContents::new(&context).await?;
let mut file_size = 0;
file_size += dbfile.metadata()?.len();
let mut file_size = dbfile.metadata()?.len();
for blob in blobdir.iter() {
file_size += blob.to_abs_path().metadata()?.len()
file_size = file_size
.checked_add(blob.to_abs_path().metadata()?.len())
.context("File size overflow")?;
}
send_stream.write_all(&file_size.to_be_bytes()).await?;
@@ -466,6 +467,32 @@ mod tests {
}
}
/// Tests that trying to accidentally overwrite a profile
/// that is in use will fail.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cant_overwrite_profile_in_use() -> Result<()> {
let mut tcm = TestContextManager::new();
let ctx0 = &tcm.alice().await;
let ctx1 = &tcm.bob().await;
// Prepare to transfer backup.
let provider = BackupProvider::prepare(ctx0).await?;
// Try to overwrite an existing profile.
let err = get_backup(ctx1, provider.qr()).await.unwrap_err();
assert!(format!("{err:#}").contains("Cannot import backups to accounts in use"));
// ctx0 is supposed to also finish, and emit an error:
provider.await.unwrap();
ctx0.evtracker
.get_matching(|e| matches!(e, EventType::Error(_)))
.await;
assert_eq!(ctx1.get_primary_self_addr().await?, "bob@example.net");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_drop_provider() {
let mut tcm = TestContextManager::new();

View File

@@ -10,13 +10,12 @@ use crate::key;
use crate::key::DcKey;
use crate::mimeparser::MimeMessage;
use crate::pgp;
use crate::pgp::KeyPair;
pub fn key_from_asc(data: &str) -> Result<key::SignedSecretKey> {
key::SignedSecretKey::from_asc(data)
}
pub async fn store_self_keypair(context: &Context, keypair: &KeyPair) -> Result<()> {
pub async fn store_self_keypair(context: &Context, keypair: &key::SignedSecretKey) -> Result<()> {
key::store_self_keypair(context, keypair).await
}
@@ -29,7 +28,7 @@ pub async fn save_broadcast_secret(context: &Context, chat_id: ChatId, secret: &
crate::chat::save_broadcast_secret(context, chat_id, secret).await
}
pub fn create_dummy_keypair(addr: &str) -> Result<KeyPair> {
pub fn create_dummy_keypair(addr: &str) -> Result<key::SignedSecretKey> {
pgp::create_keypair(EmailAddress::new(addr)?)
}

View File

@@ -7,16 +7,23 @@ use std::io::Cursor;
use anyhow::{Context as _, Result, bail, ensure};
use base64::Engine as _;
use deltachat_contact_tools::EmailAddress;
use pgp::composed::Deserializable;
use pgp::composed::{Deserializable, SignedKeyDetails};
pub use pgp::composed::{SignedPublicKey, SignedSecretKey};
use pgp::crypto::aead::AeadAlgorithm;
use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::packet::{
Features, KeyFlags, Notation, PacketTrait as _, SignatureConfig, SignatureType, Subpacket,
SubpacketData,
};
use pgp::ser::Serialize;
use pgp::types::{KeyDetails, KeyId, Password};
use pgp::types::{CompressionAlgorithm, KeyDetails, KeyVersion};
use rand_old::thread_rng;
use tokio::runtime::Handle;
use crate::context::Context;
use crate::events::EventType;
use crate::log::LogExt;
use crate::pgp::KeyPair;
use crate::tools::{self, time_elapsed};
/// Convenience trait for working with keys.
@@ -113,19 +120,155 @@ pub trait DcKey: Serialize + Deserializable + Clone {
/// Whether the key is private (or public).
fn is_private() -> bool;
}
/// Returns the OpenPGP Key ID.
fn key_id(&self) -> KeyId;
/// Converts secret key to public key.
pub(crate) fn secret_key_to_public_key(
context: &Context,
mut signed_secret_key: SignedSecretKey,
timestamp: u32,
addr: &str,
relay_addrs: &str,
) -> Result<SignedPublicKey> {
info!(context, "Converting secret key to public key.");
let timestamp = pgp::types::Timestamp::from_secs(timestamp);
// Subpackets that we want to share between DKS and User ID signature.
let common_subpackets = || -> Result<Vec<Subpacket>> {
let keyflags = {
let mut keyflags = KeyFlags::default();
keyflags.set_certify(true);
keyflags.set_sign(true);
keyflags
};
let features = {
let mut features = Features::default();
features.set_seipd_v1(true);
features.set_seipd_v2(true);
features
};
Ok(vec![
Subpacket::regular(SubpacketData::SignatureCreationTime(timestamp))?,
Subpacket::regular(SubpacketData::IssuerFingerprint(
signed_secret_key.fingerprint(),
))?,
Subpacket::regular(SubpacketData::KeyFlags(keyflags))?,
Subpacket::regular(SubpacketData::Features(features))?,
Subpacket::regular(SubpacketData::PreferredSymmetricAlgorithms(smallvec![
SymmetricKeyAlgorithm::AES256,
SymmetricKeyAlgorithm::AES192,
SymmetricKeyAlgorithm::AES128
]))?,
Subpacket::regular(SubpacketData::PreferredHashAlgorithms(smallvec![
HashAlgorithm::Sha256,
HashAlgorithm::Sha384,
HashAlgorithm::Sha512,
HashAlgorithm::Sha224,
]))?,
Subpacket::regular(SubpacketData::PreferredCompressionAlgorithms(smallvec![
CompressionAlgorithm::ZLIB,
CompressionAlgorithm::ZIP,
]))?,
Subpacket::regular(SubpacketData::PreferredAeadAlgorithms(smallvec![(
SymmetricKeyAlgorithm::AES256,
AeadAlgorithm::Ocb
)]))?,
Subpacket::regular(SubpacketData::IsPrimary(true))?,
])
};
// RFC 4880 required that Transferrable Public Key (aka OpenPGP Certificate)
// contains at least one User ID:
// <https://www.rfc-editor.org/rfc/rfc4880#section-11.1>
// RFC 9580 does not require User ID even for V4 certificates anymore:
// <https://www.rfc-editor.org/rfc/rfc9580.html#name-openpgp-version-4-certifica>
//
// We do not use and do not expect User ID in any keys,
// but nevertheless include User ID in V4 keys for compatibility with clients that follow RFC 4880.
// RFC 9580 also recommends including User ID into V4 keys:
// <https://www.rfc-editor.org/rfc/rfc9580.html#section-5.2.3.10-8>
//
// We do not support keys older than V4 and are not going
// to include User ID in newer V6 keys as all clients that support V6
// should support keys without User ID.
let users = if signed_secret_key.version() == KeyVersion::V4 {
let user_id = format!("<{addr}>");
let mut rng = thread_rng();
// Self-signature is a "positive certification",
// see <https://www.ietf.org/archive/id/draft-gallagher-openpgp-signatures-02.html#name-certification-signature-typ>.
let mut user_id_signature_config = SignatureConfig::from_key(
&mut rng,
&signed_secret_key.primary_key,
SignatureType::CertPositive,
)?;
user_id_signature_config.hashed_subpackets = common_subpackets()?;
user_id_signature_config.unhashed_subpackets = vec![Subpacket::regular(
SubpacketData::IssuerKeyId(signed_secret_key.legacy_key_id()),
)?];
let user_id_packet =
pgp::packet::UserId::from_str(pgp::types::PacketHeaderVersion::New, &user_id)?;
let signature = user_id_signature_config.sign_certification(
&signed_secret_key.primary_key,
&signed_secret_key.primary_key.public_key(),
&pgp::types::Password::empty(),
user_id_packet.tag(),
&user_id_packet,
)?;
vec![user_id_packet.into_signed(signature)]
} else {
vec![]
};
let direct_signatures = {
let mut rng = thread_rng();
let mut direct_key_signature_config = SignatureConfig::from_key(
&mut rng,
&signed_secret_key.primary_key,
SignatureType::Key,
)?;
direct_key_signature_config.hashed_subpackets = common_subpackets()?;
let notation = Notation {
readable: true,
name: "relays@chatmail.at".into(),
value: relay_addrs.to_string().into(),
};
direct_key_signature_config
.hashed_subpackets
.push(Subpacket::regular(SubpacketData::Notation(notation))?);
let direct_key_signature = direct_key_signature_config.sign_key(
&signed_secret_key.primary_key,
&pgp::types::Password::empty(),
signed_secret_key.primary_key.public_key(),
)?;
vec![direct_key_signature]
};
signed_secret_key.details = SignedKeyDetails {
revocation_signatures: vec![],
direct_signatures,
users,
user_attributes: vec![],
};
Ok(signed_secret_key.to_public_key())
}
/// Attempts to load own public key.
///
/// Returns `None` if no key is generated yet.
/// Returns `None` if no secret key is generated yet.
pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option<SignedPublicKey>> {
let Some(public_key_bytes) = context
let mut lock = context.self_public_key.lock().await;
if let Some(ref public_key) = *lock {
return Ok(Some(public_key.clone()));
}
let Some(secret_key_bytes) = context
.sql
.query_row_optional(
"SELECT public_key
"SELECT private_key
FROM keypairs
WHERE id=(SELECT value FROM config WHERE keyname='key_id')",
(),
@@ -138,8 +281,27 @@ pub(crate) async fn load_self_public_key_opt(context: &Context) -> Result<Option
else {
return Ok(None);
};
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
Ok(Some(public_key))
let signed_secret_key = SignedSecretKey::from_slice(&secret_key_bytes)?;
let timestamp = context
.sql
.query_get_value::<u32>(
"SELECT MAX(timestamp)
FROM (SELECT add_timestamp AS timestamp
FROM transports
UNION ALL
SELECT remove_timestamp AS timestamp
FROM removed_transports)",
(),
)
.await?
.context("No transports configured")?;
let addr = context.get_primary_self_addr().await?;
let all_addrs = context.get_published_self_addrs().await?.join(",");
let signed_public_key =
secret_key_to_public_key(context, signed_secret_key, timestamp, &addr, &all_addrs)?;
*lock = Some(signed_public_key.clone());
Ok(Some(signed_public_key))
}
/// Loads own public key.
@@ -149,31 +311,24 @@ pub(crate) async fn load_self_public_key(context: &Context) -> Result<SignedPubl
match load_self_public_key_opt(context).await? {
Some(public_key) => Ok(public_key),
None => {
let keypair = generate_keypair(context).await?;
Ok(keypair.public)
generate_keypair(context).await?;
let public_key = load_self_public_key_opt(context)
.await?
.context("Secret key generated, but public key cannot be created")?;
Ok(public_key)
}
}
}
/// Returns our own public keyring.
///
/// No keys are generated and at most one key is returned.
pub(crate) async fn load_self_public_keyring(context: &Context) -> Result<Vec<SignedPublicKey>> {
let keys = context
.sql
.query_map_vec(
r#"SELECT public_key
FROM keypairs
ORDER BY id=(SELECT value FROM config WHERE keyname='key_id') DESC"#,
(),
|row| {
let public_key_bytes: Vec<u8> = row.get(0)?;
Ok(public_key_bytes)
},
)
.await?
.into_iter()
.filter_map(|bytes| SignedPublicKey::from_slice(&bytes).log_err(context).ok())
.collect();
Ok(keys)
if let Some(public_key) = load_self_public_key_opt(context).await? {
Ok(vec![public_key])
} else {
Ok(vec![])
}
}
/// Returns own public key fingerprint in (not human-readable) hex representation.
@@ -225,8 +380,8 @@ pub(crate) async fn load_self_secret_key(context: &Context) -> Result<SignedSecr
match private_key {
Some(bytes) => SignedSecretKey::from_slice(&bytes),
None => {
let keypair = generate_keypair(context).await?;
Ok(keypair.secret)
let secret = generate_keypair(context).await?;
Ok(secret)
}
}
}
@@ -272,10 +427,6 @@ impl DcKey for SignedPublicKey {
fn dc_fingerprint(&self) -> Fingerprint {
self.fingerprint().into()
}
fn key_id(&self) -> KeyId {
KeyDetails::key_id(self)
}
}
impl DcKey for SignedSecretKey {
@@ -299,39 +450,12 @@ impl DcKey for SignedSecretKey {
fn dc_fingerprint(&self) -> Fingerprint {
self.fingerprint().into()
}
fn key_id(&self) -> KeyId {
KeyDetails::key_id(&**self)
}
}
/// Deltachat extension trait for secret keys.
///
/// Provides some convenience wrappers only applicable to [SignedSecretKey].
pub(crate) trait DcSecretKey {
/// Create a public key from a private one.
fn split_public_key(&self) -> Result<SignedPublicKey>;
}
impl DcSecretKey for SignedSecretKey {
fn split_public_key(&self) -> Result<SignedPublicKey> {
self.verify()?;
let unsigned_pubkey = self.public_key();
let mut rng = rand_old::thread_rng();
let signed_pubkey = unsigned_pubkey.sign(
&mut rng,
&self.primary_key,
self.primary_key.public_key(),
&Password::empty(),
)?;
Ok(signed_pubkey)
}
}
async fn generate_keypair(context: &Context) -> Result<KeyPair> {
async fn generate_keypair(context: &Context) -> Result<SignedSecretKey> {
let addr = context.get_primary_self_addr().await?;
let addr = EmailAddress::new(&addr)?;
let _guard = context.generating_key_mutex.lock().await;
let _public_key_guard = context.self_public_key.lock().await;
// Check if the key appeared while we were waiting on the lock.
match load_keypair(context).await? {
@@ -354,51 +478,52 @@ async fn generate_keypair(context: &Context) -> Result<KeyPair> {
}
}
pub(crate) async fn load_keypair(context: &Context) -> Result<Option<KeyPair>> {
pub(crate) async fn load_keypair(context: &Context) -> Result<Option<SignedSecretKey>> {
let res = context
.sql
.query_row_optional(
"SELECT public_key, private_key
FROM keypairs
WHERE id=(SELECT value FROM config WHERE keyname='key_id')",
"SELECT private_key
FROM keypairs
WHERE id=(SELECT value FROM config WHERE keyname='key_id')",
(),
|row| {
let pub_bytes: Vec<u8> = row.get(0)?;
let sec_bytes: Vec<u8> = row.get(1)?;
Ok((pub_bytes, sec_bytes))
let sec_bytes: Vec<u8> = row.get(0)?;
Ok(sec_bytes)
},
)
.await?;
Ok(if let Some((pub_bytes, sec_bytes)) = res {
Some(KeyPair {
public: SignedPublicKey::from_slice(&pub_bytes)?,
secret: SignedSecretKey::from_slice(&sec_bytes)?,
})
let signed_secret_key = if let Some(sec_bytes) = res {
Some(SignedSecretKey::from_slice(&sec_bytes)?)
} else {
None
})
};
Ok(signed_secret_key)
}
/// Store the keypair as an owned keypair for addr in the database.
/// Stores own keypair in the database and sets it as a default.
///
/// This will save the keypair as keys for the given address. The
/// "self" here refers to the fact that this DC instance owns the
/// keypair. Usually `addr` will be [Config::ConfiguredAddr].
///
/// If either the public or private keys are already present in the
/// database, this entry will be removed first regardless of the
/// address associated with it. Practically this means saving the
/// same key again overwrites it.
///
/// [Config::ConfiguredAddr]: crate::config::Config::ConfiguredAddr
pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) -> Result<()> {
/// Fails if we already have a key, so it is not possible to
/// have more than one key for new setups. Existing setups
/// may still have more than one key for compatibility.
pub(crate) async fn store_self_keypair(
context: &Context,
signed_secret_key: &SignedSecretKey,
) -> Result<()> {
// This public key is stored in the database
// only for backwards compatibility.
//
// It should not be used e.g. in Autocrypt headers or vCards.
// Use `secret_key_to_public_key()` function instead,
// which adds relay list to the signature.
let signed_public_key = signed_secret_key.to_public_key();
let mut config_cache_lock = context.sql.config_cache.write().await;
let new_key_id = context
.sql
.transaction(|transaction| {
let public_key = DcKey::to_bytes(&keypair.public);
let secret_key = DcKey::to_bytes(&keypair.secret);
let public_key = DcKey::to_bytes(&signed_public_key);
let secret_key = DcKey::to_bytes(signed_secret_key);
// private_key and public_key columns
// are UNIQUE since migration 107,
@@ -436,9 +561,7 @@ pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) ->
/// Use import/export APIs instead.
pub async fn preconfigure_keypair(context: &Context, secret_data: &str) -> Result<()> {
let secret = SignedSecretKey::from_asc(secret_data)?;
let public = secret.split_public_key()?;
let keypair = KeyPair { public, secret };
store_self_keypair(context, &keypair).await?;
store_self_keypair(context, &secret).await?;
Ok(())
}
@@ -517,7 +640,7 @@ mod tests {
use crate::config::Config;
use crate::test_utils::{TestContext, alice_keypair};
static KEYPAIR: LazyLock<KeyPair> = LazyLock::new(alice_keypair);
static KEYPAIR: LazyLock<SignedSecretKey> = LazyLock::new(alice_keypair);
#[test]
fn test_from_armored_string() {
@@ -587,12 +710,12 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
#[test]
fn test_asc_roundtrip() {
let key = KEYPAIR.public.clone();
let key = KEYPAIR.clone().to_public_key();
let asc = key.to_asc(Some(("spam", "ham")));
let key2 = SignedPublicKey::from_asc(&asc).unwrap();
assert_eq!(key, key2);
let key = KEYPAIR.secret.clone();
let key = KEYPAIR.clone();
let asc = key.to_asc(Some(("spam", "ham")));
let key2 = SignedSecretKey::from_asc(&asc).unwrap();
assert_eq!(key, key2);
@@ -600,8 +723,8 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
#[test]
fn test_from_slice_roundtrip() {
let public_key = KEYPAIR.public.clone();
let private_key = KEYPAIR.secret.clone();
let private_key = KEYPAIR.clone();
let public_key = KEYPAIR.clone().to_public_key();
let binary = DcKey::to_bytes(&public_key);
let public_key2 = SignedPublicKey::from_slice(&binary).expect("invalid public key");
@@ -633,8 +756,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
/// this resulted in various number of garbage
/// octets at the end of the key, starting from 3 octets,
/// but possibly 4 or 5 and maybe more octets
/// if the key is imported or transferred
/// using Autocrypt Setup Message multiple times.
/// if the key is imported multiple times.
#[test]
fn test_ignore_trailing_garbage() {
// Test several variants of garbage.
@@ -643,7 +765,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
b"\x02\xfc\xaa".as_slice(),
b"\x01\x02\x03\x04\x05".as_slice(),
] {
let private_key = KEYPAIR.secret.clone();
let private_key = KEYPAIR.clone();
let mut binary = DcKey::to_bytes(&private_key);
binary.extend(garbage);
@@ -657,7 +779,7 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
#[test]
fn test_base64_roundtrip() {
let key = KEYPAIR.public.clone();
let key = KEYPAIR.clone().to_public_key();
let base64 = key.to_base64();
let key2 = SignedPublicKey::from_base64(&base64).unwrap();
assert_eq!(key, key2);
@@ -712,12 +834,6 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
assert_eq!(res0.unwrap(), res1.unwrap());
}
#[test]
fn test_split_key() {
let pubkey = KEYPAIR.secret.split_public_key().unwrap();
assert_eq!(pubkey.primary_key, KEYPAIR.public.primary_key);
}
/// Tests that setting a default key second time is not allowed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_save_self_key_twice() {

Some files were not shown because too many files have changed in this diff Show More