Compare commits

...

119 Commits

Author SHA1 Message Date
link2xt
1b42e74b52 feat: remove MvboxMove and OnlyFetchMvbox 2026-03-16 17:08:01 +00:00
link2xt
d7b3a85127 feat: do not unconditionally watch mvbox for non-chatmail
Since commit 25750de4e1
released in 2.36.0 we do not move messages to mvbox without explicit
mvbox_move setting, so do not need to watch it as well
as long as other devices are updated to the same change.
2026-03-16 16:52:22 +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
143 changed files with 6079 additions and 3824 deletions

View File

@@ -20,7 +20,7 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.93.0
RUST_VERSION: 1.94.0
# Minimum Supported Rust Version
MSRV: 1.88.0

View File

@@ -34,7 +34,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: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
@@ -58,7 +58,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: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
@@ -82,7 +82,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: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
@@ -106,7 +106,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: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel
@@ -157,7 +157,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: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
@@ -181,7 +181,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: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel
@@ -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

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

@@ -18,7 +18,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: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary

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@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0

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,211 @@
# Changelog
## [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
@@ -103,7 +309,7 @@
- [**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 notices, including muted ones. ([#7709](https://github.com/chatmail/core/pull/7709)).
- 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
@@ -335,7 +541,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
@@ -7696,3 +7902,8 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[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

330
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.40.0"
version = "2.46.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,10 +78,10 @@ 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 }
@@ -96,8 +96,8 @@ 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 }
@@ -186,7 +186,7 @@ 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,12 +194,12 @@ 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.18"

View File

@@ -22,6 +22,44 @@ For example, to release version 1.116.0 of the core, do the following steps.
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,

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.40.0"
version = "2.46.0-dev"
description = "Deltachat FFI"
edition = "2018"
readme = "README.md"

View File

@@ -430,14 +430,6 @@ char* dc_get_blobdir (const dc_context_t* context);
* 1=send a copy of outgoing messages to self (default).
* Sending messages to self is needed for a proper multi-account setup,
* however, on the other hand, may lead to unwanted notifications in non-delta clients.
* - `mvbox_move` = 1=detect chat messages,
* move them to the `DeltaChat` folder,
* and watch the `DeltaChat` folder for updates (default),
* 0=do not move chat-messages
* - `only_fetch_mvbox` = 1=Do not fetch messages from folders other than the
* `DeltaChat` folder. Messages will still be fetched from the
* spam folder.
* 0=watch all folders normally (default)
* - `show_emails` = DC_SHOW_EMAILS_OFF (0)=
* show direct replies to chats only,
* DC_SHOW_EMAILS_ACCEPTED_CONTACTS (1)=
@@ -1854,15 +1846,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
@@ -1889,10 +1882,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.
*
@@ -1900,7 +1894,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
@@ -4610,6 +4604,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.
@@ -4665,6 +4660,7 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
#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
/**
@@ -6751,6 +6747,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
@@ -7492,7 +7489,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."
@@ -7544,12 +7541,6 @@ void dc_event_unref(dc_event_t* event);
/// "❤️ 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
@@ -7581,6 +7572,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.
@@ -7601,6 +7605,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)]
@@ -5150,10 +5153,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.40.0"
version = "2.46.0-dev"
description = "DeltaChat JSON-RPC API"
edition = "2021"
license = "MPL-2.0"

View File

@@ -31,7 +31,7 @@ use deltachat::peer_channels::{
};
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;
@@ -194,6 +194,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
// ---------------------------------------------
@@ -854,6 +864,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 +1080,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 +1089,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.
///
@@ -1940,6 +1982,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?;
@@ -1953,6 +1997,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
@@ -2466,7 +2515,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

@@ -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

@@ -23,6 +23,12 @@ pub struct EnteredLoginParam {
/// Imap server port.
pub imap_port: Option<u16>,
/// IMAP server folder.
///
/// Defaults to "INBOX" if not set.
/// Should not be an empty string.
pub imap_folder: Option<String>,
/// Imap socket security.
pub imap_security: Option<Socket>,
@@ -66,6 +72,7 @@ impl From<dc::EnteredLoginParam> for EnteredLoginParam {
password: param.imap.password,
imap_server: param.imap.server.into_option(),
imap_port: param.imap.port.into_option(),
imap_folder: param.imap.folder.into_option(),
imap_security: imap_security.into_option(),
imap_user: param.imap.user.into_option(),
smtp_server: param.smtp.server.into_option(),
@@ -85,14 +92,15 @@ impl TryFrom<EnteredLoginParam> for dc::EnteredLoginParam {
fn try_from(param: EnteredLoginParam) -> Result<Self> {
Ok(Self {
addr: param.addr,
imap: dc::EnteredServerLoginParam {
imap: dc::EnteredImapLoginParam {
server: param.imap_server.unwrap_or_default(),
port: param.imap_port.unwrap_or_default(),
folder: param.imap_folder.unwrap_or_default(),
security: param.imap_security.unwrap_or_default().into(),
user: param.imap_user.unwrap_or_default(),
password: param.password,
},
smtp: dc::EnteredServerLoginParam {
smtp: dc::EnteredSmtpLoginParam {
server: param.smtp_server.unwrap_or_default(),
port: param.smtp_port.unwrap_or_default(),
security: param.smtp_security.unwrap_or_default().into(),

View File

@@ -388,6 +388,7 @@ impl From<download::DownloadState> for DownloadState {
pub enum SystemMessageType {
Unknown,
GroupNameChanged,
GroupDescriptionChanged,
GroupImageChanged,
MemberAddedToGroup,
MemberRemovedFromGroup,
@@ -440,6 +441,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.40.0"
"version": "2.46.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.40.0"
version = "2.46.0-dev"
license = "MPL-2.0"
edition = "2021"
repository = "https://github.com/chatmail/core"

View File

@@ -343,6 +343,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\
@@ -770,6 +771,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

@@ -179,7 +179,7 @@ const DB_COMMANDS: [&str; 11] = [
"housekeeping",
];
const CHAT_COMMANDS: [&str; 39] = [
const CHAT_COMMANDS: [&str; 40] = [
"listchats",
"listarchived",
"start-realtime",
@@ -192,6 +192,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.40.0"
version = "2.46.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

@@ -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

@@ -2,96 +2,13 @@ import logging
import re
import time
import pytest
from imap_tools import AND, U
from deltachat_rpc_client import Contact, EventType, Message
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()
chat = ac1.create_chat(ac2)
chat.send_text("message1")
# Message is moved to the movebox
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
# Message is downloaded
msg = ac2.wait_for_incoming_msg().get_snapshot()
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_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.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,
"""When a batch of messages is moved from Inbox to another folder with a single MOVE command,
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
messages they refer to and thus dropped.
@@ -101,9 +18,6 @@ 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()
ac2.bring_online()
@@ -119,11 +33,17 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
react_str = "\N{THUMBS UP SIGN}"
msg1.send_reaction(react_str).wait_until_delivered()
logging.info("moving messages to ac2's DeltaChat folder in the reverse order")
logging.info("moving messages to ac2's movebox folder in the reverse order")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("Movebox")
ac2_direct_imap.connect()
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()], reverse=True):
ac2_direct_imap.conn.move(uid, "DeltaChat")
ac2_direct_imap.conn.move(uid, "Movebox")
logging.info("moving messages back")
ac2_direct_imap.select_folder("Movebox")
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()]):
ac2_direct_imap.conn.move(uid, "INBOX")
logging.info("receiving messages by ac2")
ac2.start_io()
@@ -136,173 +56,22 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
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, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
# 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()
chat = ac1.create_chat(ac2)
chat.send_text("message1")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
chat.send_text("message2")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
chat.send_text("message3")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
def test_moved_markseen(acfactory, direct_imap):
def test_moved_markseen(acfactory, direct_imap, log):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac1 = acfactory.get_online_account()
addr, password = acfactory.get_credentials()
ac2 = acfactory.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password})
ac2.bring_online()
log.section("ac2: creating DeltaChat folder")
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.
ac2.add_or_update_transport({"addr": addr, "password": password, "imapFolder": "DeltaChat"})
ac2.bring_online()
ac2.stop_io()
@@ -312,6 +81,7 @@ def test_moved_markseen(acfactory, direct_imap):
idle2.wait_for_new_message()
# Emulate moving of the message to DeltaChat folder by Sieve rule.
log.section("ac2: moving message into DeltaChat folder")
ac2_direct_imap.conn.move(["*"], "DeltaChat")
ac2_direct_imap.select_folder("DeltaChat")
assert len(list(ac2_direct_imap.conn.fetch("*", mark_seen=False))) == 1
@@ -335,17 +105,11 @@ def test_moved_markseen(acfactory, direct_imap):
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True, uid=U(1, "*")), mark_seen=False))) == 1
@pytest.mark.parametrize("mvbox_move", [True, False])
def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
def test_markseen_message_and_mdn(acfactory, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
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()
# Do not send BCC to self, we only want to test MDN on ac1.
ac1.set_config("bcc_self", "0")
@@ -354,10 +118,7 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
msg = ac2.wait_for_incoming_msg()
msg.mark_seen()
if mvbox_move:
rex = re.compile("Marked messages [0-9]+ in folder DeltaChat as seen.")
else:
rex = re.compile("Marked messages [0-9]+ in folder INBOX as seen.")
rex = re.compile("Marked messages [0-9]+ in folder INBOX as seen.")
for ac in ac1, ac2:
while True:
@@ -365,12 +126,11 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
if event.kind == EventType.INFO and rex.search(event.msg):
break
folder = "mvbox" if mvbox_move else "inbox"
ac1_direct_imap = direct_imap(ac1)
ac2_direct_imap = direct_imap(ac2)
ac1_direct_imap.select_config_folder(folder)
ac2_direct_imap.select_config_folder(folder)
ac1_direct_imap.select_folder("INBOX")
ac2_direct_imap.select_folder("INBOX")
# Check that the mdn is marked as seen
assert len(list(ac1_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
@@ -378,77 +138,6 @@ 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
@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()

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,6 +1,7 @@
import pytest
from deltachat_rpc_client import EventType
from deltachat_rpc_client.const import DownloadState
from deltachat_rpc_client.rpc import JsonRpcError
@@ -8,10 +9,6 @@ def test_add_second_address(acfactory) -> None:
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
# When the first transport is created,
# mvbox_move and only_fetch_mvbox should be disabled.
assert account.get_config("mvbox_move") == "0"
assert account.get_config("only_fetch_mvbox") == "0"
assert account.get_config("show_emails") == "2"
qr = acfactory.get_account_qr()
@@ -31,34 +28,12 @@ def test_add_second_address(acfactory) -> None:
account.delete_transport(second_addr)
assert len(account.list_transports()) == 2
# Enabling mvbox_move or only_fetch_mvbox
# is not allowed when multi-transport is enabled.
for option in ["mvbox_move", "only_fetch_mvbox"]:
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"])
def test_no_second_transport_with_mvbox(acfactory, key) -> None:
"""Test that second transport cannot be configured if mvbox is used."""
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
assert account.get_config("mvbox_move") == "0"
assert account.get_config("only_fetch_mvbox") == "0"
qr = acfactory.get_account_qr()
account.set_config(key, "1")
with pytest.raises(JsonRpcError):
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 +42,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,47 +94,50 @@ def test_change_address(acfactory) -> None:
assert sender_addr2 == new_alice_addr
@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.
Disabling mvbox_move is required to be able to setup a second transport.
"""
account = acfactory.get_unconfigured_account()
account.set_config("fix_is_chatmail", "1")
account.set_config("is_chatmail", is_chatmail)
# The default value when the setting is unset is "1".
# This is not changed for compatibility with old databases
# imported from backups.
assert account.get_config("mvbox_move") == "1"
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()
account.add_transport_from_qr(qr)
alice.add_transport_from_qr(qr)
alice.start_io()
# Once the first transport is set up,
# mvbox_move is disabled.
assert account.get_config("mvbox_move") == "0"
assert account.get_config("is_chatmail") == is_chatmail
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
def test_reconfigure_transport(acfactory) -> None:
"""Test that reconfiguring the transport works
even if settings not supported for multi-transport
like mvbox_move are enabled."""
"""Test that reconfiguring the transport works."""
account = acfactory.get_online_account()
account.set_config("mvbox_move", "1")
[transport] = account.list_transports()
account.add_or_update_transport(transport)
# Reconfiguring the transport should not reset
# the settings as if when configuring the first transport.
assert account.get_config("mvbox_move") == "1"
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 +146,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 +162,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 +179,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
@@ -277,11 +261,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)
@@ -289,9 +272,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")
@@ -299,12 +279,51 @@ 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) -> 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)
# Alice changes the 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!"
# Alice deletes the first transport.
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!"

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

@@ -13,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:
@@ -665,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()

View File

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

View File

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

View File

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

View File

@@ -522,7 +522,6 @@ class ACFactory:
ac = self.get_unconfigured_account()
assert "addr" in configdict and "mail_pw" in configdict, configdict
configdict.setdefault("bcc_self", False)
configdict.setdefault("mvbox_move", False)
configdict.setdefault("sync_msgs", False)
configdict.setdefault("delete_server_after", 0)
ac.update_config(configdict)

View File

@@ -932,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

@@ -52,19 +52,19 @@ class TestOfflineAccountBasic:
def test_set_config_int_conversion(self, acfactory):
ac1 = acfactory.get_unconfigured_account()
ac1.set_config("mvbox_move", False)
assert ac1.get_config("mvbox_move") == "0"
ac1.set_config("mvbox_move", True)
assert ac1.get_config("mvbox_move") == "1"
ac1.set_config("mvbox_move", 0)
assert ac1.get_config("mvbox_move") == "0"
ac1.set_config("mvbox_move", 1)
assert ac1.get_config("mvbox_move") == "1"
ac1.set_config("bcc_self", False)
assert ac1.get_config("bcc_self") == "0"
ac1.set_config("bcc_self", True)
assert ac1.get_config("bcc_self") == "1"
ac1.set_config("bcc_self", 0)
assert ac1.get_config("bcc_self") == "0"
ac1.set_config("bcc_self", 1)
assert ac1.get_config("bcc_self") == "1"
def test_update_config(self, acfactory):
ac1 = acfactory.get_unconfigured_account()
ac1.update_config({"mvbox_move": False})
assert ac1.get_config("mvbox_move") == "0"
ac1.update_config({"bcc_self": True})
assert ac1.get_config("bcc_self") == "1"
def test_has_bccself(self, acfactory):
ac1 = acfactory.get_unconfigured_account()
@@ -558,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-02-04
2026-03-14

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.93.0
RUST_VERSION=1.94.0
ARCH="$(uname -m)"
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu

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

@@ -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?);
@@ -752,6 +758,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 +848,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 +920,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

@@ -321,6 +321,7 @@ 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,

View File

@@ -11,10 +11,11 @@ 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};
@@ -78,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)
}
@@ -102,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(())
@@ -170,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),
@@ -200,9 +207,10 @@ 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);
@@ -241,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 {
@@ -257,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(())
@@ -275,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?;
@@ -320,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 {
@@ -348,10 +363,13 @@ 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 can_call_me = match who_can_call_me(self).await? {
WhoCanCallMe::Contacts => ChatIdBlocked::lookup_by_contact(self, from_id)
@@ -391,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 {
@@ -413,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
@@ -441,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 {

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};
@@ -62,7 +63,7 @@ async fn setup_call() -> Result<CallSetup> {
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_eq!(info.has_video_initially(), true);
assert_text(t, m.id, "Outgoing call").await?;
assert_text(t, m.id, "Outgoing video call").await?;
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
}
@@ -84,7 +85,7 @@ async fn setup_call() -> Result<CallSetup> {
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_eq!(info.has_video_initially(), true);
assert_text(t, m.id, "Incoming call").await?;
assert_text(t, m.id, "Incoming video call").await?;
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
}
@@ -115,9 +116,28 @@ async fn accept_call() -> Result<CallSetup> {
// Bob accepts the incoming call
bob.accept_incoming_call(bob_call.id, ACCEPT_INFO.to_string())
.await?;
assert_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
@@ -129,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)
@@ -142,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 { .. }))
@@ -164,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 { .. }))
@@ -200,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;
@@ -214,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;
@@ -225,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 { .. }))
@@ -236,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 { .. }))
@@ -266,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 { .. }))
@@ -278,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 { .. }))
@@ -290,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;
@@ -300,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;
@@ -328,8 +367,18 @@ async fn test_callee_rejects_call() -> Result<()> {
} = setup_call().await?;
// Bob has accepted Alice before, but does not want to talk with Alice
bob_call.chat_id.accept(&bob).await?;
bob.end_call(bob_call.id).await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Declined call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -370,6 +419,35 @@ async fn test_callee_rejects_call() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_callee_sees_contact_request_call() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
alice
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true)
.await?;
let sent1 = alice.pop_sent_msg().await;
let bob_call = bob.recv_msg(&sent1).await;
// Bob can't end_call() because the contact request isn't accepted, but he can mark the call as
// seen.
markseen_msgs(bob, vec![bob_call.id]).await?;
assert_eq!(bob_call.id.get_state(bob).await?, MessageState::InSeen);
// Bob sends an MDN only to self so that an unaccepted contact can't know anything.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, ContactId::SELF)
)
.await?,
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_caller_cancels_call() -> Result<()> {
// Alice calls Bob
@@ -420,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?;

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;
@@ -42,6 +42,7 @@ 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::{self, send_msg_to_smtp};
use crate::stock_str;
@@ -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,
@@ -941,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
@@ -1145,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::messages_e2e_encrypted(context).await + "\n";
let mut ret = stock_str::messages_are_e2ee(context).await + "\n";
for &contact_id in get_chat_contacts(context, self)
.await?
@@ -1168,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");
}
@@ -1730,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,
@@ -1763,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?;
@@ -2807,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?;
}
@@ -2985,6 +2998,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!(
@@ -3090,6 +3104,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,
@@ -3879,18 +3894,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 +3950,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 +3963,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
@@ -4187,7 +4185,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 +4328,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 +4393,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 +4407,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 +4429,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],
@@ -4451,6 +4560,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,
@@ -4543,7 +4653,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;
}
@@ -4629,6 +4738,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>,
@@ -4938,18 +5048,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,
@@ -4966,12 +5076,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(())
}
@@ -5015,6 +5144,7 @@ pub(crate) enum SyncAction {
///
/// The list is a list of pairs of fingerprint and address.
SetPgpContacts(Vec<(String, String)>),
SetDescription(String),
Delete,
}
@@ -5113,6 +5243,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

@@ -2641,7 +2641,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(())
}
@@ -2731,27 +2731,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");
@@ -2992,27 +2989,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);
@@ -3118,7 +3137,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");
@@ -3135,7 +3154,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?;
@@ -3156,6 +3175,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)]
@@ -3186,14 +3400,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;
@@ -3470,16 +3687,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,
@@ -3495,7 +3709,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;
@@ -3525,7 +3739,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;
@@ -3592,7 +3806,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);
@@ -3642,6 +3856,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'", ())
@@ -3652,14 +3867,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(())
}
@@ -3694,7 +3915,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?;
@@ -3708,7 +3929,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();
@@ -3728,6 +3949,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(
@@ -3751,6 +3973,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")
@@ -3824,7 +4047,7 @@ async fn test_chat_get_encryption_info() -> Result<()> {
chat_id.get_encryption_info(alice).await?,
"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"
);
@@ -3834,11 +4057,11 @@ async fn test_chat_get_encryption_info() -> Result<()> {
chat_id.get_encryption_info(alice).await?,
"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"
);
@@ -4510,6 +4733,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;
@@ -4574,7 +4801,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)
@@ -4644,6 +4871,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

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};
@@ -155,18 +155,6 @@ pub enum Config {
#[strum(props(default = "1"))]
MdnsEnabled,
/// True if chat messages should be moved to a separate folder. Auto-sent messages like sync
/// ones are moved there anyway.
#[strum(props(default = "1"))]
MvboxMove,
/// Watch for new messages in the "Mvbox" (aka DeltaChat folder) only.
///
/// This will not entirely disable other folders, e.g. the spam folder will also still
/// be watched for new messages.
#[strum(props(default = "0"))]
OnlyFetchMvbox,
/// Whether to show classic emails or only chat messages.
#[strum(props(default = "2"))] // also change ShowEmails.default() on changes
ShowEmails,
@@ -268,9 +256,6 @@ pub enum Config {
/// Configured folder for incoming messages.
ConfiguredInboxFolder,
/// Configured folder for chat messages.
ConfiguredMvboxFolder,
/// Unix timestamp of the last successful configuration.
ConfiguredTimestamp,
@@ -328,10 +313,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".
@@ -471,7 +452,6 @@ impl Config {
self,
Self::Displayname
| Self::MdnsEnabled
| Self::MvboxMove
| Self::ShowEmails
| Self::Selfavatar
| Self::Selfstatus,
@@ -480,10 +460,7 @@ impl Config {
/// Whether the config option needs an IO scheduler restart to take effect.
pub(crate) fn needs_io_restart(&self) -> bool {
matches!(
self,
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ConfiguredAddr
)
matches!(self, Config::ConfiguredAddr)
}
}
@@ -595,15 +572,7 @@ impl Context {
.get_config(key)
.await?
.and_then(|s| s.parse::<i32>().ok())
.map(|x| x != 0)
.unwrap_or_default())
}
/// Returns true if movebox ("DeltaChat" folder) should be watched.
pub(crate) async fn should_watch_mvbox(&self) -> Result<bool> {
Ok(self.get_config_bool(Config::MvboxMove).await?
|| self.get_config_bool(Config::OnlyFetchMvbox).await?
|| !self.get_config_bool(Config::IsChatmail).await?)
.is_some_and(|x| x != 0))
}
/// Returns true if sync messages should be sent.
@@ -687,8 +656,6 @@ impl Context {
| Config::ProxyEnabled
| Config::BccSelf
| Config::MdnsEnabled
| Config::MvboxMove
| Config::OnlyFetchMvbox
| Config::Configured
| Config::Bot
| Config::NotifyAboutWrongPw
@@ -711,16 +678,6 @@ impl Context {
pub async fn set_config(&self, key: Config, value: Option<&str>) -> Result<()> {
Self::check_config(key, value)?;
let n_transports = self.count_transports().await?;
if n_transports > 1
&& matches!(
key,
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ShowEmails
)
{
bail!("Cannot reconfigure {key} when multiple transports are configured");
}
let _pause = match key.needs_io_restart() {
true => self.scheduler.pause(self).await?,
_ => Default::default(),
@@ -799,12 +756,6 @@ impl Context {
.set_raw_config(key.as_ref(), value.map(|s| s.to_lowercase()).as_deref())
.await?;
}
Config::MvboxMove => {
self.sql.set_raw_config(key.as_ref(), value).await?;
self.sql
.set_raw_config(constants::DC_FOLDERS_CONFIGURED_KEY, None)
.await?;
}
Config::ConfiguredAddr => {
let Some(addr) = value else {
bail!("Cannot unset configured_addr");
@@ -838,6 +789,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=? 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
@@ -954,12 +921,18 @@ 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();
Ok(primary_addrs.chain(secondary_addrs).collect())
self.sql
.query_map_vec(
"SELECT addr FROM transports ORDER BY add_timestamp DESC",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)
.await
}
/// Returns all secondary self addresses.

View File

@@ -196,11 +196,11 @@ async fn test_sync() -> Result<()> {
sync(&alice0, &alice1).await;
assert_eq!(alice1.get_config_bool(Config::MdnsEnabled).await?, false);
for key in [Config::ShowEmails, Config::MvboxMove] {
let val = alice0.get_config_bool(key).await?;
alice0.set_config_bool(key, !val).await?;
{
let val = alice0.get_config_bool(Config::ShowEmails).await?;
alice0.set_config_bool(Config::ShowEmails, !val).await?;
sync(&alice0, &alice1).await;
assert_eq!(alice1.get_config_bool(key).await?, !val);
assert_eq!(alice1.get_config_bool(Config::ShowEmails).await?, !val);
}
// `Config::SyncMsgs` mustn't be synced.

View File

@@ -273,36 +273,16 @@ impl Context {
(&param.addr,),
)
.await?
{
// Should be checked before `MvboxMove` because the latter makes no sense in presense of
// `OnlyFetchMvbox` and even grayed out in the UIs in this case.
if self.get_config(Config::OnlyFetchMvbox).await?.as_deref() != Some("0") {
bail!(
"To use additional relays, disable the legacy option \"Settings / Advanced / Only Fetch from DeltaChat Folder\"."
);
}
if self.get_config(Config::MvboxMove).await?.as_deref() != Some("0") {
bail!(
"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
&& self
.sql
.count("SELECT COUNT(*) FROM transports", ())
.await?
>= MAX_TRANSPORT_RELAYS
{
bail!(
"You have reached the maximum number of relays ({}).",
MAX_TRANSPORT_RELAYS
)
}
{
bail!(
"You have reached the maximum number of relays ({}).",
MAX_TRANSPORT_RELAYS
)
}
let provider = match configure(self, param).await {
@@ -515,6 +495,7 @@ async fn get_configured_param(
.collect(),
imap_user: param.imap.user.clone(),
imap_password: param.imap.password.clone(),
imap_folder: Some(param.imap.folder.clone()).filter(|folder| !folder.is_empty()),
smtp: servers
.iter()
.filter_map(|params| {
@@ -554,9 +535,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 +573,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);
@@ -610,11 +591,17 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
progress!(ctx, 900);
let is_configured = ctx.is_configured().await?;
if !is_configured {
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);
@@ -634,7 +621,9 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
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);
@@ -765,7 +754,7 @@ pub enum Error {
mod tests {
use super::*;
use crate::config::Config;
use crate::login_param::EnteredServerLoginParam;
use crate::login_param::EnteredImapLoginParam;
use crate::test_utils::TestContext;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -784,7 +773,7 @@ mod tests {
let entered_param = EnteredLoginParam {
addr: "alice@example.org".to_string(),
imap: EnteredServerLoginParam {
imap: EnteredImapLoginParam {
user: "alice@example.net".to_string(),
password: "foobar".to_string(),
..Default::default()

View File

@@ -210,11 +210,6 @@ pub const WORSE_IMAGE_SIZE: u32 = 640;
/// usage by UIs.
pub const MAX_RCVD_IMAGE_PIXELS: u32 = 50_000_000;
// Key for the folder configuration version (see below).
pub(crate) const DC_FOLDERS_CONFIGURED_KEY: &str = "folders_configured";
// this value can be increased if the folder configuration is changed and must be redone on next program start
pub(crate) const DC_FOLDERS_CONFIGURED_VERSION: i32 = 5;
// If more recipients are needed in SMTP's `RCPT TO:` header, the recipient list is split into
// chunks. This does not affect MIME's `To:` header. Can be overwritten by setting
// `max_smtp_rcpt_to` in the provider db.

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;
@@ -1342,7 +1397,7 @@ WHERE addr=?
let fingerprint_other = fingerprint_other.to_string();
let stock_message = if contact.public_key(context).await?.is_some() {
stock_str::messages_e2e_encrypted(context).await
stock_str::messages_are_e2ee(context).await
} else {
stock_str::encr_none(context).await
};
@@ -1382,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)
}
@@ -1909,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,
@@ -2000,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}");
}
@@ -2041,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

@@ -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

@@ -10,6 +10,7 @@ use std::time::Duration;
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};
@@ -19,7 +20,7 @@ use crate::constants::{self, DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT, DC_VERSI
use crate::contact::{Contact, ContactId};
use crate::debug_logging::DebugLogging;
use crate::events::{Event, EventEmitter, EventType, Events};
use crate::imap::{FolderMeaning, Imap, ServerMetadata};
use crate::imap::{Imap, ServerMetadata};
use crate::key::self_fingerprint;
use crate::log::warn;
use crate::logged_debug_assert;
@@ -28,7 +29,7 @@ use crate::net::tls::TlsSessionStore;
use crate::peer_channels::Iroh;
use crate::push::PushSubscriber;
use crate::quota::QuotaInfo;
use crate::scheduler::{ConnectivityStore, SchedulerState, convert_folder_meaning};
use crate::scheduler::{ConnectivityStore, SchedulerState};
use crate::sql::Sql;
use crate::stock_str::StockStrings;
use crate::timesmearing::SmearedTimestamp;
@@ -233,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,
@@ -306,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>>,
@@ -342,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();
@@ -474,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(),
@@ -494,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(),
};
@@ -603,17 +623,10 @@ impl Context {
let mut session = connection.prepare(self).await?;
// Fetch IMAP folders.
// Inbox is fetched before Mvbox because fetching from Inbox
// may result in moving some messages to Mvbox.
for folder_meaning in [FolderMeaning::Inbox, FolderMeaning::Mvbox] {
if let Some((_folder_config, watch_folder)) =
convert_folder_meaning(self, folder_meaning).await?
{
connection
.fetch_move_delete(self, &mut session, &watch_folder, folder_meaning)
.await?;
}
}
let folder = connection.folder.clone();
connection
.fetch_move_delete(self, &mut session, &folder)
.await?;
// Update quota (to send warning if full) - but only check it once in a while.
// note: For now this only checks quota of primary transport,
@@ -624,7 +637,7 @@ impl Context {
DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT,
)
.await
&& let Err(err) = self.update_recent_quota(&mut session).await
&& let Err(err) = self.update_recent_quota(&mut session, folder).await
{
warn!(self, "Failed to update quota: {err:#}.");
}
@@ -864,23 +877,6 @@ impl Context {
Err(err) => format!("<key failure: {err}>"),
};
let mvbox_move = self.get_config_int(Config::MvboxMove).await?;
let only_fetch_mvbox = self.get_config_int(Config::OnlyFetchMvbox).await?;
let folders_configured = self
.sql
.get_raw_config_int(constants::DC_FOLDERS_CONFIGURED_KEY)
.await?
.unwrap_or_default();
let configured_inbox_folder = self
.get_config(Config::ConfiguredInboxFolder)
.await?
.unwrap_or_else(|| "<unset>".to_string());
let configured_mvbox_folder = self
.get_config(Config::ConfiguredMvboxFolder)
.await?
.unwrap_or_else(|| "<unset>".to_string());
let mut res = get_info();
// insert values
@@ -956,14 +952,6 @@ impl Context {
.await?
.to_string(),
);
res.insert("mvbox_move", mvbox_move.to_string());
res.insert("only_fetch_mvbox", only_fetch_mvbox.to_string());
res.insert(
constants::DC_FOLDERS_CONFIGURED_KEY,
folders_configured.to_string(),
);
res.insert("configured_inbox_folder", configured_inbox_folder);
res.insert("configured_mvbox_folder", configured_mvbox_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());
@@ -999,12 +987,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)
@@ -1269,12 +1251,6 @@ ORDER BY m.timestamp DESC,m.id DESC",
Ok(list)
}
/// 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))
}
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 {
@@ -272,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?;
}
@@ -327,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

@@ -70,8 +70,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();

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

@@ -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,

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,

View File

@@ -18,7 +18,6 @@ use async_channel::{self, Receiver, Sender};
use async_imap::types::{Fetch, Flag, Name, NameAttribute, UnsolicitedResponse};
use futures::{FutureExt as _, TryStreamExt};
use futures_lite::FutureExt;
use num_traits::FromPrimitive;
use ratelimit::Ratelimit;
use url::Url;
@@ -28,13 +27,14 @@ use crate::calls::{
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::constants::{Blocked, DC_VERSION_STR};
use crate::contact::ContactId;
use crate::context::Context;
use crate::ensure_and_debug_assert;
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
use crate::log::{LogExt, warn};
use crate::message::{self, Message, MessageState, MessengerMessage, MsgId};
use crate::message::{self, Message, MessageState, MsgId};
use crate::mimeparser;
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionStream;
@@ -53,7 +53,6 @@ use crate::transport::{
pub(crate) mod capabilities;
mod client;
mod idle;
pub mod scan_folders;
pub mod select_folder;
pub(crate) mod session;
@@ -93,6 +92,9 @@ pub(crate) struct Imap {
oauth2: bool,
/// Watched folder.
pub(crate) folder: String,
authentication_failed_once: bool,
pub(crate) connectivity: ConnectivityStore,
@@ -164,7 +166,6 @@ pub enum FolderMeaning {
/// Spam folder.
Spam,
Inbox,
Mvbox,
Trash,
/// Virtual folders.
@@ -176,19 +177,6 @@ pub enum FolderMeaning {
Virtual,
}
impl FolderMeaning {
pub fn to_config(self) -> Option<Config> {
match self {
FolderMeaning::Unknown => None,
FolderMeaning::Spam => None,
FolderMeaning::Inbox => Some(Config::ConfiguredInboxFolder),
FolderMeaning::Mvbox => Some(Config::ConfiguredMvboxFolder),
FolderMeaning::Trash => None,
FolderMeaning::Virtual => None,
}
}
}
struct UidGrouper<T: Iterator<Item = (i64, u32, String)>> {
inner: Peekable<T>,
}
@@ -209,6 +197,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()?;
@@ -264,6 +253,11 @@ impl Imap {
let addr = &param.addr;
let strict_tls = param.strict_tls(proxy_config.is_some());
let oauth2 = param.oauth2;
let folder = param
.imap_folder
.clone()
.unwrap_or_else(|| "INBOX".to_string());
ensure_and_debug_assert!(!folder.is_empty(), "Watched folder name cannot be empty");
let (resync_request_sender, resync_request_receiver) = async_channel::bounded(1);
Ok(Imap {
transport_id,
@@ -274,6 +268,7 @@ impl Imap {
proxy_config,
strict_tls,
oauth2,
folder,
authentication_failed_once: false,
connectivity: Default::default(),
conn_last_try: UNIX_EPOCH,
@@ -357,10 +352,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) => {
@@ -486,7 +481,7 @@ impl Imap {
/// that folders are created and IMAP capabilities are determined.
pub(crate) async fn prepare(&mut self, context: &Context) -> Result<Session> {
let configuring = false;
let mut session = match self.connect(context, configuring).await {
let session = match self.connect(context, configuring).await {
Ok(session) => session,
Err(err) => {
self.connectivity.set_err(context, &err);
@@ -494,14 +489,6 @@ impl Imap {
}
};
let folders_configured = context
.sql
.get_raw_config_int(constants::DC_FOLDERS_CONFIGURED_KEY)
.await?;
if folders_configured.unwrap_or_default() < constants::DC_FOLDERS_CONFIGURED_VERSION {
self.configure_folders(context, &mut session).await?;
}
Ok(session)
}
@@ -514,15 +501,15 @@ impl Imap {
context: &Context,
session: &mut Session,
watch_folder: &str,
folder_meaning: FolderMeaning,
) -> Result<()> {
ensure_and_debug_assert!(!watch_folder.is_empty(), "Watched folder cannot be empty");
if !context.sql.is_open().await {
// probably shutdown
bail!("IMAP operation attempted while it is torn down");
}
let msgs_fetched = self
.fetch_new_messages(context, session, watch_folder, folder_meaning)
.fetch_new_messages(context, session, watch_folder)
.await
.context("fetch_new_messages")?;
if msgs_fetched && context.get_config_delete_device_after().await?.is_some() {
@@ -544,19 +531,13 @@ 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,
session: &mut Session,
folder: &str,
folder_meaning: FolderMeaning,
) -> Result<bool> {
if should_ignore_folder(context, folder, folder_meaning).await? {
info!(context, "Not fetching from {folder:?}.");
session.new_mail = false;
return Ok(false);
}
let folder_exists = session
.select_with_uidvalidity(context, folder)
.await
@@ -573,9 +554,7 @@ impl Imap {
let mut read_cnt = 0;
loop {
let (n, fetch_more) = self
.fetch_new_msg_batch(context, session, folder, folder_meaning)
.await?;
let (n, fetch_more) = self.fetch_new_msg_batch(context, session, folder).await?;
read_cnt += n;
if !fetch_more {
return Ok(read_cnt > 0);
@@ -584,12 +563,12 @@ 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,
session: &mut Session,
folder: &str,
folder_meaning: FolderMeaning,
) -> Result<(usize, bool)> {
let transport_id = self.transport_id;
let uid_validity = get_uidvalidity(context, transport_id, folder).await?;
@@ -605,6 +584,7 @@ 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();
@@ -658,13 +638,7 @@ impl Imap {
info!(context, "Deleting locally deleted message {message_id}.");
}
let _target;
let target = if delete {
""
} else {
_target = target_folder(context, folder, folder_meaning, &headers).await?;
&_target
};
let target = if delete { "" } else { folder };
context
.sql
@@ -692,18 +666,9 @@ impl Imap {
// message, move it to the movebox and then download the second message before
// downloading the first one, if downloading from inbox before moving is allowed.
if folder == target
// Never download messages directly from the spam folder.
// If the sender is known, the message will be moved to the Inbox or Mvbox
// and then we download the message from there.
// Also see `spam_target_folder_cfg()`.
&& folder_meaning != FolderMeaning::Spam
&& prefetch_should_download(
context,
&headers,
&message_id,
fetch_response.flags(),
)
.await.context("prefetch_should_download")?
&& prefetch_should_download(context, &headers, &message_id, fetch_response.flags())
.await
.context("prefetch_should_download")?
{
if headers
.get_header_value(HeaderDef::ChatIsPostMessage)
@@ -1266,6 +1231,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,
@@ -1430,6 +1396,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;
@@ -1617,13 +1584,8 @@ impl Session {
// Store new encrypted device token on the server
// even if it is the same as the old one.
if let Some(encrypted_device_token) = new_encrypted_device_token {
let folder = context
.get_config(Config::ConfiguredInboxFolder)
.await?
.context("INBOX is not configured")?;
self.run_command_and_check_ok(&format_setmetadata(
&folder,
"INBOX",
&encrypted_device_token,
))
.await
@@ -1668,117 +1630,6 @@ impl Session {
}
Ok(())
}
/// Attempts to configure mvbox.
///
/// 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 folder name.
async fn configure_mvbox<'a>(
&mut self,
context: &Context,
folders: &[&'a str],
) -> Result<Option<&'a str>> {
// Close currently selected folder if needed.
// We are going to select folders using low-level EXAMINE operations below.
self.maybe_close_folder(context).await?;
for folder in folders {
info!(context, "Looking for MVBOX-folder \"{}\"...", &folder);
let res = self.examine(&folder).await;
if res.is_ok() {
info!(
context,
"MVBOX-folder {:?} successfully selected, using it.", &folder
);
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 folder_exists = self.select_with_uidvalidity(context, folder).await?;
ensure!(folder_exists, "No MVBOX folder {:?}??", &folder);
return Ok(Some(folder));
}
}
Ok(None)
}
}
impl Imap {
pub(crate) async fn configure_folders(
&mut self,
context: &Context,
session: &mut Session,
) -> Result<()> {
let mut folders = session
.list(Some(""), Some("*"))
.await
.context("list_folders failed")?;
let mut delimiter = ".".to_string();
let mut delimiter_is_default = true;
let mut folder_configs = BTreeMap::new();
while let Some(folder) = folders.try_next().await? {
info!(context, "Scanning folder: {:?}", folder);
// Update the delimiter iff there is a different one, but only once.
if let Some(d) = folder.delimiter()
&& delimiter_is_default
&& !d.is_empty()
&& delimiter != d
{
delimiter = d.to_string();
delimiter_is_default = false;
}
let folder_meaning = get_folder_meaning_by_attrs(folder.attributes());
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());
}
}
drop(folders);
info!(context, "Using \"{}\" as folder-delimiter.", delimiter);
let fallback_folder = format!("INBOX{delimiter}DeltaChat");
let mvbox_folder = session
.configure_mvbox(context, &["DeltaChat", &fallback_folder])
.await
.context("failed to configure mvbox")?;
context
.set_config_internal(Config::ConfiguredInboxFolder, Some("INBOX"))
.await?;
if let Some(mvbox_folder) = mvbox_folder {
info!(context, "Setting MVBOX FOLDER TO {}", &mvbox_folder);
context
.set_config_internal(Config::ConfiguredMvboxFolder, Some(mvbox_folder))
.await?;
}
for (config, name) in folder_configs {
context.set_config_internal(config, Some(&name)).await?;
}
context
.sql
.set_raw_config_int(
constants::DC_FOLDERS_CONFIGURED_KEY,
constants::DC_FOLDERS_CONFIGURED_VERSION,
)
.await?;
info!(context, "FINISHED configuring IMAP-folders.");
Ok(())
}
}
impl Session {
@@ -1912,15 +1763,7 @@ async fn spam_target_folder_cfg(
return Ok(None);
}
if needs_move_to_mvbox(context, headers).await?
// If OnlyFetchMvbox is set, we don't want to move the message to
// the inbox where we wouldn't fetch it again:
|| context.get_config_bool(Config::OnlyFetchMvbox).await?
{
Ok(Some(Config::ConfiguredMvboxFolder))
} else {
Ok(Some(Config::ConfiguredInboxFolder))
}
Ok(Some(Config::ConfiguredInboxFolder))
}
/// Returns `ConfiguredInboxFolder` or `ConfiguredMvboxFolder` if
@@ -1931,16 +1774,12 @@ pub async fn target_folder_cfg(
folder_meaning: FolderMeaning,
headers: &[mailparse::MailHeader<'_>],
) -> Result<Option<Config>> {
if context.is_mvbox(folder).await? {
if folder == "DeltaChat" {
return Ok(None);
}
if folder_meaning == FolderMeaning::Spam {
spam_target_folder_cfg(context, headers).await
} else if folder_meaning == FolderMeaning::Inbox
&& needs_move_to_mvbox(context, headers).await?
{
Ok(Some(Config::ConfiguredMvboxFolder))
} else {
Ok(None)
}
@@ -1961,36 +1800,6 @@ pub async fn target_folder(
}
}
async fn needs_move_to_mvbox(
context: &Context,
headers: &[mailparse::MailHeader<'_>],
) -> Result<bool> {
let has_chat_version = headers.get_header_value(HeaderDef::ChatVersion).is_some();
if !context.get_config_bool(Config::MvboxMove).await? {
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? {
match parent.is_dc_message {
MessengerMessage::No => Ok(false),
MessengerMessage::Yes | MessengerMessage::Reply => Ok(true),
}
} else {
Ok(false)
}
}
/// Try to get the folder meaning by the name of the folder only used if the server does not support XLIST.
// TODO: lots languages missing - maybe there is a list somewhere on other MUAs?
// however, if we fail to find out the sent-folder,
@@ -2127,16 +1936,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),
@@ -2149,29 +1953,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)
}
@@ -2348,24 +2130,10 @@ async fn get_modseq(context: &Context, transport_id: u32, folder: &str) -> Resul
.unwrap_or(0))
}
/// Whether to ignore fetching messages from a folder.
///
/// This caters for the [`Config::OnlyFetchMvbox`] setting which means mails from folders
/// not explicitly watched should not be fetched.
async fn should_ignore_folder(
context: &Context,
folder: &str,
folder_meaning: FolderMeaning,
) -> Result<bool> {
if !context.get_config_bool(Config::OnlyFetchMvbox).await? {
return Ok(false);
}
Ok(!(context.is_mvbox(folder).await? || folder_meaning == FolderMeaning::Spam))
}
/// 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![];

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

@@ -115,11 +115,7 @@ impl Session {
impl Imap {
/// Idle using polling.
pub(crate) async fn fake_idle(
&mut self,
context: &Context,
watch_folder: String,
) -> Result<()> {
pub(crate) async fn fake_idle(&mut self, context: &Context, watch_folder: &str) -> Result<()> {
let fake_idle_start_time = tools::Time::now();
info!(context, "IMAP-fake-IDLEing folder={:?}", watch_folder);

View File

@@ -100,7 +100,6 @@ fn test_build_sequence_sets() {
async fn check_target_folder_combination(
folder: &str,
mvbox_move: bool,
chat_msg: bool,
expected_destination: &str,
accepted_chat: bool,
@@ -108,16 +107,10 @@ async fn check_target_folder_combination(
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}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}"
);
let t = TestContext::new_alice().await;
t.ctx
.set_config(Config::ConfiguredMvboxFolder, Some("DeltaChat"))
.await?;
t.ctx
.set_config(Config::MvboxMove, Some(if mvbox_move { "1" } else { "0" }))
.await?;
if accepted_chat {
let contact_id = Contact::create(&t.ctx, "", "bob@example.net").await?;
@@ -164,42 +157,33 @@ 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}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}: expected {expected:?}, got {actual:?}"
);
Ok(())
}
// chat_msg means that the message was sent by Delta Chat
// The tuples are (folder, mvbox_move, chat_msg, expected_destination)
const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, bool, &str)] = &[
("INBOX", false, false, "INBOX"),
("INBOX", false, true, "INBOX"),
("INBOX", true, false, "INBOX"),
("INBOX", true, true, "DeltaChat"),
("Spam", false, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", false, true, "INBOX"),
("Spam", true, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", true, true, "DeltaChat"),
// The tuples are (folder, chat_msg, expected_destination)
const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, &str)] = &[
("INBOX", false, "INBOX"),
("INBOX", true, "INBOX"),
("Spam", false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", true, "INBOX"),
];
// These are the same as above, but non-chat messages in Spam stay in Spam
const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[
("INBOX", false, false, "INBOX"),
("INBOX", false, true, "INBOX"),
("INBOX", true, false, "INBOX"),
("INBOX", true, true, "DeltaChat"),
("Spam", false, false, "Spam"),
("Spam", false, true, "INBOX"),
("Spam", true, false, "Spam"),
("Spam", true, true, "DeltaChat"),
const COMBINATIONS_REQUEST: &[(&str, bool, &str)] = &[
("INBOX", false, "INBOX"),
("INBOX", true, "INBOX"),
("Spam", false, "Spam"),
("Spam", true, "INBOX"),
];
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_incoming_accepted() -> Result<()> {
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
for (folder, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
true,
@@ -213,10 +197,9 @@ async fn test_target_folder_incoming_accepted() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_incoming_request() -> Result<()> {
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_REQUEST {
for (folder, chat_msg, expected_destination) in COMBINATIONS_REQUEST {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
false,
@@ -231,17 +214,9 @@ async fn test_target_folder_incoming_request() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_outgoing() -> Result<()> {
// Test outgoing emails
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
true,
true,
false,
)
.await?;
for (folder, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(folder, *chat_msg, expected_destination, true, true, false)
.await?;
}
Ok(())
}
@@ -249,10 +224,9 @@ async fn test_target_folder_outgoing() -> Result<()> {
#[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 {
for (folder, 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,

View File

@@ -1,108 +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();
}
}
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

@@ -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::{
@@ -103,7 +102,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 +141,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 +203,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 +236,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 +287,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 +311,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 +360,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 +372,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 +382,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 +447,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 +521,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 +601,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 +655,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 +721,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 +795,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 +808,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 +830,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 +1025,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

@@ -129,6 +129,7 @@ pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result<St
}
/// Creates a new setup code for Autocrypt Setup Message.
#[expect(clippy::arithmetic_side_effects)]
fn create_setup_code(_context: &Context) -> String {
let mut random_val: u16;
let mut ret = String::new();

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_all_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,8 +311,11 @@ 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)
}
}
}
@@ -215,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)
}
}
}
@@ -262,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 {
@@ -289,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? {
@@ -344,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,
@@ -426,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(())
}
@@ -507,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() {
@@ -577,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);
@@ -590,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,7 +766,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);
@@ -647,7 +780,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);
@@ -702,12 +835,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() {

View File

@@ -14,8 +14,10 @@
clippy::unused_async,
clippy::explicit_iter_loop,
clippy::explicit_into_iter_loop,
clippy::cloned_instead_of_copied
clippy::cloned_instead_of_copied,
clippy::manual_is_variant_and
)]
#![cfg_attr(not(test), warn(clippy::arithmetic_side_effects))]
#![cfg_attr(not(test), forbid(clippy::indexing_slicing))]
#![cfg_attr(not(test), forbid(clippy::string_slice))]
#![allow(

View File

@@ -263,6 +263,7 @@ impl Kml {
}
/// Enables location streaming in chat identified by `chat_id` for `seconds` seconds.
#[expect(clippy::arithmetic_side_effects)]
pub async fn send_locations_to_chat(
context: &Context,
chat_id: ChatId,
@@ -385,6 +386,7 @@ pub async fn set(context: &Context, latitude: f64, longitude: f64, accuracy: f64
}
/// Searches for locations in the given time range, optionally filtering by chat and contact IDs.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_range(
context: &Context,
chat_id: Option<ChatId>,
@@ -517,6 +519,7 @@ pub(crate) async fn delete_orphaned_poi_locations(context: &Context) -> Result<(
}
/// Returns `location.kml` contents.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_kml(context: &Context, chat_id: ChatId) -> Result<Option<(String, u32)>> {
let mut last_added_location_id = 0;
@@ -752,6 +755,7 @@ pub(crate) async fn location_loop(context: &Context, interrupt_receiver: Receive
/// Returns number of seconds until the next time location streaming for some chat ends
/// automatically.
#[expect(clippy::arithmetic_side_effects)]
async fn maybe_send_locations(context: &Context) -> Result<Option<u64>> {
let mut next_event: Option<u64> = None;

View File

@@ -76,6 +76,7 @@ impl<S: SessionStream> LoggingStream<S> {
}
impl<S: SessionStream> AsyncRead for LoggingStream<S> {
#[expect(clippy::arithmetic_side_effects)]
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,

View File

@@ -56,9 +56,37 @@ pub enum EnteredCertificateChecks {
AcceptInvalidCertificates2 = 3,
}
/// Login parameters for a single server, either IMAP or SMTP
/// Login parameters for a single IMAP server.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnteredServerLoginParam {
pub struct EnteredImapLoginParam {
/// Server hostname or IP address.
pub server: String,
/// Server port.
///
/// 0 if not specified.
pub port: u16,
/// Folder to watch.
///
/// If empty, user has not entered anything and it shuold expand to "INBOX" later.
pub folder: String,
/// Socket security.
pub security: Socket,
/// Username.
///
/// Empty string if not specified.
pub user: String,
/// Password.
pub password: String,
}
/// Login parameters for a single SMTP server.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnteredSmtpLoginParam {
/// Server hostname or IP address.
pub server: String,
@@ -86,10 +114,10 @@ pub struct EnteredLoginParam {
pub addr: String,
/// IMAP settings.
pub imap: EnteredServerLoginParam,
pub imap: EnteredImapLoginParam,
/// SMTP settings.
pub smtp: EnteredServerLoginParam,
pub smtp: EnteredSmtpLoginParam,
/// TLS options: whether to allow invalid certificates and/or
/// invalid hostnames
@@ -101,6 +129,8 @@ pub struct EnteredLoginParam {
impl EnteredLoginParam {
/// Loads entered account settings.
///
/// This is a legacy API for loading from separate config parameters.
pub(crate) async fn load(context: &Context) -> Result<Self> {
let addr = context
.get_config(Config::Addr)
@@ -117,6 +147,10 @@ impl EnteredLoginParam {
.get_config_parsed::<u16>(Config::MailPort)
.await?
.unwrap_or_default();
// There is no way to set custom folder with this legacy API.
let mail_folder = String::new();
let mail_security = context
.get_config_parsed::<i32>(Config::MailSecurity)
.await?
@@ -175,14 +209,15 @@ impl EnteredLoginParam {
Ok(EnteredLoginParam {
addr,
imap: EnteredServerLoginParam {
imap: EnteredImapLoginParam {
server: mail_server,
port: mail_port,
folder: mail_folder,
security: mail_security,
user: mail_user,
password: mail_pw,
},
smtp: EnteredServerLoginParam {
smtp: EnteredSmtpLoginParam {
server: send_server,
port: send_port,
security: send_security,
@@ -344,14 +379,15 @@ mod tests {
let t = TestContext::new().await;
let param = EnteredLoginParam {
addr: "alice@example.org".to_string(),
imap: EnteredServerLoginParam {
imap: EnteredImapLoginParam {
server: "".to_string(),
port: 0,
folder: "".to_string(),
security: Socket::Starttls,
user: "".to_string(),
password: "foobar".to_string(),
},
smtp: EnteredServerLoginParam {
smtp: EnteredSmtpLoginParam {
server: "".to_string(),
port: 2947,
security: Socket::default(),

View File

@@ -201,6 +201,7 @@ SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ?, ? FROM msgs WHERE id=?1
}
/// Returns detailed message information in a multi-line text form.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_info(self, context: &Context) -> Result<String> {
let msg = Message::load_from_db(context, self).await?;
@@ -822,6 +823,7 @@ impl Message {
///
/// Currently this includes `additional_text`, but this may change in future, when the UIs show
/// the necessary info themselves.
#[expect(clippy::arithmetic_side_effects)]
pub fn get_text(&self) -> String {
self.text.clone() + &self.additional_text
}
@@ -964,6 +966,7 @@ impl Message {
///
/// A message has a deviating timestamp when it is sent on
/// another day as received/sorted by.
#[expect(clippy::arithmetic_side_effects)]
pub fn has_deviating_timestamp(&self) -> bool {
let cnv_to_local = gm2local_offset();
let sort_timestamp = self.get_sort_timestamp() + cnv_to_local;
@@ -1005,6 +1008,7 @@ impl Message {
pub async fn get_info_contact_id(&self, context: &Context) -> Result<Option<ContactId>> {
match self.param.get_cmd() {
SystemMessage::GroupNameChanged
| SystemMessage::GroupDescriptionChanged
| SystemMessage::GroupImageChanged
| SystemMessage::EphemeralTimerChanged => {
if self.from_id != ContactId::INFO {
@@ -1930,6 +1934,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
// We also don't send read receipts for contact requests.
// Read receipts will not be sent even after accepting the chat.
let to_id = if curr_blocked == Blocked::Not
&& !curr_hidden
&& curr_param.get_bool(Param::WantsMdn).unwrap_or_default()
&& curr_param.get_cmd() == SystemMessage::Unknown
&& context.should_send_mdns().await?
@@ -2063,6 +2068,22 @@ pub(crate) async fn set_msg_failed(
Ok(())
}
/// Inserts a tombstone into `msgs` table
/// to prevent downloading the same message in the future.
///
/// Returns tombstone database row ID.
pub(crate) async fn insert_tombstone(context: &Context, rfc724_mid: &str) -> Result<MsgId> {
let row_id = context
.sql
.insert(
"INSERT INTO msgs(rfc724_mid, chat_id) VALUES (?,?)",
(rfc724_mid, DC_CHAT_ID_TRASH),
)
.await?;
let msg_id = MsgId::new(u32::try_from(row_id)?);
Ok(msg_id)
}
/// The number of messages assigned to unblocked chats
pub async fn get_unblocked_msg_cnt(context: &Context) -> usize {
match context
@@ -2118,6 +2139,7 @@ pub async fn get_request_msg_cnt(context: &Context) -> usize {
/// Returns the number of messages that are older than the given number of seconds.
/// This includes e-mails downloaded due to the `show_emails` option.
/// Messages in the "saved messages" folder are not counted as they will not be deleted automatically.
#[expect(clippy::arithmetic_side_effects)]
pub async fn estimate_deletion_cnt(
context: &Context,
from_server: bool,

View File

@@ -33,7 +33,7 @@ use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::{SystemMessage, is_hidden};
use crate::param::Param;
use crate::peer_channels::{create_iroh_header, get_iroh_topic_for_msg};
use crate::pgp::SeipdVersion;
use crate::pgp::{SeipdVersion, addresses_from_public_key};
use crate::simplify::escape_message_footer_marks;
use crate::stock_str;
use crate::tools::{
@@ -195,6 +195,7 @@ fn new_address_with_name(name: &str, address: String) -> Address<'static> {
}
impl MimeFactory {
#[expect(clippy::arithmetic_side_effects)]
pub async fn from_msg(context: &Context, msg: Message) -> Result<MimeFactory> {
let now = time();
let chat = Chat::load_from_db(context, msg.chat_id).await?;
@@ -273,10 +274,13 @@ impl MimeFactory {
.await?
.context("Can't send member addition/removal: missing key")?;
recipients.push(addr.clone());
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
let relays =
addresses_from_public_key(&public_key).unwrap_or_else(|| vec![addr.clone()]);
recipients.extend(relays);
to.push((authname, addr.clone()));
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
encryption_pubkeys = Some(vec![(addr, public_key)]);
} else {
let email_to_remove = if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup {
@@ -353,9 +357,23 @@ impl MimeFactory {
false => "".to_string(),
};
if add_timestamp >= remove_timestamp {
let relays = if let Some(public_key) = public_key_opt {
let addrs = addresses_from_public_key(&public_key);
keys.push((addr.clone(), public_key));
addrs
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
missing_key_addresses.insert(addr.clone());
if is_encrypted {
warn!(context, "Missing key for {addr}");
}
None
} else {
None
}.unwrap_or_else(|| vec![addr.clone()]);
if !recipients_contain_addr(&to, &addr) {
if id != ContactId::SELF {
recipients.push(addr.clone());
recipients.extend(relays);
}
if !undisclosed_recipients {
to.push((name, addr.clone()));
@@ -366,42 +384,38 @@ impl MimeFactory {
} else if id == ContactId::SELF {
member_fingerprints.push(self_fingerprint.to_string());
} else {
ensure_and_debug_assert!(member_fingerprints.is_empty(), "If some past member is a key-contact, all other past members should be key-contacts too");
ensure_and_debug_assert!(member_fingerprints.is_empty(), "If some member is a key-contact, all other members should be key-contacts too");
}
}
member_timestamps.push(add_timestamp);
}
}
recipient_ids.insert(id);
if let Some(public_key) = public_key_opt {
keys.push((addr.clone(), public_key))
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
missing_key_addresses.insert(addr.clone());
if is_encrypted {
warn!(context, "Missing key for {addr}");
}
}
} else if remove_timestamp.saturating_add(60 * 24 * 3600) > now {
// Row is a tombstone,
// member is not actually part of the group.
if !recipients_contain_addr(&past_members, &addr) {
if let Some(email_to_remove) = email_to_remove
&& email_to_remove == addr {
// This is a "member removed" message,
// we need to notify removed member
// that it was removed.
if id != ContactId::SELF {
recipients.push(addr.clone());
}
if let Some(public_key) = public_key_opt {
keys.push((addr.clone(), public_key))
let relays = if let Some(public_key) = public_key_opt {
let addrs = addresses_from_public_key(&public_key);
keys.push((addr.clone(), public_key));
addrs
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
missing_key_addresses.insert(addr.clone());
if is_encrypted {
warn!(context, "Missing key for {addr}");
}
None
} else {
None
}.unwrap_or_else(|| vec![addr.clone()]);
// This is a "member removed" message,
// we need to notify removed member
// that it was removed.
if id != ContactId::SELF {
recipients.extend(relays);
}
}
if !undisclosed_recipients {
@@ -455,9 +469,16 @@ impl MimeFactory {
.filter(|id| *id != ContactId::SELF)
.collect();
if recipient_ids.len() == 1
&& msg.param.get_cmd() != SystemMessage::MemberRemovedFromGroup
&& chat.typ != Chattype::OutBroadcast
&& !matches!(
msg.param.get_cmd(),
SystemMessage::MemberRemovedFromGroup | SystemMessage::SecurejoinMessage
)
&& !matches!(chat.typ, Chattype::OutBroadcast | Chattype::InBroadcast)
{
info!(
context,
"Scale up origin of {} recipients to OutgoingTo.", chat.id
);
ContactId::scaleup_origin(context, &recipient_ids, Origin::OutgoingTo).await?;
}
@@ -663,7 +684,7 @@ impl MimeFactory {
if msg
.param
.get_bool(Param::AttachGroupImage)
.get_bool(Param::AttachChatAvatarAndDescription)
.unwrap_or_default()
{
return chat.param.get(Param::ProfileImage).map(Into::into);
@@ -726,6 +747,7 @@ impl MimeFactory {
/// Consumes a `MimeFactory` and renders it into a message which is then stored in
/// `smtp`-table to be used by the SMTP loop
#[expect(clippy::arithmetic_side_effects)]
pub async fn render(mut self, context: &Context) -> Result<RenderedEmail> {
let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new();
@@ -869,16 +891,6 @@ impl MimeFactory {
"Auto-Submitted",
mail_builder::headers::raw::Raw::new("auto-generated".to_string()).into(),
));
} else if let Loaded::Message { msg, .. } = &self.loaded
&& msg.param.get_cmd() == SystemMessage::SecurejoinMessage
{
let step = msg.param.get(Param::Arg).unwrap_or_default();
if step != "vg-request" && step != "vc-request" {
headers.push((
"Auto-Submitted",
mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(),
));
}
}
if let Loaded::Message { msg, chat } = &self.loaded
@@ -947,6 +959,22 @@ impl MimeFactory {
));
}
if self.pre_message_mode == PreMessageMode::Post {
headers.push((
"Chat-Is-Post-Message",
mail_builder::headers::raw::Raw::new("1").into(),
));
} else if let PreMessageMode::Pre {
post_msg_rfc724_mid,
} = &self.pre_message_mode
{
headers.push((
"Chat-Post-Message-ID",
mail_builder::headers::message_id::MessageId::new(post_msg_rfc724_mid.clone())
.into(),
));
}
let is_encrypted = self.will_be_encrypted();
// Add ephemeral timer for non-MDN messages.
@@ -993,189 +1021,29 @@ impl MimeFactory {
Loaded::Mdn { .. } => self.render_mdn()?,
};
// Split headers based on header confidentiality policy.
// Headers that must go into IMF header section.
//
// These are standard headers such as Date, In-Reply-To, References, which cannot be placed
// anywhere else according to the standard. Placing headers here also allows them to be fetched
// individually over IMAP without downloading the message body. This is why Chat-Version is
// placed here.
let mut unprotected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
// Headers that MUST NOT (only) go into IMF header section:
// - Large headers which may hit the header section size limit on the server, such as
// Chat-User-Avatar with a base64-encoded image inside.
// - Headers duplicated here that servers mess up with in the IMF header section, like
// Message-ID.
// - Nonstandard headers that should be DKIM-protected because e.g. OpenDKIM only signs
// known headers.
//
// The header should be hidden from MTA
// by moving it either into protected part
// in case of encrypted mails
// or unprotected MIME preamble in case of unencrypted mails.
let mut hidden_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
// Opportunistically protected headers.
//
// These headers are placed into encrypted part *if* the message is encrypted. Place headers
// which are not needed before decryption (e.g. Chat-Group-Name) or are not interesting if the
// message cannot be decrypted (e.g. Chat-Disposition-Notification-To) here.
//
// If the message is not encrypted, these headers are placed into IMF header section, so make
// sure that the message will be encrypted if you place any sensitive information here.
let mut protected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
// MIME header <https://datatracker.ietf.org/doc/html/rfc2045>.
unprotected_headers.push((
"MIME-Version",
mail_builder::headers::raw::Raw::new("1.0").into(),
));
if self.pre_message_mode == PreMessageMode::Post {
unprotected_headers.push((
"Chat-Is-Post-Message",
mail_builder::headers::raw::Raw::new("1").into(),
));
} else if let PreMessageMode::Pre {
post_msg_rfc724_mid,
} = &self.pre_message_mode
{
protected_headers.push((
"Chat-Post-Message-ID",
mail_builder::headers::message_id::MessageId::new(post_msg_rfc724_mid.clone())
.into(),
));
}
for header @ (original_header_name, _header_value) in &headers {
let header_name = original_header_name.to_lowercase();
if header_name == "message-id" {
unprotected_headers.push(header.clone());
hidden_headers.push(header.clone());
} else if is_hidden(&header_name) {
hidden_headers.push(header.clone());
} else if header_name == "from" {
// Unencrypted securejoin messages should _not_ include the display name:
if is_encrypted || !is_securejoin_message {
protected_headers.push(header.clone());
}
unprotected_headers.push((
original_header_name,
Address::new_address(None::<&'static str>, self.from_addr.clone()).into(),
));
} else if header_name == "to" {
protected_headers.push(header.clone());
if is_encrypted {
unprotected_headers.push(("To", hidden_recipients().into()));
} else {
unprotected_headers.push(header.clone());
}
} else if header_name == "chat-broadcast-secret" {
if is_encrypted {
protected_headers.push(header.clone());
} else {
bail!("Message is unecrypted, cannot include broadcast secret");
}
} else if is_encrypted && header_name == "date" {
protected_headers.push(header.clone());
// Randomized date goes to unprotected header.
//
// We cannot just send "Thu, 01 Jan 1970 00:00:00 +0000"
// or omit the header because GMX then fails with
//
// host mx00.emig.gmx.net[212.227.15.9] said:
// 554-Transaction failed
// 554-Reject due to policy restrictions.
// 554 For explanation visit https://postmaster.gmx.net/en/case?...
// (in reply to end of DATA command)
//
// and the explanation page says
// "The time information deviates too much from the actual time".
//
// We also limit the range to 6 days (518400 seconds)
// because with a larger range we got
// error "500 Date header far in the past/future"
// which apparently originates from Symantec Messaging Gateway
// and means the message has a Date that is more
// than 7 days in the past:
// <https://github.com/chatmail/core/issues/7466>
let timestamp_offset = rand::random_range(0..518400);
let protected_timestamp = self.timestamp.saturating_sub(timestamp_offset);
let unprotected_date =
chrono::DateTime::<chrono::Utc>::from_timestamp(protected_timestamp, 0)
.unwrap()
.to_rfc2822();
unprotected_headers.push((
"Date",
mail_builder::headers::raw::Raw::new(unprotected_date).into(),
));
} else if is_encrypted {
protected_headers.push(header.clone());
match header_name.as_str() {
"subject" => {
unprotected_headers.push((
"Subject",
mail_builder::headers::raw::Raw::new("[...]").into(),
));
}
"in-reply-to"
| "references"
| "auto-submitted"
| "chat-version"
| "autocrypt-setup-message" => {
unprotected_headers.push(header.clone());
}
_ => {
// Other headers are removed from unprotected part.
}
}
} else {
// Copy the header to the protected headers
// in case of signed-only message.
// If the message is not signed, this value will not be used.
protected_headers.push(header.clone());
unprotected_headers.push(header.clone())
}
}
let HeadersByConfidentiality {
mut unprotected_headers,
hidden_headers,
protected_headers,
} = group_headers_by_confidentiality(
headers,
&self.from_addr,
self.timestamp,
is_encrypted,
is_securejoin_message,
);
let use_std_header_protection = context
.get_config_bool(Config::StdHeaderProtectionComposing)
.await?;
let outer_message = if let Some(encryption_pubkeys) = self.encryption_pubkeys {
// Store protected headers in the inner message.
let message = protected_headers
.into_iter()
.fold(message, |message, (header, value)| {
message.header(header, value)
});
// Add hidden headers to encrypted payload.
let mut message: MimePart<'static> = hidden_headers
.into_iter()
.fold(message, |message, (header, value)| {
message.header(header, value)
});
if use_std_header_protection {
message = unprotected_headers
.iter()
// Structural headers shouldn't be added as "HP-Outer". They are defined in
// <https://www.rfc-editor.org/rfc/rfc9787.html#structural-header-fields>.
.filter(|(name, _)| {
!(name.eq_ignore_ascii_case("mime-version")
|| name.eq_ignore_ascii_case("content-type")
|| name.eq_ignore_ascii_case("content-transfer-encoding")
|| name.eq_ignore_ascii_case("content-disposition"))
})
.fold(message, |message, (name, value)| {
message.header(format!("HP-Outer: {name}"), value.clone())
});
}
let mut message = add_headers_to_encrypted_part(
message,
&unprotected_headers,
hidden_headers,
protected_headers,
use_std_header_protection,
);
// Add gossip headers in chats with multiple recipients
let multiple_recipients =
@@ -1266,21 +1134,6 @@ impl MimeFactory {
}
}
// Set the appropriate Content-Type for the inner message.
for (h, v) in &mut message.headers {
if h == "Content-Type"
&& let mail_builder::headers::HeaderType::ContentType(ct) = v
{
let mut ct_new = ct.clone();
ct_new = ct_new.attribute("protected-headers", "v1");
if use_std_header_protection {
ct_new = ct_new.attribute("hp", "cipher");
}
*ct = ct_new;
break;
}
}
// Disable compression for SecureJoin to ensure
// there are no compression side channels
// leaking information about the tokens.
@@ -1328,8 +1181,9 @@ impl MimeFactory {
}
let encrypted = if let Some(shared_secret) = shared_secret {
let sign = true;
encrypt_helper
.encrypt_symmetrically(context, &shared_secret, message, compress)
.encrypt_symmetrically(context, &shared_secret, message, compress, sign)
.await?
} else {
// Asymmetric encryption
@@ -1363,35 +1217,7 @@ impl MimeFactory {
.await?
};
// XXX: additional newline is needed
// to pass filtermail at
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
let encrypted = encrypted + "\n";
// Set the appropriate Content-Type for the outer message
MimePart::new(
"multipart/encrypted; protocol=\"application/pgp-encrypted\"",
vec![
// Autocrypt part 1
MimePart::new("application/pgp-encrypted", "Version: 1\r\n").header(
"Content-Description",
mail_builder::headers::raw::Raw::new("PGP/MIME version identification"),
),
// Autocrypt part 2
MimePart::new(
"application/octet-stream; name=\"encrypted.asc\"",
encrypted,
)
.header(
"Content-Description",
mail_builder::headers::raw::Raw::new("OpenPGP encrypted message"),
)
.header(
"Content-Disposition",
mail_builder::headers::raw::Raw::new("inline; filename=\"encrypted.asc\";"),
),
],
)
wrap_encrypted_part(encrypted)
} else if matches!(self.loaded, Loaded::Mdn { .. }) {
// Never add outer multipart/mixed wrapper to MDN
// as multipart/report Content-Type is used to recognize MDNs
@@ -1458,22 +1284,12 @@ impl MimeFactory {
}
};
// Store the unprotected headers on the outer message.
let outer_message = unprotected_headers
.into_iter()
.fold(outer_message, |message, (header, value)| {
message.header(header, value)
});
let MimeFactory {
last_added_location_id,
..
} = self;
let mut buffer = Vec::new();
let cursor = Cursor::new(&mut buffer);
outer_message.clone().write_part(cursor).ok();
let message = String::from_utf8_lossy(&buffer).to_string();
let message = render_outer_message(unprotected_headers, outer_message);
Ok(RenderedEmail {
message,
@@ -1613,9 +1429,9 @@ impl MimeFactory {
.await?
.unwrap_or_default()
{
placeholdertext = Some("I left the group.".to_string());
placeholdertext = Some(format!("{email_to_remove} left the group."));
} else {
placeholdertext = Some(format!("I removed member {email_to_remove}."));
placeholdertext = Some(format!("Member {email_to_remove} was removed."));
};
if !email_to_remove.is_empty() {
@@ -1638,7 +1454,7 @@ impl MimeFactory {
let email_to_add = msg.param.get(Param::Arg).unwrap_or_default();
let fingerprint_to_add = msg.param.get(Param::Arg4).unwrap_or_default();
placeholdertext = Some(format!("I added member {email_to_add}."));
placeholdertext = Some(format!("Member {email_to_add} was added."));
if !email_to_add.is_empty() {
headers.push((
@@ -1663,13 +1479,24 @@ impl MimeFactory {
}
}
SystemMessage::GroupNameChanged => {
placeholdertext = Some("Chat name changed.".to_string());
let old_name = msg.param.get(Param::Arg).unwrap_or_default().to_string();
headers.push((
"Chat-Group-Name-Changed",
mail_builder::headers::text::Text::new(old_name).into(),
));
}
SystemMessage::GroupDescriptionChanged => {
placeholdertext = Some(
"[Chat description changed. To see this and other new features, please update the app]".to_string(),
);
headers.push((
"Chat-Group-Description-Changed",
mail_builder::headers::text::Text::new("").into(),
));
}
SystemMessage::GroupImageChanged => {
placeholdertext = Some("Chat image changed.".to_string());
headers.push((
"Chat-Content",
mail_builder::headers::text::Text::new("group-avatar-changed").into(),
@@ -1681,7 +1508,44 @@ impl MimeFactory {
));
}
}
_ => {}
SystemMessage::Unknown => {}
SystemMessage::AutocryptSetupMessage => {}
SystemMessage::SecurejoinMessage => {}
SystemMessage::LocationStreamingEnabled => {}
SystemMessage::LocationOnly => {}
SystemMessage::EphemeralTimerChanged => {}
SystemMessage::ChatProtectionEnabled => {}
SystemMessage::ChatProtectionDisabled => {}
SystemMessage::InvalidUnencryptedMail => {}
SystemMessage::SecurejoinWait => {}
SystemMessage::SecurejoinWaitTimeout => {}
SystemMessage::MultiDeviceSync => {}
SystemMessage::WebxdcStatusUpdate => {}
SystemMessage::WebxdcInfoMessage => {}
SystemMessage::IrohNodeAddr => {}
SystemMessage::ChatE2ee => {}
SystemMessage::CallAccepted => {}
SystemMessage::CallEnded => {}
}
if command == SystemMessage::GroupDescriptionChanged
|| command == SystemMessage::MemberAddedToGroup
|| msg
.param
.get_bool(Param::AttachChatAvatarAndDescription)
.unwrap_or_default()
{
let description = chat::get_chat_description(context, chat.id).await?;
headers.push((
"Chat-Group-Description",
mail_builder::headers::text::Text::new(description.clone()).into(),
));
if let Some(ts) = chat.param.get_i64(Param::GroupDescriptionTimestamp) {
headers.push((
"Chat-Group-Description-Timestamp",
mail_builder::headers::text::Text::new(ts.to_string()).into(),
));
}
}
}
@@ -1702,13 +1566,10 @@ impl MimeFactory {
| SystemMessage::MultiDeviceSync
| SystemMessage::WebxdcStatusUpdate => {
// This should prevent automatic replies,
// such as non-delivery reports.
// such as non-delivery reports,
// if the message is unencrypted.
//
// See <https://tools.ietf.org/html/rfc3834>
//
// Adding this header without encryption leaks some
// information about the message contents, but it can
// already be easily guessed from message timing and size.
headers.push((
"Auto-Submitted",
mail_builder::headers::raw::Raw::new("auto-generated").into(),
@@ -2043,6 +1904,7 @@ impl MimeFactory {
}
/// Render an MDN
#[expect(clippy::arithmetic_side_effects)]
fn render_mdn(&mut self) -> Result<MimePart<'static>> {
// RFC 6522, this also requires the `report-type` parameter which is equal
// to the MIME subtype of the second body part of the multipart/report
@@ -2108,6 +1970,258 @@ impl MimeFactory {
}
}
/// Stores the unprotected headers on the outer message, and renders it.
pub(crate) fn render_outer_message(
unprotected_headers: Vec<(&'static str, HeaderType<'static>)>,
outer_message: MimePart<'static>,
) -> String {
let outer_message = unprotected_headers
.into_iter()
.fold(outer_message, |message, (header, value)| {
message.header(header, value)
});
let mut buffer = Vec::new();
let cursor = Cursor::new(&mut buffer);
outer_message.clone().write_part(cursor).ok();
String::from_utf8_lossy(&buffer).to_string()
}
/// Takes the encrypted part, wraps it in a MimePart,
/// and sets the appropriate Content-Type for the outer message
pub(crate) fn wrap_encrypted_part(encrypted: String) -> MimePart<'static> {
// XXX: additional newline is needed
// to pass filtermail at
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
let encrypted = encrypted + "\n";
MimePart::new(
"multipart/encrypted; protocol=\"application/pgp-encrypted\"",
vec![
// Autocrypt part 1
MimePart::new("application/pgp-encrypted", "Version: 1\r\n").header(
"Content-Description",
mail_builder::headers::raw::Raw::new("PGP/MIME version identification"),
),
// Autocrypt part 2
MimePart::new(
"application/octet-stream; name=\"encrypted.asc\"",
encrypted,
)
.header(
"Content-Description",
mail_builder::headers::raw::Raw::new("OpenPGP encrypted message"),
)
.header(
"Content-Disposition",
mail_builder::headers::raw::Raw::new("inline; filename=\"encrypted.asc\";"),
),
],
)
}
fn add_headers_to_encrypted_part(
message: MimePart<'static>,
unprotected_headers: &[(&'static str, HeaderType<'static>)],
hidden_headers: Vec<(&'static str, HeaderType<'static>)>,
protected_headers: Vec<(&'static str, HeaderType<'static>)>,
use_std_header_protection: bool,
) -> MimePart<'static> {
// Store protected headers in the inner message.
let message = protected_headers
.into_iter()
.fold(message, |message, (header, value)| {
message.header(header, value)
});
// Add hidden headers to encrypted payload.
let mut message: MimePart<'static> = hidden_headers
.into_iter()
.fold(message, |message, (header, value)| {
message.header(header, value)
});
if use_std_header_protection {
message = unprotected_headers
.iter()
// Structural headers shouldn't be added as "HP-Outer". They are defined in
// <https://www.rfc-editor.org/rfc/rfc9787.html#structural-header-fields>.
.filter(|(name, _)| {
!(name.eq_ignore_ascii_case("mime-version")
|| name.eq_ignore_ascii_case("content-type")
|| name.eq_ignore_ascii_case("content-transfer-encoding")
|| name.eq_ignore_ascii_case("content-disposition"))
})
.fold(message, |message, (name, value)| {
message.header(format!("HP-Outer: {name}"), value.clone())
});
}
// Set the appropriate Content-Type for the inner message
for (h, v) in &mut message.headers {
if h == "Content-Type"
&& let mail_builder::headers::HeaderType::ContentType(ct) = v
{
let mut ct_new = ct.clone();
ct_new = ct_new.attribute("protected-headers", "v1");
if use_std_header_protection {
ct_new = ct_new.attribute("hp", "cipher");
}
*ct = ct_new;
break;
}
}
message
}
struct HeadersByConfidentiality {
/// Headers that must go into IMF header section.
///
/// These are standard headers such as Date, In-Reply-To, References, which cannot be placed
/// anywhere else according to the standard. Placing headers here also allows them to be fetched
/// individually over IMAP without downloading the message body. This is why Chat-Version is
/// placed here.
unprotected_headers: Vec<(&'static str, HeaderType<'static>)>,
/// Headers that MUST NOT (only) go into IMF header section:
/// - Large headers which may hit the header section size limit on the server, such as
/// Chat-User-Avatar with a base64-encoded image inside.
/// - Headers duplicated here that servers mess up with in the IMF header section, like
/// Message-ID.
/// - Nonstandard headers that should be DKIM-protected because e.g. OpenDKIM only signs
/// known headers.
///
/// The header should be hidden from MTA
/// by moving it either into protected part
/// in case of encrypted mails
/// or unprotected MIME preamble in case of unencrypted mails.
hidden_headers: Vec<(&'static str, HeaderType<'static>)>,
/// Opportunistically protected headers.
///
/// These headers are placed into encrypted part *if* the message is encrypted. Place headers
/// which are not needed before decryption (e.g. Chat-Group-Name) or are not interesting if the
/// message cannot be decrypted (e.g. Chat-Disposition-Notification-To) here.
///
/// If the message is not encrypted, these headers are placed into IMF header section, so make
/// sure that the message will be encrypted if you place any sensitive information here.
protected_headers: Vec<(&'static str, HeaderType<'static>)>,
}
/// Split headers based on header confidentiality policy.
/// See [`HeadersByConfidentiality`] for more info.
fn group_headers_by_confidentiality(
headers: Vec<(&'static str, HeaderType<'static>)>,
from_addr: &str,
timestamp: i64,
is_encrypted: bool,
is_securejoin_message: bool,
) -> HeadersByConfidentiality {
let mut unprotected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
let mut hidden_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
let mut protected_headers: Vec<(&'static str, HeaderType<'static>)> = Vec::new();
// MIME header <https://datatracker.ietf.org/doc/html/rfc2045>.
unprotected_headers.push((
"MIME-Version",
mail_builder::headers::raw::Raw::new("1.0").into(),
));
for header @ (original_header_name, _header_value) in &headers {
let header_name = original_header_name.to_lowercase();
if header_name == "message-id" {
unprotected_headers.push(header.clone());
hidden_headers.push(header.clone());
} else if is_hidden(&header_name) {
hidden_headers.push(header.clone());
} else if header_name == "from" {
// Unencrypted securejoin messages should _not_ include the display name:
if is_encrypted || !is_securejoin_message {
protected_headers.push(header.clone());
}
unprotected_headers.push((
original_header_name,
Address::new_address(None::<&'static str>, from_addr.to_string()).into(),
));
} else if header_name == "to" {
protected_headers.push(header.clone());
if is_encrypted {
unprotected_headers.push(("To", hidden_recipients().into()));
} else {
unprotected_headers.push(header.clone());
}
} else if header_name == "chat-broadcast-secret" {
if is_encrypted {
protected_headers.push(header.clone());
}
} else if is_encrypted && header_name == "date" {
protected_headers.push(header.clone());
// Randomized date goes to unprotected header.
//
// We cannot just send "Thu, 01 Jan 1970 00:00:00 +0000"
// or omit the header because GMX then fails with
//
// host mx00.emig.gmx.net[212.227.15.9] said:
// 554-Transaction failed
// 554-Reject due to policy restrictions.
// 554 For explanation visit https://postmaster.gmx.net/en/case?...
// (in reply to end of DATA command)
//
// and the explanation page says
// "The time information deviates too much from the actual time".
//
// We also limit the range to 6 days (518400 seconds)
// because with a larger range we got
// error "500 Date header far in the past/future"
// which apparently originates from Symantec Messaging Gateway
// and means the message has a Date that is more
// than 7 days in the past:
// <https://github.com/chatmail/core/issues/7466>
let timestamp_offset = rand::random_range(0..518400);
let protected_timestamp = timestamp.saturating_sub(timestamp_offset);
let unprotected_date =
chrono::DateTime::<chrono::Utc>::from_timestamp(protected_timestamp, 0)
.unwrap()
.to_rfc2822();
unprotected_headers.push((
"Date",
mail_builder::headers::raw::Raw::new(unprotected_date).into(),
));
} else if is_encrypted {
protected_headers.push(header.clone());
match header_name.as_str() {
"subject" => {
unprotected_headers.push((
"Subject",
mail_builder::headers::raw::Raw::new("[...]").into(),
));
}
"chat-version" | "autocrypt-setup-message" | "chat-is-post-message" => {
unprotected_headers.push(header.clone());
}
_ => {
// Other headers are removed from unprotected part.
}
}
} else {
// Copy the header to the protected headers
// in case of signed-only message.
// If the message is not signed, this value will not be used.
protected_headers.push(header.clone());
unprotected_headers.push(header.clone())
}
}
HeadersByConfidentiality {
unprotected_headers,
hidden_headers,
protected_headers,
}
}
fn hidden_recipients() -> Address<'static> {
Address::new_group(Some("hidden-recipients".to_string()), Vec::new())
}
@@ -2215,5 +2329,115 @@ fn b_encode(value: &str) -> String {
)
}
pub(crate) async fn render_symm_encrypted_securejoin_message(
context: &Context,
step: &str,
rfc724_mid: &str,
attach_self_pubkey: bool,
auth: &str,
shared_secret: &str,
) -> Result<String> {
info!(context, "Sending secure-join message {step:?}.");
let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new();
let from_addr = context.get_primary_self_addr().await?;
let from = new_address_with_name("", from_addr.to_string());
headers.push(("From", from.into()));
let to: Vec<Address<'static>> = vec![hidden_recipients()];
headers.push((
"To",
mail_builder::headers::address::Address::new_list(to.clone()).into(),
));
headers.push((
"Subject",
mail_builder::headers::text::Text::new("Secure-Join".to_string()).into(),
));
let timestamp = create_smeared_timestamp(context);
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp, 0)
.unwrap()
.to_rfc2822();
headers.push(("Date", mail_builder::headers::raw::Raw::new(date).into()));
headers.push((
"Message-ID",
mail_builder::headers::message_id::MessageId::new(rfc724_mid.to_string()).into(),
));
// Automatic Response headers <https://www.rfc-editor.org/rfc/rfc3834>
if context.get_config_bool(Config::Bot).await? {
headers.push((
"Auto-Submitted",
mail_builder::headers::raw::Raw::new("auto-generated".to_string()).into(),
));
}
let encrypt_helper = EncryptHelper::new(context).await?;
if attach_self_pubkey {
let aheader = encrypt_helper.get_aheader().to_string();
headers.push((
"Autocrypt",
mail_builder::headers::raw::Raw::new(aheader).into(),
));
}
headers.push((
"Secure-Join",
mail_builder::headers::raw::Raw::new(step.to_string()).into(),
));
headers.push((
"Secure-Join-Auth",
mail_builder::headers::text::Text::new(auth.to_string()).into(),
));
let message: MimePart<'static> = MimePart::new("text/plain", "Secure-Join");
let is_encrypted = true;
let is_securejoin_message = true;
let HeadersByConfidentiality {
unprotected_headers,
hidden_headers,
protected_headers,
} = group_headers_by_confidentiality(
headers,
&from_addr,
timestamp,
is_encrypted,
is_securejoin_message,
);
let outer_message = {
let use_std_header_protection = true;
let message = add_headers_to_encrypted_part(
message,
&unprotected_headers,
hidden_headers,
protected_headers,
use_std_header_protection,
);
// Disable compression for SecureJoin to ensure
// there are no compression side channels
// leaking information about the tokens.
let compress = false;
// Only sign the message if we attach the pubkey.
let sign = attach_self_pubkey;
let encrypted = encrypt_helper
.encrypt_symmetrically(context, shared_secret, message, compress, sign)
.await?;
wrap_encrypted_part(encrypted)
};
let message = render_outer_message(unprotected_headers, outer_message);
Ok(message)
}
#[cfg(test)]
mod mimefactory_tests;

View File

@@ -6,7 +6,7 @@ use std::path::Path;
use std::str;
use std::str::FromStr;
use anyhow::{Context as _, Result, bail};
use anyhow::{Context as _, Result, bail, ensure};
use deltachat_contact_tools::{addr_cmp, addr_normalize, sanitize_bidi_characters};
use deltachat_derive::{FromSql, ToSql};
use format_flowed::unformat_flowed;
@@ -19,14 +19,14 @@ use crate::blob::BlobObject;
use crate::chat::ChatId;
use crate::config::Config;
use crate::constants;
use crate::contact::ContactId;
use crate::contact::{ContactId, import_public_key};
use crate::context::Context;
use crate::decrypt::{try_decrypt, validate_detached_signature};
use crate::decrypt::{self, validate_detached_signature};
use crate::dehtml::dehtml;
use crate::download::PostMsgMetadata;
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
use crate::key::{self, DcKey, Fingerprint, SignedPublicKey, load_self_secret_keyring};
use crate::key::{self, DcKey, Fingerprint, SignedPublicKey};
use crate::log::warn;
use crate::message::{self, Message, MsgId, Viewtype, get_vcard_summary, set_msg_failed};
use crate::param::{Param, Params};
@@ -186,10 +186,10 @@ pub enum SystemMessage {
#[default]
Unknown = 0,
/// Group name changed.
/// Group or broadcast channel name changed.
GroupNameChanged = 2,
/// Group avatar changed.
/// Group or broadcast channel avatar changed.
GroupImageChanged = 3,
/// Member was added to the group.
@@ -254,6 +254,9 @@ pub enum SystemMessage {
/// Message indicating that a call was ended.
CallEnded = 67,
/// Group or broadcast channel description changed.
GroupDescriptionChanged = 70,
}
const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup";
@@ -263,6 +266,7 @@ impl MimeMessage {
///
/// This method has some side-effects,
/// such as saving blobs and saving found public keys to the database.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn from_bytes(context: &Context, body: &[u8]) -> Result<Self> {
let mail = mailparse::parse_mail(body)?;
@@ -355,10 +359,10 @@ impl MimeMessage {
// Remove headers that are allowed _only_ in the encrypted+signed part. It's ok to leave
// them in signed-only emails, but has no value currently.
Self::remove_secured_headers(&mut headers, &mut headers_removed);
let encrypted = false;
Self::remove_secured_headers(&mut headers, &mut headers_removed, encrypted);
let mut from = from.context("No from in message")?;
let private_keyring = load_self_secret_keyring(context).await?;
let dkim_results = handle_authres(context, &mail, &from.addr).await?;
@@ -382,57 +386,53 @@ impl MimeMessage {
let mail_raw; // Memory location for a possible decrypted message.
let decrypted_msg; // Decrypted signed OpenPGP message.
let secrets: Vec<String> = context
.sql
.query_map_vec("SELECT secret FROM broadcast_secrets", (), |row| {
let secret: String = row.get(0)?;
Ok(secret)
})
.await?;
let expected_sender_fingerprint: Option<String>;
let (mail, is_encrypted) =
match tokio::task::block_in_place(|| try_decrypt(&mail, &private_keyring, &secrets)) {
Ok(Some(mut msg)) => {
mail_raw = msg.as_data_vec().unwrap_or_default();
let (mail, is_encrypted) = match decrypt::decrypt(context, &mail).await {
Ok(Some((mut msg, expected_sender_fp))) => {
mail_raw = msg.as_data_vec().unwrap_or_default();
let decrypted_mail = mailparse::parse_mail(&mail_raw)?;
if std::env::var(crate::DCC_MIME_DEBUG).is_ok() {
info!(
context,
"decrypted message mime-body:\n{}",
String::from_utf8_lossy(&mail_raw),
);
}
decrypted_msg = Some(msg);
timestamp_sent = Self::get_timestamp_sent(
&decrypted_mail.headers,
timestamp_sent,
timestamp_rcvd,
let decrypted_mail = mailparse::parse_mail(&mail_raw)?;
if std::env::var(crate::DCC_MIME_DEBUG).is_ok() {
info!(
context,
"decrypted message mime-body:\n{}",
String::from_utf8_lossy(&mail_raw),
);
}
let protected_aheader_values = decrypted_mail
.headers
.get_all_values(HeaderDef::Autocrypt.into());
if !protected_aheader_values.is_empty() {
aheader_values = protected_aheader_values;
}
decrypted_msg = Some(msg);
(Ok(decrypted_mail), true)
timestamp_sent = Self::get_timestamp_sent(
&decrypted_mail.headers,
timestamp_sent,
timestamp_rcvd,
);
let protected_aheader_values = decrypted_mail
.headers
.get_all_values(HeaderDef::Autocrypt.into());
if !protected_aheader_values.is_empty() {
aheader_values = protected_aheader_values;
}
Ok(None) => {
mail_raw = Vec::new();
decrypted_msg = None;
(Ok(mail), false)
}
Err(err) => {
mail_raw = Vec::new();
decrypted_msg = None;
warn!(context, "decryption failed: {:#}", err);
(Err(err), false)
}
};
expected_sender_fingerprint = expected_sender_fp;
(Ok(decrypted_mail), true)
}
Ok(None) => {
mail_raw = Vec::new();
decrypted_msg = None;
expected_sender_fingerprint = None;
(Ok(mail), false)
}
Err(err) => {
mail_raw = Vec::new();
decrypted_msg = None;
expected_sender_fingerprint = None;
warn!(context, "decryption failed: {:#}", err);
(Err(err), false)
}
};
let mut autocrypt_header = None;
if incoming {
@@ -458,22 +458,9 @@ impl MimeMessage {
let autocrypt_fingerprint = if let Some(autocrypt_header) = &autocrypt_header {
let fingerprint = autocrypt_header.public_key.dc_fingerprint().hex();
let inserted = context
.sql
.execute(
"INSERT INTO public_keys (fingerprint, public_key)
VALUES (?, ?)
ON CONFLICT (fingerprint)
DO NOTHING",
(&fingerprint, autocrypt_header.public_key.to_bytes()),
)
.await?;
if inserted > 0 {
info!(
context,
"Saved key with fingerprint {fingerprint} from the Autocrypt header"
);
}
import_public_key(context, &autocrypt_header.public_key)
.await
.context("Failed to import public key from the Autocrypt header")?;
Some(fingerprint)
} else {
None
@@ -496,8 +483,7 @@ impl MimeMessage {
// We don't decompress messages compressed multiple times.
None
}
Some(pgp::composed::Message::SignedOnePass { reader, .. }) => reader.signature(),
Some(pgp::composed::Message::Signed { reader, .. }) => Some(reader.signature()),
Some(pgp::composed::Message::Signed { reader, .. }) => reader.signature(0),
Some(pgp::composed::Message::Encrypted { .. }) => {
// The message is already decrypted once.
None
@@ -543,6 +529,22 @@ impl MimeMessage {
signatures.extend(signatures_detached);
content
});
if let Some(expected_sender_fingerprint) = expected_sender_fingerprint {
ensure!(
!signatures.is_empty(),
"Unsigned message is not allowed to be encrypted with this shared secret"
);
ensure!(
signatures.len() == 1,
"Too many signatures on symm-encrypted message"
);
ensure!(
signatures.contains_key(&expected_sender_fingerprint.parse()?),
"This sender is not allowed to encrypt with this secret key"
);
}
if let (Ok(mail), true) = (mail, is_encrypted) {
if !signatures.is_empty() {
// Unsigned "Subject" mustn't be prepended to messages shown as encrypted
@@ -606,7 +608,7 @@ impl MimeMessage {
}
}
if signatures.is_empty() {
Self::remove_secured_headers(&mut headers, &mut headers_removed);
Self::remove_secured_headers(&mut headers, &mut headers_removed, is_encrypted);
}
if !is_encrypted {
signatures.clear();
@@ -726,6 +728,7 @@ impl MimeMessage {
Ok(parser)
}
#[expect(clippy::arithmetic_side_effects)]
fn get_timestamp_sent(
hdrs: &[mailparse::MailHeader<'_>],
default: i64,
@@ -774,6 +777,11 @@ impl MimeMessage {
self.is_system_message = SystemMessage::MemberAddedToGroup;
} else if self.get_header(HeaderDef::ChatGroupNameChanged).is_some() {
self.is_system_message = SystemMessage::GroupNameChanged;
} else if self
.get_header(HeaderDef::ChatGroupDescriptionChanged)
.is_some()
{
self.is_system_message = SystemMessage::GroupDescriptionChanged;
}
}
@@ -998,6 +1006,7 @@ impl MimeMessage {
Ok(())
}
#[expect(clippy::arithmetic_side_effects)]
fn avatar_action_from_header(
&mut self,
context: &Context,
@@ -1499,6 +1508,7 @@ impl MimeMessage {
}
#[expect(clippy::too_many_arguments)]
#[expect(clippy::arithmetic_side_effects)]
async fn do_add_single_file_part(
&mut self,
context: &Context,
@@ -1636,24 +1646,12 @@ impl MimeMessage {
}
Ok(key) => key,
};
if let Err(err) = key.verify() {
warn!(context, "Attached PGP key verification failed: {err:#}.");
if let Err(err) = import_public_key(context, &key).await {
warn!(context, "Attached PGP key import failed: {err:#}.");
return Ok(false);
}
let fingerprint = key.dc_fingerprint().hex();
context
.sql
.execute(
"INSERT INTO public_keys (fingerprint, public_key)
VALUES (?, ?)
ON CONFLICT (fingerprint)
DO NOTHING",
(&fingerprint, key.to_bytes()),
)
.await?;
info!(context, "Imported PGP key {fingerprint} from attachment.");
info!(context, "Imported PGP key from attachment.");
Ok(true)
}
@@ -1711,20 +1709,37 @@ impl MimeMessage {
.and_then(|msgid| parse_message_id(msgid).ok())
}
/// Remove headers that are not allowed in unsigned / unencrypted messages.
///
/// Pass `encrypted=true` parameter for an encrypted, but unsigned message.
/// Pass `encrypted=false` parameter for an unencrypted message.
/// Don't call this function if the message was encrypted and signed.
fn remove_secured_headers(
headers: &mut HashMap<String, String>,
removed: &mut HashSet<String>,
encrypted: bool,
) {
remove_header(headers, "secure-join-fingerprint", removed);
remove_header(headers, "secure-join-auth", removed);
remove_header(headers, "chat-verified", removed);
remove_header(headers, "autocrypt-gossip", removed);
// Secure-Join is secured unless it is an initial "vc-request"/"vg-request".
if let Some(secure_join) = remove_header(headers, "secure-join", removed)
&& (secure_join == "vc-request" || secure_join == "vg-request")
{
headers.insert("secure-join".to_string(), secure_join);
if headers.get("secure-join") == Some(&"vc-request-pubkey".to_string()) && encrypted {
// vc-request-pubkey message is encrypted, but unsigned,
// and contains a Secure-Join-Auth header.
//
// It is unsigned in order not to leak Bob's identity to a server operator
// that scraped the AUTH token somewhere from the web,
// and because Alice anyways couldn't verify his signature at this step,
// because she doesn't know his public key yet.
} else {
remove_header(headers, "secure-join-auth", removed);
// Secure-Join is secured unless it is an initial "vc-request"/"vg-request".
if let Some(secure_join) = remove_header(headers, "secure-join", removed)
&& (secure_join == "vc-request" || secure_join == "vg-request")
{
headers.insert("secure-join".to_string(), secure_join);
}
}
}
@@ -2050,6 +2065,7 @@ impl MimeMessage {
/// Returns parsed `Chat-Group-Member-Timestamps` header contents.
///
/// Returns `None` if there is no such header.
#[expect(clippy::arithmetic_side_effects)]
pub fn chat_group_member_timestamps(&self) -> Option<Vec<i64>> {
let now = time() + constants::TIMESTAMP_SENT_TOLERANCE;
self.get_header(HeaderDef::ChatGroupMemberTimestamps)
@@ -2144,17 +2160,9 @@ async fn parse_gossip_headers(
continue;
}
let fingerprint = header.public_key.dc_fingerprint().hex();
context
.sql
.execute(
"INSERT INTO public_keys (fingerprint, public_key)
VALUES (?, ?)
ON CONFLICT (fingerprint)
DO NOTHING",
(&fingerprint, header.public_key.to_bytes()),
)
.await?;
import_public_key(context, &header.public_key)
.await
.context("Failed to import Autocrypt-Gossip key")?;
let gossiped_key = GossipedKey {
public_key: header.public_key,
@@ -2592,3 +2600,5 @@ async fn handle_ndn(
#[cfg(test)]
mod mimeparser_tests;
#[cfg(test)]
mod shared_secret_decryption_tests;

View File

@@ -10,6 +10,7 @@ use crate::{
key,
message::{MessageState, MessengerMessage},
receive_imf::receive_imf,
securejoin::QrInvite,
test_utils::{TestContext, TestContextManager},
tools::time,
};
@@ -2156,3 +2157,27 @@ Third alternative.
assert_eq!(message.parts[0].typ, Viewtype::Text);
assert_eq!(message.parts[0].msg, "Third alternative.");
}
/// Tests that loading a bobstate from an old version of Delta Chat
/// (that doesn't have the is_v3 attribute)
/// doesn't fail
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_load_shared_secrets_with_legacy_state() -> Result<()> {
let alice = &TestContext::new_alice().await;
alice.sql.execute(
r#"INSERT INTO bobstate (invite, next_step, chat_id)
VALUES ('{"Contact":{"contact_id":10,"fingerprint":[111,111,111,11,111,11,111,111,111,11,11,111,11,111,111,111,111,111,11,111],"invitenumber":"xxxxxxxxxxxxxxxxxxxxxxxx","authcode":"yyyyyyyyyyyyyyyyyyyyyyyy"}}', 0, 10)"#,
()
).await?;
let qr: QrInvite = alice
.sql
.query_get_value("SELECT invite FROM bobstate", ())
.await
.unwrap()
.unwrap();
assert_eq!(qr.is_v3(), false);
Ok(())
}

View File

@@ -0,0 +1,258 @@
use super::*;
use crate::chat::{create_broadcast, load_broadcast_secret};
use crate::constants::DC_CHAT_ID_TRASH;
use crate::key::{load_self_secret_key, self_fingerprint};
use crate::pgp;
use crate::qr::{Qr, check_qr};
use crate::receive_imf::receive_imf;
use crate::securejoin::{get_securejoin_qr, join_securejoin};
use crate::test_utils::{TestContext, TestContextManager};
use anyhow::Result;
/// Tests that the following attack isn't possible:
///
/// 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 defeat this, a message that was unexpectedly
/// encrypted with a symmetric secret must be dropped.
async fn test_shared_secret_decryption_ex(
recipient_ctx: &TestContext,
from_addr: &str,
secret_for_encryption: &str,
signer_ctx: Option<&TestContext>,
expected_error: Option<&str>,
) -> Result<()> {
let plain_body = "Hello, this is a secure message.";
let plain_text = format!("Content-Type: text/plain; charset=utf-8\r\n\r\n{plain_body}");
let previous_highest_msg_id = get_highest_msg_id(recipient_ctx).await;
let signer_key = if let Some(signer_ctx) = signer_ctx {
Some(load_self_secret_key(signer_ctx).await?)
} else {
None
};
if let Some(signer_ctx) = signer_ctx {
// The recipient needs to know the signer's pubkey
// in order to be able to validate the pubkey:
recipient_ctx.add_or_lookup_contact(signer_ctx).await;
}
let encrypted_msg = pgp::symm_encrypt_message(
plain_text.as_bytes().to_vec(),
signer_key,
secret_for_encryption,
true,
)
.await?;
let boundary = "boundary123";
let rcvd_mail = format!(
"Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"{boundary}\"\n\
From: {from}\n\
To: \"hidden-recipients\": ;\n\
Subject: [...]\n\
MIME-Version: 1.0\n\
Message-ID: <12345@example.org>\n\
\n\
--{boundary}\n\
Content-Type: application/pgp-encrypted\n\
\n\
Version: 1\n\
\n\
--{boundary}\n\
Content-Type: application/octet-stream; name=\"encrypted.asc\"\n\
Content-Disposition: inline; filename=\"encrypted.asc\"\n\
\n\
{encrypted_msg}\n\
--{boundary}--\n",
from = from_addr,
boundary = boundary,
encrypted_msg = encrypted_msg
);
let rcvd = receive_imf(recipient_ctx, rcvd_mail.as_bytes(), false)
.await
.expect("If receive_imf() adds an error here, then Bob may be notified about the error and tell the attacker, leaking that he knows the secret")
.expect("A trashed message should be created, otherwise we'll unnecessarily download it again");
if let Some(error_pattern) = expected_error {
assert!(rcvd.chat_id == DC_CHAT_ID_TRASH);
assert_eq!(
previous_highest_msg_id,
get_highest_msg_id(recipient_ctx).await,
"receive_imf() must not add any message. Otherwise, Bob may send something about an error to the attacker, leaking that he knows the secret"
);
let EventType::Warning(warning) = recipient_ctx
.evtracker
.get_matching(|ev| matches!(ev, EventType::Warning(_)))
.await
else {
unreachable!()
};
assert!(warning.contains(error_pattern), "Wrong warning: {warning}");
} else {
let msg = recipient_ctx.get_last_msg().await;
assert_eq!(&[msg.id], rcvd.msg_ids.as_slice());
assert_eq!(msg.text, plain_body);
assert_eq!(rcvd.chat_id.is_special(), false);
}
Ok(())
}
async fn get_highest_msg_id(context: &Context) -> MsgId {
context
.sql
.query_get_value(
"SELECT MAX(id) FROM msgs WHERE chat_id!=?",
(DC_CHAT_ID_TRASH,),
)
.await
.unwrap()
.unwrap_or_default()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_security_attacker_signature() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await; // Attacker
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;
let secret = load_broadcast_secret(alice, alice_chat_id).await?.unwrap();
let charlie_addr = charlie.get_config(Config::Addr).await?.unwrap();
test_shared_secret_decryption_ex(
bob,
&charlie_addr,
&secret,
Some(charlie),
Some("This sender is not allowed to encrypt with this secret key"),
)
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_security_no_signature() -> 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;
let secret = load_broadcast_secret(alice, alice_chat_id).await?.unwrap();
test_shared_secret_decryption_ex(
bob,
"attacker@example.org",
&secret,
None,
Some("Unsigned message is not allowed to be encrypted with this shared secret"),
)
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_security_happy_path() -> 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;
let secret = load_broadcast_secret(alice, alice_chat_id).await?.unwrap();
let alice_addr = alice
.get_config(crate::config::Config::Addr)
.await?
.unwrap();
test_shared_secret_decryption_ex(bob, &alice_addr, &secret, Some(alice), None).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_qr_code_security() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await; // Attacker
let qr = get_securejoin_qr(alice, None).await?;
let Qr::AskVerifyContact { authcode, .. } = check_qr(bob, &qr).await? else {
unreachable!()
};
// Start a securejoin process, but don't finish it:
join_securejoin(bob, &qr).await?;
let charlie_addr = charlie.get_config(Config::Addr).await?.unwrap();
let alice_fp = self_fingerprint(alice).await?;
let secret_for_encryption = dbg!(format!("securejoin/{alice_fp}/{authcode}"));
test_shared_secret_decryption_ex(
bob,
&charlie_addr,
&secret_for_encryption,
Some(charlie),
Some("This sender is not allowed to encrypt with this secret key"),
)
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_qr_code_happy_path() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let qr = get_securejoin_qr(alice, None).await?;
let Qr::AskVerifyContact { authcode, .. } = check_qr(bob, &qr).await? else {
unreachable!()
};
// Start a securejoin process, but don't finish it:
join_securejoin(bob, &qr).await?;
let alice_fp = self_fingerprint(alice).await?;
let secret_for_encryption = format!("securejoin/{alice_fp}/{authcode}");
test_shared_secret_decryption_ex(
bob,
"alice@example.net",
&secret_for_encryption,
Some(alice),
None,
)
.await
}
/// Control: Test that the behavior is the same when the shared secret is unknown
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unknown_secret() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
test_shared_secret_decryption_ex(
bob,
"alice@example.net",
"Some secret unknown to Bob",
Some(alice),
Some("Could not find symmetric secret for session key"),
)
.await
}

View File

@@ -109,8 +109,8 @@ pub(crate) async fn connect_tcp_inner(
) -> Result<Pin<Box<TimeoutStream<TcpStream>>>> {
let tcp_stream = timeout(TIMEOUT, TcpStream::connect(addr))
.await
.context("connection timeout")?
.context("connection failure")?;
.context("Connection timeout")?
.context("Connection failure")?;
// Disable Nagle's algorithm.
tcp_stream.set_nodelay(true)?;

View File

@@ -118,7 +118,7 @@ where
fn http_url_cache_timestamps(url: &str, mimetype: Option<&str>) -> (i64, i64) {
let now = time();
let expires = now + 3600 * 24 * 35;
let expires = now.saturating_add(3600 * 24 * 35);
let stale = if url.ends_with(".xdc") {
// WebXDCs are never stale, they just expire.
expires
@@ -128,19 +128,19 @@ fn http_url_cache_timestamps(url: &str, mimetype: Option<&str>) -> (i64, i64) {
// Policy at <https://operations.osmfoundation.org/policies/tiles/>
// requires that we cache tiles for at least 7 days.
// Do not revalidate earlier than that.
now + 3600 * 24 * 7
now.saturating_add(3600 * 24 * 7)
} else if mimetype.is_some_and(|s| s.starts_with("image/")) {
// Cache images for 1 day.
//
// As of 2024-12-12 WebXDC icons at <https://webxdc.org/apps/>
// use the same path for all app versions,
// so may change, but it is not critical if outdated icon is displayed.
now + 3600 * 24
now.saturating_add(3600 * 24)
} else {
// Revalidate everything else after 1 hour.
//
// This includes HTML, CSS and JS.
now + 3600
now.saturating_add(3600)
};
(expires, stale)
}
@@ -173,6 +173,7 @@ async fn http_cache_put(context: &Context, url: &str, response: &Response) -> Re
/// Retrieves the binary from HTTP cache.
///
/// Also returns if the response is stale and should be revalidated in the background.
#[expect(clippy::arithmetic_side_effects)]
async fn http_cache_get(context: &Context, url: &str) -> Result<Option<(Response, bool)>> {
let now = time();
let Some((blob_name, mimetype, encoding, stale_timestamp)) = context

View File

@@ -174,6 +174,7 @@ pub enum ProxyConfig {
}
/// Constructs HTTP/1.1 `CONNECT` request for HTTP(S) proxy.
#[expect(clippy::arithmetic_side_effects)]
fn http_connect_request(host: &str, port: u16, auth: Option<(&str, &str)>) -> String {
// According to <https://datatracker.ietf.org/doc/html/rfc7230#section-5.4>
// clients MUST send `Host:` header in HTTP/1.1 requests,
@@ -322,6 +323,7 @@ impl ProxyConfig {
/// config into `proxy_url` if `proxy_url` is unset or empty.
///
/// Unsets `socks5_host`, `socks5_port`, `socks5_user` and `socks5_password` in any case.
#[expect(clippy::arithmetic_side_effects)]
async fn migrate_socks_config(sql: &Sql) -> Result<()> {
if sql.get_raw_config("proxy_url").await?.is_none() {
// Load legacy SOCKS5 settings.

View File

@@ -67,6 +67,7 @@ pub async fn get_oauth2_url(
}
}
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn get_oauth2_access_token(
context: &Context,
addr: &str,
@@ -256,6 +257,7 @@ pub(crate) async fn get_oauth2_addr(
}
impl Oauth2 {
#[expect(clippy::arithmetic_side_effects)]
fn from_address(addr: &str) -> Option<Self> {
let addr_normalized = normalize_addr(addr);
if let Some(domain) = addr_normalized

View File

@@ -140,7 +140,7 @@ pub enum Param {
Arg4 = b'H',
/// For Messages
AttachGroupImage = b'A',
AttachChatAvatarAndDescription = b'A',
/// For Messages
WebrtcRoom = b'V',
@@ -219,6 +219,9 @@ pub enum Param {
/// For Chats: timestamp of group name update.
GroupNameTimestamp = b'g',
/// For Chats: timestamp of chat description update.
GroupDescriptionTimestamp = b'6',
/// For Chats: timestamp of member list update.
MemberListTimestamp = b'k',

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