Compare commits

...

80 Commits

Author SHA1 Message Date
WofWca
5c1ee5dc1a feat!: fire MsgRead on subsequent MDNs
BREAKING CHANGE: previously this event only fired
if message state really did just transition
from `DC_STATE_OUT_DELIVERED` to `DC_STATE_OUT_MDN_RCVD`.
Now this is only the case for `MsgRead` events
that have the newly added `first_time == true`.

Closes https://github.com/deltachat/deltachat-desktop/issues/5220.

This is also useful for channels
as it facilitates updating the post (message) read count live.

Despite the fact that it's a breaking change,
this should not be problematic in most cases
because clients mostly use this event as an "it's time to reload"
indicator.

There is a case in Delta Chat Desktop where and adjustment
will be needed:
d1fbb30979/packages/frontend/src/stores/messagelist.ts (L119-L123)
It seems that the message state could later transition
to `DC_STATE_OUT_FAILED`, so we should not uncoditionally set it
to `DC_STATE_OUT_MDN_RCVD`.

Note that this does not expose `first_time` in CFFI.
2026-03-12 20:34:20 +04: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
117 changed files with 3519 additions and 2090 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@eac588ad8def6316056a12d4907a9d4d84ff7a3b
- 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,58 @@
# Changelog
## [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
@@ -403,7 +456,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
@@ -7767,3 +7820,4 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[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

286
Cargo.lock generated
View File

@@ -124,12 +124,9 @@ checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc"
[[package]]
name = "anyhow"
version = "1.0.100"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
dependencies = [
"backtrace",
]
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
@@ -180,7 +177,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
"synstructure",
]
@@ -192,7 +189,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -274,9 +271,9 @@ dependencies = [
[[package]]
name = "async-imap"
version = "0.11.1"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8da885da5980f3934831e6370445c0e0e44ef251d7792308b39e908915a41d09"
checksum = "a78dceaba06f029d8f4d7df20addd4b7370a30206e3926267ecda2915b0f3f66"
dependencies = [
"async-channel 2.5.0",
"async-compression",
@@ -308,12 +305,12 @@ dependencies = [
[[package]]
name = "async-native-tls"
version = "0.5.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9343dc5acf07e79ff82d0c37899f079db3534d99f189a1837c8e549c99405bec"
checksum = "37dd6b179962fe4048a6f81d4c0d7ed419a21fdf49204b4c6b04971693358e79"
dependencies = [
"native-tls",
"thiserror 1.0.69",
"thiserror 2.0.18",
"tokio",
"url",
]
@@ -342,7 +339,7 @@ checksum = "644dd749086bf3771a2fbc5f256fdb982d53f011c7d5d560304eafeecebce79d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -461,7 +458,7 @@ checksum = "57413e4b276d883b77fb368b7b33ae6a5eb97692852d49a5394d4f72ba961827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
"thiserror 2.0.18",
]
@@ -473,9 +470,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.9.1"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bitvec"
@@ -601,7 +598,7 @@ dependencies = [
"proc-macro-crate 2.0.0",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1039,9 +1036,9 @@ dependencies = [
[[package]]
name = "criterion"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d883447757bb0ee46f233e9dc22eb84d93a9508c9b868687b274fc431d886bf"
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
dependencies = [
"alloca",
"anes",
@@ -1065,9 +1062,9 @@ dependencies = [
[[package]]
name = "criterion-plot"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4"
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
dependencies = [
"cast",
"itertools",
@@ -1119,7 +1116,7 @@ version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
"crossterm_winapi",
"parking_lot",
"rustix 0.38.44",
@@ -1231,7 +1228,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1272,7 +1269,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1283,7 +1280,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
dependencies = [
"darling_core",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1303,7 +1300,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.43.0"
version = "2.44.0-dev"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1371,8 +1368,8 @@ dependencies = [
"sha2",
"shadowsocks",
"smallvec",
"strum 0.27.2",
"strum_macros 0.27.2",
"strum 0.28.0",
"strum_macros 0.28.0",
"tagger",
"tempfile",
"testdir",
@@ -1413,7 +1410,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.43.0"
version = "2.44.0-dev"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1434,7 +1431,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "2.43.0"
version = "2.44.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1450,7 +1447,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.43.0"
version = "2.44.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1474,12 +1471,12 @@ name = "deltachat_derive"
version = "2.0.0"
dependencies = [
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
name = "deltachat_ffi"
version = "2.43.0"
version = "2.44.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1528,7 +1525,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1558,7 +1555,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1568,7 +1565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1597,7 +1594,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
"unicode-xid",
]
@@ -1609,7 +1606,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
"unicode-xid",
]
@@ -1675,7 +1672,7 @@ checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1738,7 +1735,7 @@ checksum = "7a4102713839a8c01c77c165bc38ef2e83948f6397fa1e1dcfacec0f07b149d3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1867,7 +1864,7 @@ dependencies = [
"heck 0.4.1",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1887,7 +1884,7 @@ checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1997,7 +1994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.1.3",
"rustix 1.1.4",
"windows-sys 0.59.0",
]
@@ -2104,9 +2101,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futures"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
@@ -2132,9 +2129,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
@@ -2157,15 +2154,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
@@ -2174,9 +2171,9 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
@@ -2193,32 +2190,32 @@ dependencies = [
[[package]]
name = "futures-macro"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.31"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
@@ -2228,7 +2225,6 @@ dependencies = [
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
@@ -2640,13 +2636,12 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.19"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http 1.1.0",
"http-body",
@@ -2797,7 +2792,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -3087,7 +3082,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -3259,9 +3254,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.180"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libm"
@@ -3275,7 +3270,7 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
"libc",
"redox_syscall 0.5.12",
]
@@ -3309,9 +3304,9 @@ checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
@@ -3368,6 +3363,12 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lru_time_cache"
version = "0.11.11"
@@ -3589,7 +3590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0800eae8638a299eaa67476e1c6b6692922273e0f7939fd188fc861c837b9cd2"
dependencies = [
"anyhow",
"bitflags 2.9.1",
"bitflags 2.11.0",
"byteorder",
"libc",
"log",
@@ -3684,7 +3685,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases",
"libc",
@@ -3789,7 +3790,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -3850,7 +3851,7 @@ dependencies = [
"proc-macro-crate 3.2.0",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -3859,7 +3860,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
]
[[package]]
@@ -3930,7 +3931,7 @@ version = "0.10.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
"cfg-if",
"foreign-types",
"libc",
@@ -3947,7 +3948,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -4134,7 +4135,7 @@ dependencies = [
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -4243,7 +4244,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -4362,7 +4363,7 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -4392,7 +4393,7 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
"crc32fast",
"fdeflate",
"flate2",
@@ -4594,7 +4595,7 @@ dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -4608,11 +4609,11 @@ dependencies = [
[[package]]
name = "proptest"
version = "1.9.0"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40"
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
"num-traits",
"rand 0.9.2",
"rand_chacha 0.9.0",
@@ -4660,9 +4661,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.39.0"
version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2e3bf4aa9d243beeb01a7b3bc30b77cfe2c44e24ec02d751a7104a53c2c49a1"
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
dependencies = [
"memchr",
]
@@ -4687,13 +4688,14 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.9"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.2.16",
"rand 0.8.5",
"getrandom 0.3.3",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
@@ -4895,7 +4897,7 @@ version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
]
[[package]]
@@ -4911,9 +4913,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.12.2"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
@@ -5082,7 +5084,7 @@ version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@@ -5126,7 +5128,7 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys 0.4.14",
@@ -5135,14 +5137,14 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.3"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys 0.11.0",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.1",
]
@@ -5203,7 +5205,7 @@ version = "16.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62fd9ca5ebc709e8535e8ef7c658eb51457987e48c98ead2be482172accc408d"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
"cfg-if",
"clipboard-win",
"fd-lock",
@@ -5282,7 +5284,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -5330,7 +5332,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
"core-foundation",
"core-foundation-sys",
"libc",
@@ -5414,7 +5416,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -5425,7 +5427,7 @@ checksum = "330f01ce65a3a5fe59a60c82f3c9a024b573b8a6e875bd233fe5f934e71d54e3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -5649,7 +5651,7 @@ version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
]
[[package]]
@@ -5691,7 +5693,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -5774,9 +5776,9 @@ dependencies = [
[[package]]
name = "strum"
version = "0.27.2"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
[[package]]
name = "strum_macros"
@@ -5788,19 +5790,19 @@ dependencies = [
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -5871,9 +5873,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.114"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -5897,7 +5899,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -5934,7 +5936,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
"core-foundation",
"system-configuration-sys",
]
@@ -5969,14 +5971,14 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tempfile"
version = "3.24.0"
version = "3.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix 1.1.3",
"rustix 1.1.4",
"windows-sys 0.61.1",
]
@@ -6032,7 +6034,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -6043,7 +6045,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -6158,7 +6160,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -6358,7 +6360,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -6408,7 +6410,7 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -6453,7 +6455,7 @@ dependencies = [
"proc-macro-error2",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -6654,7 +6656,7 @@ dependencies = [
"log",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
"wasm-bindgen-shared",
]
@@ -6689,7 +6691,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@@ -6872,7 +6874,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -6883,7 +6885,7 @@ checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -6894,7 +6896,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -6905,7 +6907,7 @@ checksum = "cb26fd936d991781ea39e87c3a27285081e3c0da5ca0fcbc02d368cc6f52ff01"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -7217,7 +7219,7 @@ version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags 2.9.1",
"bitflags 2.11.0",
]
[[package]]
@@ -7364,7 +7366,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -7387,7 +7389,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
"synstructure",
]
@@ -7415,7 +7417,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -7435,7 +7437,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
"synstructure",
]
@@ -7456,7 +7458,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -7478,7 +7480,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.43.0"
version = "2.44.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 }
@@ -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.to_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.43.0"
version = "2.44.0-dev"
description = "Deltachat FFI"
edition = "2018"
readme = "README.md"

View File

@@ -4612,6 +4612,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.
@@ -4667,6 +4668,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
/**
@@ -7494,7 +7496,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."
@@ -7577,6 +7579,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.
@@ -7609,6 +7624,17 @@ void dc_event_unref(dc_event_t* event);
/// "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};
@@ -5150,10 +5151,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.43.0"
version = "2.44.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.
@@ -1970,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?;
@@ -1983,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
@@ -2496,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

@@ -192,8 +192,7 @@ pub enum EventType {
msg_id: u32,
},
/// A single message is read by the receiver. State changed from DC_STATE_OUT_DELIVERED to
/// DC_STATE_OUT_MDN_RCVD, see `Message.state`.
/// A single message is read by a receiver.
#[serde(rename_all = "camelCase")]
MsgRead {
/// ID of the chat which the message belongs to.
@@ -201,6 +200,12 @@ pub enum EventType {
/// ID of the message that was read.
msg_id: u32,
/// Read for the first time (e.g. by just one group member
/// / channel subscriber).
/// State changed from DC_STATE_OUT_DELIVERED to
/// DC_STATE_OUT_MDN_RCVD, see dc_msg_get_state().
first_time: bool,
},
/// A single message was deleted.
@@ -540,9 +545,14 @@ impl From<CoreEventType> for EventType {
chat_id: chat_id.to_u32(),
msg_id: msg_id.to_u32(),
},
CoreEventType::MsgRead { chat_id, msg_id } => MsgRead {
CoreEventType::MsgRead {
chat_id,
msg_id,
first_time,
} => MsgRead {
chat_id: chat_id.to_u32(),
msg_id: msg_id.to_u32(),
first_time,
},
CoreEventType::MsgDeleted { chat_id, msg_id } => MsgDeleted {
chat_id: chat_id.to_u32(),

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.43.0"
"version": "2.44.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.43.0"
version = "2.44.0-dev"
license = "MPL-2.0"
edition = "2021"
repository = "https://github.com/chatmail/core"

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.43.0"
version = "2.44.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

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

@@ -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.43.0"
version = "2.44.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.43.0"
"version": "2.44.0-dev"
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.43.0"
version = "2.44.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

@@ -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-17
2026-02-27

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_bindings()
.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

@@ -79,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)
}
@@ -175,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),

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;
@@ -257,7 +257,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 +475,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 +945,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 +1150,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?
@@ -1730,6 +1736,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,
@@ -1769,16 +1776,6 @@ impl Chat {
.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?;
@@ -2995,6 +2992,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!(
@@ -3100,6 +3098,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,
@@ -3889,8 +3888,6 @@ 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
@@ -3898,11 +3895,7 @@ pub(crate) async fn add_contact_to_chat_ex(
.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.
@@ -3951,20 +3944,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() {
@@ -3978,6 +3957,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
@@ -4242,20 +4222,19 @@ async fn set_chat_description_ex(
bail!("Cannot set chat description; self not in group");
}
let affected_rows = context
let old_description = get_chat_description(context, chat_id).await?;
if old_description == new_description {
return Ok(());
}
context
.sql
.execute(
"INSERT INTO chats_descriptions(chat_id, description) VALUES(?, ?)
ON CONFLICT(chat_id) DO UPDATE
SET description=excluded.description WHERE description<>excluded.description",
"INSERT OR REPLACE INTO chats_descriptions(chat_id, description) VALUES(?, ?)",
(chat_id, &new_description),
)
.await?;
if affected_rows == 0 {
return Ok(());
}
if chat.is_promoted() {
let mut msg = Message::new(Viewtype::Text);
msg.text = stock_str::msg_chat_description_changed(context, ContactId::SELF).await;
@@ -4343,8 +4322,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);
@@ -4405,7 +4387,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,
@@ -4415,7 +4401,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() {
@@ -4433,6 +4423,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],
@@ -4563,6 +4554,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,
@@ -4655,7 +4647,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;
}
@@ -4741,6 +4732,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>,
@@ -5050,18 +5042,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,
@@ -5078,12 +5070,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(())
}

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?;
@@ -3158,29 +3177,59 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_description_basic() {
test_chat_description("", false).await.unwrap()
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)
.await
.unwrap()
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).await.unwrap()
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)
.await
.unwrap()
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) -> Result<()> {
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;
@@ -3190,12 +3239,29 @@ async fn test_chat_description(initial_description: &str, join_via_qr: bool) ->
alice2.set_config_bool(Config::SyncMsgs, true).await?;
tcm.section("Create a group chat, and add Bob");
let alice_chat_id = create_group(alice, "My Group").await?;
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;
}
}
sync(alice, alice2).await;
let alice2_chat_id = get_chat_id_by_grpid(
alice2,
@@ -3223,7 +3289,7 @@ async fn test_chat_description(initial_description: &str, join_via_qr: bool) ->
initial_description
);
for description in ["This is a cool group", "", "ä ẟ 😂"] {
for description in ["This is a cool chat", "", "ä ẟ 😂"] {
tcm.section(&format!(
"Alice sets the chat description to '{description}'"
));
@@ -3235,6 +3301,11 @@ async fn test_chat_description(initial_description: &str, join_via_qr: bool) ->
);
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.");
@@ -3269,6 +3340,36 @@ async fn test_chat_description(initial_description: &str, join_via_qr: bool) ->
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)]
@@ -3299,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;
@@ -3583,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,
@@ -3608,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;
@@ -3638,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;
@@ -3705,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);
@@ -3807,7 +3908,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?;
@@ -3821,7 +3922,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();
@@ -4623,6 +4724,9 @@ async fn test_sync_broadcast_and_send_message() -> Result<()> {
vec![a2b_contact_id]
);
// alice2's smeared clock may be behind alice's one, so "hi" from alice2 may appear before "You
// joined the channel." for bob.
SystemTime::shift(Duration::from_secs(1));
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;
@@ -4687,7 +4791,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)
@@ -4757,6 +4861,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

@@ -590,11 +590,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);
@@ -609,7 +612,17 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
ctx.sql.set_raw_config("mvbox_move", Some("0")).await?;
ctx.sql.set_raw_config("only_fetch_mvbox", None).await?;
}
if !ctx.get_config_bool(Config::FixIsChatmail).await? {
if imap_session.is_chatmail() {
ctx.sql.set_raw_config("is_chatmail", Some("1")).await?;
} else if !is_configured {
// Reset the setting that may have been set
// during failed configuration.
ctx.sql.set_raw_config("is_chatmail", Some("0")).await?;
}
}
drop(imap_session);
drop(imap);
progress!(ctx, 910);

View File

@@ -673,6 +673,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 +1072,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 +1344,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
};
@@ -1909,6 +1911,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 +2003,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 +2045,7 @@ impl RecentlySeenLoop {
}
}
#[expect(clippy::arithmetic_side_effects)]
async fn run(context: Context, interrupt: Receiver<RecentlySeenInterrupt>) {
type MyHeapElem = (Reverse<i64>, ContactId);

View File

@@ -1145,8 +1145,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

@@ -239,6 +239,9 @@ pub struct InnerContext {
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<()>,
pub(crate) translated_stockstrings: StockStrings,
pub(crate) events: Events,
@@ -342,6 +345,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();
@@ -477,6 +481,7 @@ impl Context {
generating_key_mutex: Mutex::new(()),
oauth2_mutex: Mutex::new(()),
wrong_pw_warning_mutex: Mutex::new(()),
housekeeping_mutex: Mutex::new(()),
translated_stockstrings: stockstrings,
events,
scheduler: SchedulerState::new(),

View File

@@ -1,34 +1,28 @@
//! 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 ::pgp::composed::Message;
use anyhow::Result;
use mailparse::ParsedMail;
use crate::key::{Fingerprint, SignedPublicKey, SignedSecretKey};
use crate::key::{Fingerprint, SignedPublicKey};
use crate::pgp;
/// Tries to decrypt a message, but only if it is structured as an Autocrypt message.
///
/// If successful and the message was encrypted,
/// returns the decrypted and decompressed message.
pub fn try_decrypt<'a>(
mail: &'a ParsedMail<'a>,
private_keyring: &'a [SignedSecretKey],
shared_secrets: &[String],
) -> Result<Option<::pgp::composed::Message<'static>>> {
pub fn get_encrypted_pgp_message<'a>(mail: &'a ParsedMail<'a>) -> Result<Option<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)?;
let cursor = Cursor::new(data);
let (msg, _headers) = Message::from_armor(cursor)?;
Ok(Some(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))

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

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

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

@@ -171,14 +171,19 @@ pub enum EventType {
msg_id: MsgId,
},
/// A single message is read by the receiver. State changed from DC_STATE_OUT_DELIVERED to
/// DC_STATE_OUT_MDN_RCVD, see dc_msg_get_state().
/// A single message is read by a receiver.
MsgRead {
/// ID of the chat which the message belongs to.
chat_id: ChatId,
/// ID of the message that was read.
msg_id: MsgId,
/// Read for the first time (e.g. by just one group member
/// / channel subscriber).
/// State changed from DC_STATE_OUT_DELIVERED to
/// DC_STATE_OUT_MDN_RCVD, see dc_msg_get_state().
first_time: bool,
},
/// A single message was deleted.

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,7 +27,7 @@ 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::{self, Blocked, DC_VERSION_STR};
use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
@@ -208,6 +207,7 @@ impl<T: Iterator<Item = (i64, u32, String)>> Iterator for UidGrouper<T> {
// Tuple of folder, row IDs, and UID range as a string.
type Item = (String, Vec<i64>, String);
#[expect(clippy::arithmetic_side_effects)]
fn next(&mut self) -> Option<Self::Item> {
let (_, _, folder) = self.inner.peek().cloned()?;
@@ -356,10 +356,10 @@ impl Imap {
context,
self.proxy_config.clone(),
self.strict_tls,
connection_candidate,
&connection_candidate,
)
.await
.context("IMAP failed to connect")
.with_context(|| format!("IMAP failed to connect to {connection_candidate}"))
{
Ok(client) => client,
Err(err) => {
@@ -543,6 +543,7 @@ impl Imap {
/// Fetches new messages.
///
/// Returns true if at least one message was fetched.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn fetch_new_messages(
&mut self,
context: &Context,
@@ -583,6 +584,7 @@ impl Imap {
}
/// Returns number of messages processed and whether the function should be called again.
#[expect(clippy::arithmetic_side_effects)]
async fn fetch_new_msg_batch(
&mut self,
context: &Context,
@@ -1265,6 +1267,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,
@@ -1429,6 +1432,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;
@@ -2126,16 +2130,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),
@@ -2148,28 +2147,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?
.is_some_and(|parent| match parent.is_dc_message {
MessengerMessage::No => false,
MessengerMessage::Yes | MessengerMessage::Reply => true,
});
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)
}
@@ -2364,6 +2342,7 @@ async fn should_ignore_folder(
/// Builds a list of sequence/uid sets. The returned sets have each no more than around 1000
/// characters because according to <https://tools.ietf.org/html/rfc2683#section-3.2.1.5>
/// command lines should not be much more than 1000 chars (servers should allow at least 8000 chars)
#[expect(clippy::arithmetic_side_effects)]
fn build_sequence_sets(uids: &[u32]) -> Result<Vec<(Vec<u32>, String)>> {
// first, try to find consecutive ranges:
let mut ranges: Vec<UidRange> = vec![];

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

@@ -21,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\
)])";
@@ -127,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, 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.to_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().legacy_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

@@ -10,13 +10,12 @@ use deltachat_contact_tools::EmailAddress;
use pgp::composed::Deserializable;
pub use pgp::composed::{SignedPublicKey, SignedSecretKey};
use pgp::ser::Serialize;
use pgp::types::{KeyDetails, KeyId};
use pgp::types::KeyDetails;
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 +112,16 @@ 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;
}
/// Attempts to load own public key.
///
/// Returns `None` if no 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 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 +134,9 @@ 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 signed_public_key = signed_secret_key.to_public_key();
Ok(Some(signed_public_key))
}
/// Loads own public key.
@@ -149,8 +146,8 @@ 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)
let signed_secret_key = generate_keypair(context).await?;
Ok(signed_secret_key.to_public_key())
}
}
}
@@ -215,8 +212,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 +259,6 @@ impl DcKey for SignedPublicKey {
fn dc_fingerprint(&self) -> Fingerprint {
self.fingerprint().into()
}
fn key_id(&self) -> KeyId {
KeyDetails::legacy_key_id(self)
}
}
impl DcKey for SignedSecretKey {
@@ -289,13 +282,9 @@ impl DcKey for SignedSecretKey {
fn dc_fingerprint(&self) -> Fingerprint {
self.fingerprint().into()
}
fn key_id(&self) -> KeyId {
KeyDetails::legacy_key_id(&**self)
}
}
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;
@@ -321,51 +310,46 @@ 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<()> {
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,
@@ -403,9 +387,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.to_public_key();
let keypair = KeyPair { public, secret };
store_self_keypair(context, &keypair).await?;
store_self_keypair(context, &secret).await?;
Ok(())
}
@@ -484,7 +466,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() {
@@ -554,12 +536,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);
@@ -567,8 +549,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");
@@ -610,7 +592,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);
@@ -624,7 +606,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);

View File

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

@@ -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;
@@ -2064,6 +2067,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
@@ -2119,6 +2138,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

@@ -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?;
@@ -455,9 +456,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?;
}
@@ -726,6 +734,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 +878,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 +946,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 +1008,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 +1121,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 +1168,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 +1204,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 +1271,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 +1416,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 +1441,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,6 +1466,7 @@ 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",
@@ -1670,12 +1474,16 @@ impl MimeFactory {
));
}
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(),
@@ -1687,7 +1495,24 @@ 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
@@ -1728,13 +1553,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(),
@@ -2069,6 +1891,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
@@ -2134,6 +1957,258 @@ impl MimeFactory {
}
}
/// Stores the unprotected headers on the outer message, and renders it.
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
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())
}
@@ -2241,5 +2316,114 @@ 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,
) -> 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, auth, 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

@@ -18,10 +18,9 @@ use crate::authres::handle_authres;
use crate::blob::BlobObject;
use crate::chat::ChatId;
use crate::config::Config;
use crate::constants;
use crate::contact::ContactId;
use crate::context::Context;
use crate::decrypt::{try_decrypt, validate_detached_signature};
use crate::decrypt::{get_encrypted_pgp_message, validate_detached_signature};
use crate::dehtml::dehtml;
use crate::download::PostMsgMetadata;
use crate::events::EventType;
@@ -36,6 +35,7 @@ use crate::tools::{
get_filemeta, parse_receive_headers, smeared_time, time, truncate_msg_text, validate_id,
};
use crate::{chatlist_events, location, tools};
use crate::{constants, token};
/// Public key extracted from `Autocrypt-Gossip`
/// header with associated information.
@@ -266,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)?;
@@ -358,7 +359,8 @@ 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?;
@@ -383,59 +385,64 @@ impl MimeMessage {
PreMessageMode::None
};
let encrypted_pgp_message = get_encrypted_pgp_message(&mail)?;
let secrets: Vec<String>;
if let Some(e) = &encrypted_pgp_message
&& crate::pgp::check_symmetric_encryption(e).is_ok()
{
secrets = load_shared_secrets(context).await?;
} else {
secrets = vec![];
}
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 (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 tokio::task::block_in_place(|| {
encrypted_pgp_message.map(|e| crate::pgp::decrypt(e, &private_keyring, &secrets))
}) {
Some(Ok(mut msg)) => {
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)
}
};
(Ok(decrypted_mail), true)
}
None => {
mail_raw = Vec::new();
decrypted_msg = None;
(Ok(mail), false)
}
Some(Err(err)) => {
mail_raw = Vec::new();
decrypted_msg = None;
warn!(context, "decryption failed: {:#}", err);
(Err(err), false)
}
};
let mut autocrypt_header = None;
if incoming {
@@ -608,7 +615,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();
@@ -728,6 +735,7 @@ impl MimeMessage {
Ok(parser)
}
#[expect(clippy::arithmetic_side_effects)]
fn get_timestamp_sent(
hdrs: &[mailparse::MailHeader<'_>],
default: i64,
@@ -1005,6 +1013,7 @@ impl MimeMessage {
Ok(())
}
#[expect(clippy::arithmetic_side_effects)]
fn avatar_action_from_header(
&mut self,
context: &Context,
@@ -1506,6 +1515,7 @@ impl MimeMessage {
}
#[expect(clippy::too_many_arguments)]
#[expect(clippy::arithmetic_side_effects)]
async fn do_add_single_file_part(
&mut self,
context: &Context,
@@ -1718,20 +1728,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);
}
}
}
@@ -2057,6 +2084,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)
@@ -2082,6 +2110,35 @@ impl MimeMessage {
}
}
/// Loads all the shared secrets
/// that will be tried to decrypt a symmetrically-encrypted message
async fn load_shared_secrets(context: &Context) -> Result<Vec<String>> {
// First, try decrypting using the bobstate,
// because usually there will only be 1 or 2 of it,
// so, it should be fast
let mut secrets: Vec<String> = context
.sql
.query_map_vec("SELECT invite FROM bobstate", (), |row| {
let invite: crate::securejoin::QrInvite = row.get(0)?;
Ok(invite.authcode().to_string())
})
.await?;
// Then, try decrypting using broadcast secrets
secrets.extend(
context
.sql
.query_map_vec("SELECT secret FROM broadcast_secrets", (), |row| {
let secret: String = row.get(0)?;
Ok(secret)
})
.await?,
);
// Finally, try decrypting using AUTH tokens
// There can be a lot of AUTH tokens, because a new one is generated every time a QR code is shown
secrets.extend(token::lookup_all(context, token::Namespace::Auth).await?);
Ok(secrets)
}
fn rm_legacy_display_elements(text: &str) -> String {
let mut res = None;
for l in text.lines() {
@@ -2539,8 +2596,12 @@ async fn handle_mdn(
(msg_id, from_id, timestamp_sent),
)
.await?;
context.emit_event(EventType::MsgRead {
chat_id,
msg_id,
first_time: !has_mdns,
});
if !has_mdns {
context.emit_event(EventType::MsgRead { chat_id, msg_id });
// note(treefit): only matters if it is the last message in chat (but probably too expensive to check, debounce also solves it)
chatlist_events::emit_chatlist_item_changed(context, chat_id);
}

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,30 @@ 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?;
// This call must not fail:
load_shared_secrets(alice).await.unwrap();
let qr: QrInvite = alice
.sql
.query_get_value("SELECT invite FROM bobstate", ())
.await
.unwrap()
.unwrap();
assert_eq!(qr.is_v3(), false);
Ok(())
}

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

@@ -533,6 +533,7 @@ pub(crate) fn iroh_topic_from_str(topic: &str) -> Result<TopicId> {
Ok(topic)
}
#[expect(clippy::arithmetic_side_effects)]
async fn subscribe_loop(
context: &Context,
mut stream: iroh_gossip::net::GossipReceiver,

View File

@@ -63,34 +63,11 @@ pub fn split_armored_data(buf: &[u8]) -> Result<(BlockType, BTreeMap<String, Str
Ok((typ, headers, bytes))
}
/// A PGP keypair.
///
/// This has it's own struct to be able to keep the public and secret
/// keys together as they are one unit.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct KeyPair {
/// Public key.
pub public: SignedPublicKey,
/// Secret key.
pub secret: SignedSecretKey,
}
impl KeyPair {
/// Creates new keypair from a secret key.
///
/// Public key is split off the secret key.
pub fn new(secret: SignedSecretKey) -> Result<Self> {
let public = secret.to_public_key();
Ok(Self { public, secret })
}
}
/// Create a new key pair.
///
/// Both secret and public key consist of signing primary key and encryption subkey
/// as [described in the Autocrypt standard](https://autocrypt.org/level1.html#openpgp-based-key-data).
pub(crate) fn create_keypair(addr: EmailAddress) -> Result<KeyPair> {
pub(crate) fn create_keypair(addr: EmailAddress) -> Result<SignedSecretKey> {
let signing_key_type = PgpKeyType::Ed25519Legacy;
let encryption_key_type = PgpKeyType::ECDH(ECCCurve::Curve25519);
@@ -99,6 +76,7 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<KeyPair> {
.key_type(signing_key_type)
.can_certify(true)
.can_sign(true)
.feature_seipd_v2(true)
.primary_user_id(user_id)
.passphrase(None)
.preferred_symmetric_algorithms(smallvec![
@@ -135,12 +113,7 @@ pub(crate) fn create_keypair(addr: EmailAddress) -> Result<KeyPair> {
.verify_bindings()
.context("Invalid secret key generated")?;
let key_pair = KeyPair::new(secret_key)?;
key_pair
.public
.verify_bindings()
.context("Invalid public key generated")?;
Ok(key_pair)
Ok(secret_key)
}
/// Selects a subkey of the public key to use for encryption.
@@ -170,6 +143,7 @@ pub enum SeipdVersion {
/// Encrypts `plain` text using `public_keys_for_encryption`
/// and signs it using `private_key_for_signing`.
#[expect(clippy::arithmetic_side_effects)]
pub async fn pk_encrypt(
plain: Vec<u8>,
public_keys_for_encryption: Vec<SignedPublicKey>,
@@ -327,13 +301,10 @@ pub fn pk_calc_signature(
///
/// Returns the decrypted and decompressed message.
pub fn decrypt(
ctext: Vec<u8>,
msg: Message<'static>,
private_keys_for_decryption: &[SignedSecretKey],
mut shared_secrets: &[String],
) -> Result<pgp::composed::Message<'static>> {
let cursor = Cursor::new(ctext);
let (msg, _headers) = Message::from_armor(cursor)?;
let skeys: Vec<&SignedSecretKey> = private_keys_for_decryption.iter().collect();
let empty_pw = Password::empty();
@@ -389,7 +360,9 @@ pub fn decrypt(
/// 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'.
fn check_symmetric_encryption(msg: &Message<'_>) -> std::result::Result<(), &'static str> {
pub(crate) fn check_symmetric_encryption(
msg: &Message<'_>,
) -> std::result::Result<(), &'static str> {
let Message::Encrypted { esk, .. } = msg else {
return Err("not encrypted");
};
@@ -480,7 +453,7 @@ pub async fn symm_encrypt_autocrypt_setup(passphrase: &str, plain: Vec<u8>) -> R
/// `shared secret` is the secret that will be used for symmetric encryption.
pub async fn symm_encrypt_message(
plain: Vec<u8>,
private_key_for_signing: SignedSecretKey,
private_key_for_signing: Option<SignedSecretKey>,
shared_secret: &str,
compress: bool,
) -> Result<String> {
@@ -503,8 +476,10 @@ pub async fn symm_encrypt_message(
);
msg.encrypt_with_password(&mut rng, s2k, &shared_secret)?;
let hash_algorithm = private_key_for_signing.hash_alg();
msg.sign(&*private_key_for_signing, Password::empty(), hash_algorithm);
if let Some(private_key_for_signing) = private_key_for_signing.as_deref() {
let hash_algorithm = private_key_for_signing.hash_alg();
msg.sign(private_key_for_signing, Password::empty(), hash_algorithm);
}
if compress {
msg.compression(CompressionAlgorithm::ZLIB);
}
@@ -546,6 +521,16 @@ mod tests {
use pgp::composed::Esk;
use pgp::packet::PublicKeyEncryptedSessionKey;
fn decrypt_bytes(
bytes: Vec<u8>,
private_keys_for_decryption: &[SignedSecretKey],
shared_secrets: &[String],
) -> Result<pgp::composed::Message<'static>> {
let cursor = Cursor::new(bytes);
let (msg, _headers) = Message::from_armor(cursor).unwrap();
decrypt(msg, private_keys_for_decryption, shared_secrets)
}
#[expect(clippy::type_complexity)]
fn pk_decrypt_and_validate<'a>(
ctext: &'a [u8],
@@ -556,7 +541,7 @@ mod tests {
HashMap<Fingerprint, Vec<Fingerprint>>,
Vec<u8>,
)> {
let mut msg = decrypt(ctext.to_vec(), private_keys_for_decryption, &[])?;
let mut msg = decrypt_bytes(ctext.to_vec(), private_keys_for_decryption, &[])?;
let content = msg.as_data_vec()?;
let ret_signature_fingerprints =
valid_signature_fingerprints(&msg, public_keys_for_validation);
@@ -595,7 +580,7 @@ mod tests {
fn test_create_keypair() {
let keypair0 = create_keypair(EmailAddress::new("foo@bar.de").unwrap()).unwrap();
let keypair1 = create_keypair(EmailAddress::new("two@zwo.de").unwrap()).unwrap();
assert_ne!(keypair0.public, keypair1.public);
assert_ne!(keypair0.public_key(), keypair1.public_key());
}
/// [SignedSecretKey] and [SignedPublicKey] objects
@@ -612,10 +597,10 @@ mod tests {
let alice = alice_keypair();
let bob = bob_keypair();
TestKeys {
alice_secret: alice.secret.clone(),
alice_public: alice.public,
bob_secret: bob.secret.clone(),
bob_public: bob.public,
alice_secret: alice.clone(),
alice_public: alice.to_public_key(),
bob_secret: bob.clone(),
bob_public: bob.to_public_key(),
}
}
}
@@ -737,14 +722,14 @@ mod tests {
let shared_secret = "shared secret";
let ctext = symm_encrypt_message(
plain.clone(),
load_self_secret_key(alice).await?,
Some(load_self_secret_key(alice).await?),
shared_secret,
true,
)
.await?;
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
let mut decrypted = decrypt(
let mut decrypted = decrypt_bytes(
ctext.into(),
&bob_private_keyring,
&[shared_secret.to_string()],
@@ -787,7 +772,7 @@ mod tests {
// Trying to decrypt it should fail with a helpful error message:
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
let error = decrypt(
let error = decrypt_bytes(
ctext.into(),
&bob_private_keyring,
&[shared_secret.to_string()],
@@ -824,7 +809,7 @@ mod tests {
// Trying to decrypt it should fail with an OK error message:
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
let error = decrypt(ctext.into(), &bob_private_keyring, &[]).unwrap_err();
let error = decrypt_bytes(ctext.into(), &bob_private_keyring, &[]).unwrap_err();
assert_eq!(
error.to_string(),

View File

@@ -24,6 +24,7 @@ pub struct PlainText {
impl PlainText {
/// Convert plain text to HTML.
/// The function handles quotes, links, fixed and floating text paragraphs.
#[expect(clippy::arithmetic_side_effects)]
pub fn to_html(&self) -> String {
static LINKIFY_MAIL_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\b([\w.\-+]+@[\w.\-]+)\b").unwrap());

View File

@@ -61,6 +61,9 @@ pub enum Qr {
/// Authentication code.
authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Ask the user whether to join the group.
@@ -82,6 +85,9 @@ pub enum Qr {
/// Authentication code.
authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Ask whether to join the broadcast channel.
@@ -106,6 +112,9 @@ pub enum Qr {
invitenumber: String,
/// Authentication code.
authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Contact fingerprint is verified.
@@ -483,7 +492,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
let name = decode_name(&param, "n")?.unwrap_or_default();
let invitenumber = param
let mut invitenumber = param
.get("i")
// For historic reansons, broadcasts currently use j instead of i for the invitenumber:
.or_else(|| param.get("j"))
@@ -501,6 +510,16 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
let grpname = decode_name(&param, "g")?;
let broadcast_name = decode_name(&param, "b")?;
let mut is_v3 = param.get("v") == Some(&"3");
if authcode.is_some() && invitenumber.is_none() {
// Securejoin v3 doesn't need an invitenumber.
// We want to remove the invitenumber and the `v=3` parameter eventually;
// therefore, we accept v3 QR codes without an invitenumber.
is_v3 = true;
invitenumber = Some("".to_string());
}
if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
let addr = ContactAddress::new(addr)?;
let (contact_id, _) = Contact::add_or_lookup_ex(
@@ -519,7 +538,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
.await
.with_context(|| format!("can't check if address {addr:?} is our address"))?
{
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
if token::exists(context, token::Namespace::Auth, &authcode).await? {
Ok(Qr::WithdrawVerifyGroup {
grpname,
grpid,
@@ -546,6 +565,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
fingerprint,
invitenumber,
authcode,
is_v3,
})
}
} else if let (Some(grpid), Some(name)) = (grpid, broadcast_name) {
@@ -554,7 +574,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
.await
.with_context(|| format!("Can't check if {addr:?} is our address"))?
{
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
if token::exists(context, token::Namespace::Auth, &authcode).await? {
Ok(Qr::WithdrawJoinBroadcast {
name,
grpid,
@@ -581,10 +601,11 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
fingerprint,
invitenumber,
authcode,
is_v3,
})
}
} else if context.is_self_addr(&addr).await? {
if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
if token::exists(context, token::Namespace::Auth, &authcode).await? {
Ok(Qr::WithdrawVerifyContact {
contact_id,
fingerprint,
@@ -605,6 +626,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
fingerprint,
invitenumber,
authcode,
is_v3,
})
}
} else if let Some(addr) = addr {
@@ -680,6 +702,7 @@ fn decode_account(qr: &str) -> Result<Qr> {
}
/// scheme: `https://t.me/socks?server=foo&port=123` or `https://t.me/socks?server=1.2.3.4&port=123`
#[expect(clippy::arithmetic_side_effects)]
fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
@@ -1021,6 +1044,7 @@ async fn decode_smtp(context: &Context, qr: &str) -> Result<Qr> {
/// Scheme: `MATMSG:TO:addr...;SUB:subject...;BODY:body...;`
///
/// There may or may not be linebreaks after the fields.
#[expect(clippy::arithmetic_side_effects)]
async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
// Does not work when the text `TO:` is used in subject/body _and_ TO: is not the first field.
// we ignore this case.

View File

@@ -81,9 +81,14 @@ pub(super) fn decode_login(qr: &str) -> Result<Qr> {
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
let addr = percent_encoding::percent_decode_str(addr)
.decode_utf8()
.context("Address must be UTF-8")?
.to_string();
// check if username is there
if !may_be_valid_addr(addr) {
bail!("invalid DCLOGIN payload: invalid username E5");
if !may_be_valid_addr(&addr) {
bail!("Invalid DCLOGIN payload: invalid username {addr:?}.");
}
// apply to result struct
@@ -200,9 +205,7 @@ pub(crate) fn login_param_from_login_qr(
#[cfg(test)]
mod test {
use anyhow::bail;
use super::{LoginOptions, decode_login};
use super::*;
use crate::{login_param::EnteredCertificateChecks, provider::Socket, qr::Qr};
macro_rules! login_options_just_pw {
@@ -225,7 +228,7 @@ mod test {
}
#[test]
fn minimal_no_options() -> anyhow::Result<()> {
fn minimal_no_options() -> Result<()> {
let result = decode_login("dclogin://email@host.tld?p=123&v=1")?;
if let Qr::Login { address, options } = result {
assert_eq!(address, "email@host.tld".to_owned());
@@ -250,7 +253,7 @@ mod test {
Ok(())
}
#[test]
fn minimal_no_options_no_double_slash() -> anyhow::Result<()> {
fn minimal_no_options_no_double_slash() -> Result<()> {
let result = decode_login("dclogin:email@host.tld?p=123&v=1")?;
if let Qr::Login { address, options } = result {
assert_eq!(address, "email@host.tld".to_owned());
@@ -289,7 +292,7 @@ mod test {
}
#[test]
fn version_too_new() -> anyhow::Result<()> {
fn version_too_new() -> Result<()> {
let result = decode_login("dclogin:email@host.tld/?p=123456&v=2")?;
if let Qr::Login { options, .. } = result {
assert_eq!(options, LoginOptions::UnsuportedVersion(2));
@@ -306,7 +309,7 @@ mod test {
}
#[test]
fn all_advanced_options() -> anyhow::Result<()> {
fn all_advanced_options() -> Result<()> {
let result = decode_login(
"dclogin:email@host.tld?p=secret&v=1&ih=imap.host.tld&ip=4000&iu=max&ipw=87654&is=ssl&ic=1&sh=mail.host.tld&sp=3000&su=max@host.tld&spw=3242HS&ss=plain&sc=3",
)?;
@@ -336,7 +339,19 @@ mod test {
}
#[test]
fn uri_encoded_password() -> anyhow::Result<()> {
fn uri_encoded_login() -> Result<()> {
let result = decode_login("dclogin:username@%5b192.168.1.1%5d?p=1234&v=1")?;
if let Qr::Login { address, options } = result {
assert_eq!(address, "username@[192.168.1.1]".to_owned());
assert_eq!(options, login_options_just_pw!("1234".to_owned()));
} else {
bail!("wrong type")
}
Ok(())
}
#[test]
fn uri_encoded_password() -> Result<()> {
let result = decode_login(
"dclogin:email@host.tld?p=%7BDaehFl%3B%22as%40%21fhdodn5%24234%22%7B%7Dfg&v=1",
)?;
@@ -353,7 +368,7 @@ mod test {
}
#[test]
fn email_with_plus_extension() -> anyhow::Result<()> {
fn email_with_plus_extension() -> Result<()> {
let result = decode_login("dclogin:usename+extension@host?p=1234&v=1")?;
if let Qr::Login { address, options } = result {
assert_eq!(address, "usename+extension@host".to_owned());
@@ -365,7 +380,7 @@ mod test {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_dclogin_ipv4() -> anyhow::Result<()> {
async fn test_decode_dclogin_ipv4() -> Result<()> {
let result = decode_login("dclogin://test@[127.0.0.1]?p=1234&v=1")?;
if let Qr::Login { address, options } = result {
assert_eq!(address, "test@[127.0.0.1]".to_owned());
@@ -377,7 +392,7 @@ mod test {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_dclogin_ipv6() -> anyhow::Result<()> {
async fn test_decode_dclogin_ipv6() -> Result<()> {
let result =
decode_login("dclogin://test@[2001:0db8:85a3:0000:0000:8a2e:0370:7334]?p=1234&v=1")?;
if let Qr::Login { address, options } = result {

View File

@@ -1,3 +1,5 @@
use regex::Regex;
use super::*;
use crate::chat::{Chat, create_broadcast, create_group, get_chat_contacts};
use crate::config::Config;
@@ -445,9 +447,28 @@ async fn test_decode_openpgp_without_addr() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_withdraw_verifycontact() -> Result<()> {
async fn test_withdraw_verifycontact_basic() -> Result<()> {
test_withdraw_verifycontact(false).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_withdraw_verifycontact_without_invite() -> Result<()> {
test_withdraw_verifycontact(true).await
}
async fn test_withdraw_verifycontact(remove_invite: bool) -> Result<()> {
let alice = TestContext::new_alice().await;
let qr = get_securejoin_qr(&alice, None).await?;
let mut qr = get_securejoin_qr(&alice, None).await?;
if remove_invite {
// Remove the INVITENUBMER. It's not needed in Securejoin v3,
// but still included for backwards compatibility reasons.
// We want to be able to remove it in the future,
// therefore we test that things work without it.
let new_qr = Regex::new("&i=.*?&").unwrap().replace(&qr, "&");
assert!(new_qr != *qr);
qr = new_qr.to_string();
}
// scanning own verify-contact code offers withdrawing
assert!(matches!(
@@ -466,6 +487,11 @@ async fn test_withdraw_verifycontact() -> Result<()> {
check_qr(&alice, &qr).await?,
Qr::WithdrawVerifyContact { .. }
));
// Test that removing the INVITENUMBER doesn't result in saving empty token:
assert_eq!(
token::exists(&alice, token::Namespace::InviteNumber, "").await?,
false
);
// someone else always scans as ask-verify-contact
let bob = TestContext::new_bob().await;

View File

@@ -15,6 +15,7 @@ use crate::securejoin;
use crate::stock_str::{self, backup_transfer_qr};
/// Create a QR code from any input data.
#[expect(clippy::arithmetic_side_effects)]
pub fn create_qr_svg(qrcode_content: &str) -> Result<String> {
let all_size = 512.0;
let qr_code_size = 416.0;
@@ -175,6 +176,7 @@ async fn self_info(context: &Context) -> Result<(Option<Vec<u8>>, String, String
Ok((avatar, displayname, addr, color))
}
#[expect(clippy::arithmetic_side_effects)]
fn inner_generate_secure_join_qr_code(
qrcode_description: &str,
qrcode_content: &str,

View File

@@ -122,6 +122,7 @@ impl Reactions {
}
/// Returns a map from emojis to their frequencies.
#[expect(clippy::arithmetic_side_effects)]
pub fn emoji_frequencies(&self) -> BTreeMap<String, usize> {
let mut emoji_frequencies: BTreeMap<String, usize> = BTreeMap::new();
for reaction in self.reactions.values() {

View File

@@ -31,7 +31,8 @@ use crate::key::{
};
use crate::log::{LogExt as _, warn};
use crate::message::{
self, Message, MessageState, MessengerMessage, MsgId, Viewtype, rfc724_mid_exists,
self, Message, MessageState, MessengerMessage, MsgId, Viewtype, insert_tombstone,
rfc724_mid_exists,
};
use crate::mimeparser::{
AvatarAction, GossipedKey, MimeMessage, PreMessageMode, SystemMessage, parse_message_ids,
@@ -178,22 +179,6 @@ pub(crate) async fn receive_imf_from_inbox(
receive_imf_inner(context, rfc724_mid, imf_raw, seen).await
}
/// Inserts a tombstone into `msgs` table
/// to prevent downloading the same message in the future.
///
/// Returns tombstone database row ID.
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)
}
async fn get_to_and_past_contact_ids(
context: &Context,
mime_parser: &MimeMessage,
@@ -554,9 +539,17 @@ pub(crate) async fn receive_imf_inner(
.await?
.filter(|msg| msg.download_state() != DownloadState::Done)
{
// the message was partially downloaded before and is fully downloaded now.
info!(context, "Message already partly in DB, replacing.");
Some(msg.chat_id)
// The message was partially downloaded before.
match mime_parser.pre_message {
PreMessageMode::Post | PreMessageMode::None => {
info!(context, "Message already partly in DB, replacing.");
Some(msg.chat_id)
}
PreMessageMode::Pre { .. } => {
info!(context, "Cannot replace pre-message with a pre-message");
None
}
}
} else {
// The message was already fully downloaded
// or cannot be loaded because it is deleted.
@@ -887,7 +880,11 @@ UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
.is_some()
{
can_info_msg = false;
Some(Message::load_from_db(context, insert_msg_id).await?)
Some(
Message::load_from_db(context, insert_msg_id)
.await
.context("Failed to load just created webxdc instance")?,
)
} else if let Some(field) = mime_parser.get_header(HeaderDef::InReplyTo) {
if let Some(instance) =
message::get_by_rfc724_mids(context, &parse_message_ids(field)).await?
@@ -1185,6 +1182,7 @@ pub async fn from_field_to_contact_id(
}
}
#[expect(clippy::arithmetic_side_effects)]
async fn decide_chat_assignment(
context: &Context,
mime_parser: &MimeMessage,
@@ -2136,7 +2134,9 @@ async fn add_parts(
}
if let Some(replace_msg_id) = replace_msg_id {
let placeholder = Message::load_from_db(context, replace_msg_id).await?;
let placeholder = Message::load_from_db(context, replace_msg_id)
.await
.context("Failed to load placeholder message")?;
for key in [
Param::WebxdcSummary,
Param::WebxdcSummaryTimestamp,
@@ -2919,6 +2919,7 @@ async fn create_group(
}
}
#[expect(clippy::arithmetic_side_effects)]
async fn update_chats_contacts_timestamps(
context: &Context,
chat_id: ChatId,
@@ -3331,8 +3332,13 @@ async fn apply_chat_name_avatar_and_description_changes(
.is_some()
{
let old_name = &sanitize_single_line(old_name);
better_msg
.get_or_insert(stock_str::msg_grp_name(context, old_name, grpname, from_id).await);
better_msg.get_or_insert(
if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) {
stock_str::msg_broadcast_name_changed(context, old_name, grpname).await
} else {
stock_str::msg_grp_name(context, old_name, grpname, from_id).await
},
);
}
}
@@ -3389,10 +3395,18 @@ async fn apply_chat_name_avatar_and_description_changes(
{
// this is just an explicit message containing the group-avatar,
// apart from that, the group-avatar is send along with various other messages
better_msg.get_or_insert(match avatar_action {
AvatarAction::Delete => stock_str::msg_grp_img_deleted(context, from_id).await,
AvatarAction::Change(_) => stock_str::msg_grp_img_changed(context, from_id).await,
});
better_msg.get_or_insert(
if matches!(chat.typ, Chattype::InBroadcast | Chattype::OutBroadcast) {
stock_str::msg_broadcast_img_changed(context).await
} else {
match avatar_action {
AvatarAction::Delete => stock_str::msg_grp_img_deleted(context, from_id).await,
AvatarAction::Change(_) => {
stock_str::msg_grp_img_changed(context, from_id).await
}
}
},
);
}
if let Some(avatar_action) = &mime_parser.group_avatar {
@@ -3418,6 +3432,7 @@ async fn apply_chat_name_avatar_and_description_changes(
}
/// Returns a list of strings that should be shown as info messages, informing about group membership changes.
#[expect(clippy::arithmetic_side_effects)]
async fn group_changes_msgs(
context: &Context,
added_ids: &HashSet<ContactId>,
@@ -3707,6 +3722,7 @@ async fn apply_out_broadcast_changes(
let mut send_event_chat_modified = false;
let mut better_msg = None;
let mut added_removed_id: Option<ContactId> = None;
if from_id == ContactId::SELF {
apply_chat_name_avatar_and_description_changes(
@@ -3739,6 +3755,7 @@ async fn apply_out_broadcast_changes(
stock_str::msg_add_member_local(context, added_id, ContactId::UNDEFINED)
.await;
better_msg.get_or_insert(msg);
added_removed_id = Some(added_id);
send_event_chat_modified = true;
}
} else {
@@ -3763,6 +3780,7 @@ async fn apply_out_broadcast_changes(
better_msg.get_or_insert(
stock_str::msg_del_member_local(context, removed_id, ContactId::SELF).await,
);
added_removed_id = Some(removed_id);
}
}
@@ -3772,7 +3790,7 @@ async fn apply_out_broadcast_changes(
}
Ok(GroupChangesInfo {
better_msg,
added_removed_id: None,
added_removed_id,
silent: false,
extra_msgs: vec![],
})

View File

@@ -431,14 +431,6 @@ pub async fn convert_folder_meaning(
}
async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session) -> Result<Session> {
if !ctx.get_config_bool(Config::FixIsChatmail).await? {
ctx.set_config_internal(
Config::IsChatmail,
crate::config::from_bool(session.is_chatmail()),
)
.await?;
}
// Update quota no more than once a minute.
if ctx.quota_needs_update(session.transport_id(), 60).await
&& let Err(err) = ctx.update_recent_quota(&mut session).await

View File

@@ -303,6 +303,7 @@ impl Context {
///
/// This comes as an HTML from the core so that we can easily improve it
/// and the improvement instantly reaches all UIs.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_connectivity_html(&self) -> Result<String> {
let mut ret = r#"<!DOCTYPE html>
<html>

View File

@@ -20,14 +20,14 @@ use crate::headerdef::HeaderDef;
use crate::key::{DcKey, Fingerprint, load_self_public_key};
use crate::log::LogExt as _;
use crate::log::warn;
use crate::message::{Message, Viewtype};
use crate::message::{self, Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::param::Param;
use crate::qr::check_qr;
use crate::securejoin::bob::JoinerProgress;
use crate::sync::Sync::*;
use crate::tools::{create_id, time};
use crate::{SecurejoinSource, stats};
use crate::tools::{create_id, create_outgoing_rfc724_mid, time};
use crate::{SecurejoinSource, mimefactory, stats};
use crate::{SecurejoinUiPath, token};
mod bob;
@@ -127,9 +127,6 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
None => None,
};
let grpid = chat.as_ref().map(|c| c.grpid.as_str());
let sync_token = token::lookup(context, Namespace::InviteNumber, grpid)
.await?
.is_none();
// Invite number is used to request the inviter key.
let invitenumber = token::lookup_or_new(context, Namespace::InviteNumber, grpid).await?;
@@ -156,12 +153,10 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
.unwrap_or_default();
let qr = if let Some(chat) = chat {
if sync_token {
context
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
.await?;
context.scheduler.interrupt_smtp().await;
}
context
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
.await?;
context.scheduler.interrupt_smtp().await;
let chat_name = chat.get_name();
let chat_name_shortened = shorten_name(chat_name, 25);
@@ -178,11 +173,11 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
if chat.typ == Chattype::OutBroadcast {
// For historic reansons, broadcasts currently use j instead of i for the invitenumber.
format!(
"https://i.delta.chat/#{fingerprint}&x={grpid}&j={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&b={chat_name_urlencoded}",
"https://i.delta.chat/#{fingerprint}&v=3&x={grpid}&j={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&b={chat_name_urlencoded}",
)
} else {
format!(
"https://i.delta.chat/#{fingerprint}&x={grpid}&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&g={chat_name_urlencoded}",
"https://i.delta.chat/#{fingerprint}&v=3&x={grpid}&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}&g={chat_name_urlencoded}",
)
}
} else {
@@ -190,12 +185,12 @@ pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Resul
let self_name_urlencoded = utf8_percent_encode(&self_name_shortened, DISALLOWED_CHARACTERS)
.to_string()
.replace("%20", "+");
if sync_token {
context.sync_qr_code_tokens(None).await?;
context.scheduler.interrupt_smtp().await;
}
context.sync_qr_code_tokens(None).await?;
context.scheduler.interrupt_smtp().await;
format!(
"https://i.delta.chat/#{fingerprint}&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}",
"https://i.delta.chat/#{fingerprint}&v=3&i={invitenumber}&s={auth}&a={self_addr_urlencoded}&n={self_name_urlencoded}",
)
};
@@ -309,7 +304,9 @@ async fn verify_sender_by_fingerprint(
fingerprint: &Fingerprint,
contact_id: ContactId,
) -> Result<bool> {
let contact = Contact::get_by_id(context, contact_id).await?;
let Some(contact) = Contact::get_by_id_optional(context, contact_id).await? else {
return Ok(false);
};
let is_verified = contact.fingerprint().is_some_and(|fp| &fp == fingerprint);
if is_verified {
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
@@ -346,12 +343,18 @@ pub(crate) enum HandshakeMessage {
/// Step of Secure-Join protocol.
#[derive(Debug, Display, PartialEq, Eq)]
pub(crate) enum SecureJoinStep {
/// vc-request or vg-request
/// vc-request or vg-request; only used in legacy securejoin
Request { invitenumber: String },
/// vc-auth-required or vg-auth-required
/// vc-auth-required or vg-auth-required; only used in legacy securejoin
AuthRequired,
/// vc-request-pubkey; only used in securejoin v3
RequestPubkey,
/// vc-pubkey; only used in securejoin v3
Pubkey,
/// vc-request-with-auth or vg-request-with-auth
RequestWithAuth,
@@ -381,6 +384,8 @@ pub(crate) fn get_secure_join_step(mime_message: &MimeMessage) -> Option<SecureJ
})
} else if let Some(step) = mime_message.get_header(HeaderDef::SecureJoin) {
match step {
"vc-request-pubkey" => Some(SecureJoinStep::RequestPubkey),
"vc-pubkey" => Some(SecureJoinStep::Pubkey),
"vg-auth-required" | "vc-auth-required" => Some(SecureJoinStep::AuthRequired),
"vg-request-with-auth" | "vc-request-with-auth" => {
Some(SecureJoinStep::RequestWithAuth)
@@ -410,6 +415,7 @@ pub(crate) fn get_secure_join_step(mime_message: &MimeMessage) -> Option<SecureJ
///
/// When `handle_securejoin_handshake()` is called, the message is not yet filed in the
/// database; this is done by `receive_imf()` later on as needed.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn handle_securejoin_handshake(
context: &Context,
mime_message: &mut MimeMessage,
@@ -438,7 +444,10 @@ pub(crate) async fn handle_securejoin_handshake(
// will improve security (completely unrelated to the securejoin protocol)
// and is something we want to do in the future:
// https://www.rfc-editor.org/rfc/rfc9580.html#name-surreptitious-forwarding
if !matches!(step, SecureJoinStep::Request { .. }) {
if !matches!(
step,
SecureJoinStep::Request { .. } | SecureJoinStep::RequestPubkey | SecureJoinStep::Pubkey
) {
let mut self_found = false;
let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint();
for (addr, key) in &mime_message.gossiped_keys {
@@ -504,7 +513,54 @@ pub(crate) async fn handle_securejoin_handshake(
==== Bob - the joiner's side =====
==== Step 4 in "Setup verified contact" protocol =====
========================================================*/
bob::handle_auth_required(context, mime_message).await
bob::handle_auth_required_or_pubkey(context, mime_message).await
}
SecureJoinStep::RequestPubkey => {
/*========================================================
==== Alice - the inviter's side =====
==== Bob requests our public key (Securejoin v3) =====
========================================================*/
debug_assert!(
mime_message.signature.is_none(),
"RequestPubkey is not supposed to be signed"
);
let Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else {
warn!(
context,
"Ignoring {step} message because of missing auth code."
);
return Ok(HandshakeMessage::Ignore);
};
if !token::exists(context, token::Namespace::Auth, auth).await? {
warn!(context, "Secure-join denied (bad auth).");
return Ok(HandshakeMessage::Ignore);
}
let rfc724_mid = create_outgoing_rfc724_mid();
let addr = ContactAddress::new(&mime_message.from.addr)?;
let attach_self_pubkey = true;
let rendered_message = mimefactory::render_symm_encrypted_securejoin_message(
context,
"vc-pubkey",
&rfc724_mid,
attach_self_pubkey,
auth,
)
.await?;
let msg_id = message::insert_tombstone(context, &rfc724_mid).await?;
insert_into_smtp(context, &rfc724_mid, &addr, rendered_message, msg_id).await?;
context.scheduler.interrupt_smtp().await;
Ok(HandshakeMessage::Done)
}
SecureJoinStep::Pubkey => {
/*========================================================
==== Bob - the joiner's side =====
==== Alice sent us her pubkey (Securejoin v3) =====
========================================================*/
bob::handle_auth_required_or_pubkey(context, mime_message).await
}
SecureJoinStep::RequestWithAuth => {
/*==========================================================
@@ -585,15 +641,12 @@ pub(crate) async fn handle_securejoin_handshake(
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
}
contact_id.regossip_keys(context).await?;
ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited).await?;
// for setup-contact, make Alice's one-to-one chat with Bob visible
// (secure-join-information are shown in the group chat)
if grpid.is_empty() {
ChatId::create_for_contact(context, contact_id).await?;
}
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
if let Some(joining_chat_id) = joining_chat_id {
// Join group.
chat::add_contact_to_chat_ex(context, Nosync, joining_chat_id, contact_id, true)
.await?;
@@ -603,6 +656,10 @@ pub(crate) async fn handle_securejoin_handshake(
// We don't use the membership consistency algorithm for broadcast channels,
// so, sync the memberlist when adding a contact
chat.sync_contacts(context).await.log_err(context).ok();
} else {
ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited)
.await?;
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
}
inviter_progress(context, contact_id, joining_chat_id, chat.typ)?;
@@ -665,6 +722,24 @@ pub(crate) async fn handle_securejoin_handshake(
}
}
async fn insert_into_smtp(
context: &Context,
rfc724_mid: &str,
recipient: &str,
rendered_message: String,
msg_id: MsgId,
) -> Result<(), Error> {
context
.sql
.execute(
"INSERT INTO smtp (rfc724_mid, recipients, mime, msg_id)
VALUES (?1, ?2, ?3, ?4)",
(&rfc724_mid, &recipient, &rendered_message, msg_id),
)
.await?;
Ok(())
}
/// Observe self-sent Securejoin message.
///
/// In a multi-device-setup, there may be other devices that "see" the handshake messages.
@@ -696,6 +771,8 @@ pub(crate) async fn observe_securejoin_on_other_device(
match step {
SecureJoinStep::Request { .. }
| SecureJoinStep::AuthRequired
| SecureJoinStep::RequestPubkey
| SecureJoinStep::Pubkey
| SecureJoinStep::Deprecated
| SecureJoinStep::Unknown { .. } => {
return Ok(HandshakeMessage::Ignore);

View File

@@ -5,20 +5,22 @@ use anyhow::{Context as _, Result};
use super::HandshakeMessage;
use super::qrinvite::QrInvite;
use crate::chat::{self, ChatId, is_contact_in_chat};
use crate::chatlist_events;
use crate::constants::{Blocked, Chattype};
use crate::contact::Origin;
use crate::contact::{Contact, Origin};
use crate::context::Context;
use crate::events::EventType;
use crate::key::self_fingerprint;
use crate::log::LogExt;
use crate::message::{Message, MsgId, Viewtype};
use crate::message::{self, Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::param::{Param, Params};
use crate::securejoin::{ContactId, encrypted_and_signed, verify_sender_by_fingerprint};
use crate::securejoin::{
ContactId, encrypted_and_signed, insert_into_smtp, verify_sender_by_fingerprint,
};
use crate::stock_str;
use crate::sync::Sync::*;
use crate::tools::{smeared_time, time};
use crate::tools::{create_outgoing_rfc724_mid, smeared_time, time};
use crate::{chatlist_events, mimefactory};
/// Starts the securejoin protocol with the QR `invite`.
///
@@ -47,8 +49,14 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
// receive_imf.
let private_chat_id = private_chat_id(context, &invite).await?;
ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?;
context.emit_event(EventType::ContactsChanged(None));
match invite {
QrInvite::Group { .. } | QrInvite::Contact { .. } => {
ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined)
.await?;
context.emit_event(EventType::ContactsChanged(None));
}
QrInvite::Broadcast { .. } => {}
}
let has_key = context
.sql
@@ -213,11 +221,11 @@ LIMIT 1
Ok(())
}
/// Handles `vc-auth-required` and `vg-auth-required` handshake messages.
/// Handles `vc-auth-required`, `vg-auth-required`, and `vc-pubkey` handshake messages.
///
/// # Bob - the joiner's side
/// ## Step 4 in the "Setup Contact protocol"
pub(super) async fn handle_auth_required(
pub(super) async fn handle_auth_required_or_pubkey(
context: &Context,
message: &MimeMessage,
) -> Result<HandshakeMessage> {
@@ -299,47 +307,68 @@ pub(crate) async fn send_handshake_message(
chat_id: ChatId,
step: BobHandshakeMsg,
) -> Result<()> {
let mut msg = Message {
viewtype: Viewtype::Text,
text: step.body_text(invite),
hidden: true,
..Default::default()
};
msg.param.set_cmd(SystemMessage::SecurejoinMessage);
if invite.is_v3() && matches!(step, BobHandshakeMsg::Request) {
// Send a minimal symmetrically-encrypted vc-request-pubkey message
let rfc724_mid = create_outgoing_rfc724_mid();
let contact = Contact::get_by_id(context, invite.contact_id()).await?;
let recipient = contact.get_addr();
let attach_self_pubkey = false;
let rendered_message = mimefactory::render_symm_encrypted_securejoin_message(
context,
"vc-request-pubkey",
&rfc724_mid,
attach_self_pubkey,
invite.authcode(),
)
.await?;
// Sends the step in Secure-Join header.
msg.param.set(Param::Arg, step.securejoin_header(invite));
let msg_id = message::insert_tombstone(context, &rfc724_mid).await?;
insert_into_smtp(context, &rfc724_mid, recipient, rendered_message, msg_id).await?;
context.scheduler.interrupt_smtp().await;
} else {
let mut msg = Message {
viewtype: Viewtype::Text,
text: step.body_text(invite),
hidden: true,
..Default::default()
};
match step {
BobHandshakeMsg::Request => {
// Sends the Secure-Join-Invitenumber header in mimefactory.rs.
msg.param.set(Param::Arg2, invite.invitenumber());
msg.force_plaintext();
}
BobHandshakeMsg::RequestWithAuth => {
// Sends the Secure-Join-Auth header in mimefactory.rs.
msg.param.set(Param::Arg2, invite.authcode());
msg.param.set_int(Param::GuaranteeE2ee, 1);
msg.param.set_cmd(SystemMessage::SecurejoinMessage);
// Sends our own fingerprint in the Secure-Join-Fingerprint header.
let bob_fp = self_fingerprint(context).await?;
msg.param.set(Param::Arg3, bob_fp);
// Sends the step in Secure-Join header.
msg.param.set(Param::Arg, step.securejoin_header(invite));
// Sends the grpid in the Secure-Join-Group header.
//
// `Secure-Join-Group` header is deprecated,
// but old Delta Chat core requires that Alice receives it.
//
// Previous Delta Chat core also sent `Secure-Join-Group` header
// in `vg-request` messages,
// but it was not used on the receiver.
if let QrInvite::Group { grpid, .. } = invite {
msg.param.set(Param::Arg4, grpid);
match step {
BobHandshakeMsg::Request => {
// Sends the Secure-Join-Invitenumber header in mimefactory.rs.
msg.param.set(Param::Arg2, invite.invitenumber());
msg.force_plaintext();
}
}
};
BobHandshakeMsg::RequestWithAuth => {
// Sends the Secure-Join-Auth header in mimefactory.rs.
msg.param.set(Param::Arg2, invite.authcode());
msg.param.set_int(Param::GuaranteeE2ee, 1);
chat::send_msg(context, chat_id, &mut msg).await?;
// Sends our own fingerprint in the Secure-Join-Fingerprint header.
let bob_fp = self_fingerprint(context).await?;
msg.param.set(Param::Arg3, bob_fp);
// Sends the grpid in the Secure-Join-Group header.
//
// `Secure-Join-Group` header is deprecated,
// but old Delta Chat core requires that Alice receives it.
//
// Previous Delta Chat core also sent `Secure-Join-Group` header
// in `vg-request` messages,
// but it was not used on the receiver.
if let QrInvite::Group { grpid, .. } = invite {
msg.param.set(Param::Arg4, grpid);
}
}
};
chat::send_msg(context, chat_id, &mut msg).await?;
}
Ok(())
}

View File

@@ -12,7 +12,7 @@ use crate::qr::Qr;
/// Represents the data from a QR-code scan.
///
/// There are methods to conveniently access fields present in both variants.
/// There are methods to conveniently access fields present in all three variants.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum QrInvite {
Contact {
@@ -20,6 +20,8 @@ pub enum QrInvite {
fingerprint: Fingerprint,
invitenumber: String,
authcode: String,
#[serde(default)]
is_v3: bool,
},
Group {
contact_id: ContactId,
@@ -28,6 +30,8 @@ pub enum QrInvite {
grpid: String,
invitenumber: String,
authcode: String,
#[serde(default)]
is_v3: bool,
},
Broadcast {
contact_id: ContactId,
@@ -36,6 +40,8 @@ pub enum QrInvite {
grpid: String,
invitenumber: String,
authcode: String,
#[serde(default)]
is_v3: bool,
},
}
@@ -78,6 +84,14 @@ impl QrInvite {
| Self::Broadcast { authcode, .. } => authcode,
}
}
pub fn is_v3(&self) -> bool {
match *self {
QrInvite::Contact { is_v3, .. } => is_v3,
QrInvite::Group { is_v3, .. } => is_v3,
QrInvite::Broadcast { is_v3, .. } => is_v3,
}
}
}
impl TryFrom<Qr> for QrInvite {
@@ -90,11 +104,13 @@ impl TryFrom<Qr> for QrInvite {
fingerprint,
invitenumber,
authcode,
is_v3,
} => Ok(QrInvite::Contact {
contact_id,
fingerprint,
invitenumber,
authcode,
is_v3,
}),
Qr::AskVerifyGroup {
grpname,
@@ -103,6 +119,7 @@ impl TryFrom<Qr> for QrInvite {
fingerprint,
invitenumber,
authcode,
is_v3,
} => Ok(QrInvite::Group {
contact_id,
fingerprint,
@@ -110,6 +127,7 @@ impl TryFrom<Qr> for QrInvite {
grpid,
invitenumber,
authcode,
is_v3,
}),
Qr::AskJoinBroadcast {
name,
@@ -118,6 +136,7 @@ impl TryFrom<Qr> for QrInvite {
fingerprint,
authcode,
invitenumber,
is_v3,
} => Ok(QrInvite::Broadcast {
name,
grpid,
@@ -125,6 +144,7 @@ impl TryFrom<Qr> for QrInvite {
fingerprint,
authcode,
invitenumber,
is_v3,
}),
_ => bail!("Unsupported QR type"),
}

View File

@@ -1,6 +1,7 @@
use std::time::Duration;
use deltachat_contact_tools::EmailAddress;
use regex::Regex;
use super::*;
use crate::chat::{CantSendReason, add_contact_to_chat, remove_contact_from_chat};
@@ -11,10 +12,10 @@ use crate::key::self_fingerprint;
use crate::mimeparser::{GossipedKey, SystemMessage};
use crate::qr::Qr;
use crate::receive_imf::receive_imf;
use crate::stock_str::{self, messages_e2e_encrypted};
use crate::stock_str::{self, messages_e2ee_info_msg};
use crate::test_utils::{
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
TimeShiftFalsePositiveNote, get_chat_msg,
TimeShiftFalsePositiveNote, get_chat_msg, sync,
};
use crate::tools::SystemTime;
@@ -27,7 +28,7 @@ enum SetupContactCase {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact() {
async fn test_setup_contact_basic() {
test_setup_contact_ex(SetupContactCase::Normal).await
}
@@ -62,13 +63,13 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
bob.set_config(Config::Displayname, Some("Bob Examplenet"))
.await
.unwrap();
let alice_auto_submitted_hdr;
let alice_auto_submitted_hdr: bool;
match case {
SetupContactCase::AliceIsBot => {
alice.set_config_bool(Config::Bot, true).await.unwrap();
alice_auto_submitted_hdr = "Auto-Submitted: auto-generated";
alice_auto_submitted_hdr = true;
}
_ => alice_auto_submitted_hdr = "Auto-Submitted: auto-replied",
_ => alice_auto_submitted_hdr = false,
};
assert_eq!(
@@ -108,7 +109,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
let mut i = 0..msg_cnt;
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), messages_e2e_encrypted(&bob).await);
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob).await);
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await);
@@ -118,12 +119,15 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
assert!(!sent.payload.contains("Bob Examplenet"));
assert_eq!(sent.recipient(), EmailAddress::new(alice_addr).unwrap());
let msg = alice.parse_msg(&sent).await;
assert!(!msg.was_encrypted());
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vc-request");
assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some());
assert!(msg.signature.is_none());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vc-request-pubkey"
);
assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some());
assert!(!msg.header_exists(HeaderDef::AutoSubmitted));
tcm.section("Step 3: Alice receives vc-request, sends vc-auth-required");
tcm.section("Step 3: Alice receives vc-request-pubkey, sends vc-pubkey");
alice.recv_msg_trash(&sent).await;
assert_eq!(
Chatlist::try_load(&alice, 0, None, None)
@@ -134,13 +138,14 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
);
let sent = alice.pop_sent_msg().await;
assert!(sent.payload.contains(alice_auto_submitted_hdr));
assert_eq!(sent.payload.contains("Auto-Submitted:"), false);
assert!(!sent.payload.contains("Alice Exampleorg"));
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vc-pubkey");
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vc-auth-required"
msg.get_header(HeaderDef::AutoSubmitted),
alice_auto_submitted_hdr.then_some("auto-generated")
);
let bob_chat = bob.get_chat(&alice).await;
@@ -170,7 +175,6 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
// Check Bob sent the right message.
let sent = bob.pop_sent_msg().await;
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
assert!(!sent.payload.contains("Bob Examplenet"));
let mut msg = alice.parse_msg(&sent).await;
assert!(msg.was_encrypted());
@@ -246,7 +250,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
let chat = alice.get_chat(&bob).await;
let msg = get_chat_msg(&alice, chat.get_id(), 0, 1).await;
assert!(msg.is_info());
let expected_text = messages_e2e_encrypted(&alice).await;
let expected_text = messages_e2ee_info_msg(&alice).await;
assert_eq!(msg.get_text(), expected_text);
}
@@ -261,7 +265,10 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
// Check Alice sent the right message to Bob.
let sent = alice.pop_sent_msg().await;
assert!(sent.payload.contains(alice_auto_submitted_hdr));
assert_eq!(
sent.payload.contains("Auto-Submitted: auto-generated"),
false
);
assert!(!sent.payload.contains("Alice Exampleorg"));
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
@@ -288,7 +295,7 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
// The `SecurejoinWait` info message has been removed, but the e2ee notice remains.
let msg = get_chat_msg(&bob, bob_chat.get_id(), 0, 1).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), messages_e2e_encrypted(&bob).await);
assert_eq!(msg.get_text(), messages_e2ee_info_msg(&bob).await);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -421,18 +428,31 @@ async fn test_setup_contact_concurrent_calls() -> Result<()> {
assert!(!alice_id.is_special());
assert_eq!(chat.typ, Chattype::Single);
assert_ne!(claire_id, alice_id);
assert!(
assert_eq!(
bob.pop_sent_msg()
.await
.payload()
.contains("alice@example.org")
.contains("alice@example.org"),
false // Alice's address must not be sent in cleartext
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_secure_join() -> Result<()> {
async fn test_secure_join_group_legacy() -> Result<()> {
test_secure_join_group_ex(false, false).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_secure_join_group_v3() -> Result<()> {
test_secure_join_group_ex(true, false).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_secure_join_group_v3_without_invite() -> Result<()> {
test_secure_join_group_ex(true, true).await
}
async fn test_secure_join_group_ex(v3: bool, remove_invite: bool) -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
@@ -444,7 +464,8 @@ async fn test_secure_join() -> Result<()> {
let alice_chatid = chat::create_group(&alice, "the chat").await?;
tcm.section("Step 1: Generate QR-code, secure-join implied by chatid");
let qr = get_securejoin_qr(&alice, Some(alice_chatid)).await.unwrap();
let mut qr = get_securejoin_qr(&alice, Some(alice_chatid)).await.unwrap();
manipulate_qr(v3, remove_invite, &mut qr);
tcm.section("Step 2: Bob scans QR-code, sends vg-request");
let bob_chatid = join_securejoin(&bob, &qr).await?;
@@ -456,9 +477,20 @@ async fn test_secure_join() -> Result<()> {
EmailAddress::new("alice@example.org").unwrap()
);
let msg = alice.parse_msg(&sent).await;
assert!(!msg.was_encrypted());
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vg-request");
assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some());
assert!(msg.signature.is_none());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
if v3 {
"vc-request-pubkey"
} else {
"vg-request"
}
);
assert_eq!(msg.get_header(HeaderDef::SecureJoinAuth).is_some(), v3);
assert_eq!(
msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some(),
!v3
);
assert!(!msg.header_exists(HeaderDef::AutoSubmitted));
// Old Delta Chat core sent `Secure-Join-Group` header in `vg-request`,
@@ -469,19 +501,18 @@ async fn test_secure_join() -> Result<()> {
// is only sent in `vg-request-with-auth` for compatibility.
assert!(!msg.header_exists(HeaderDef::SecureJoinGroup));
tcm.section("Step 3: Alice receives vg-request, sends vg-auth-required");
tcm.section("Step 3: Alice receives vc-request-pubkey and sends vc-pubkey, or receives vg-request and sends vg-auth-required");
alice.recv_msg_trash(&sent).await;
let sent = alice.pop_sent_msg().await;
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vg-auth-required"
if v3 { "vc-pubkey" } else { "vg-auth-required" }
);
tcm.section("Step 4: Bob receives vg-auth-required, sends vg-request-with-auth");
tcm.section("Step 4: Bob receives vc-pubkey or vg-auth-required, sends v*-request-with-auth");
bob.recv_msg_trash(&sent).await;
let sent = bob.pop_sent_msg().await;
@@ -511,7 +542,6 @@ async fn test_secure_join() -> Result<()> {
}
// Check Bob sent the right handshake message.
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
let msg = alice.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
@@ -575,19 +605,27 @@ async fn test_secure_join() -> Result<()> {
{
// Now Alice's chat with Bob should still be hidden, the verified message should
// appear in the group chat.
if v3 {
assert!(
ChatIdBlocked::lookup_by_contact(&alice, contact_bob.id)
.await?
.is_none()
);
} else {
let chat = alice.get_chat(&bob).await;
assert_eq!(
chat.blocked,
Blocked::Yes,
"Alice's 1:1 chat with Bob is not hidden"
);
}
let chat = alice.get_chat(&bob).await;
assert_eq!(
chat.blocked,
Blocked::Yes,
"Alice's 1:1 chat with Bob is not hidden"
);
// There should be 2 messages in the chat:
// - The ChatProtectionEnabled message
// - You added member bob@example.net
let msg = get_chat_msg(&alice, alice_chatid, 0, 2).await;
assert!(msg.is_info());
let expected_text = messages_e2e_encrypted(&alice).await;
let expected_text = messages_e2ee_info_msg(&alice).await;
assert_eq!(msg.get_text(), expected_text);
}
@@ -640,6 +678,97 @@ async fn test_secure_join() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_secure_join_broadcast_legacy() -> Result<()> {
test_secure_join_broadcast_ex(false, false).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_secure_join_broadcast_v3() -> Result<()> {
test_secure_join_broadcast_ex(true, false).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_secure_join_broadcast_v3_without_invite() -> Result<()> {
test_secure_join_broadcast_ex(true, true).await
}
async fn test_secure_join_broadcast_ex(v3: bool, remove_invite: bool) -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = chat::create_broadcast(alice, "Channel".to_string()).await?;
let mut qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
manipulate_qr(v3, remove_invite, &mut qr);
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
let sent = alice.send_text(alice_chat_id, "Hi channel").await;
assert!(sent.recipients.contains("bob@example.net"));
let rcvd = bob.recv_msg(&sent).await;
assert_eq!(rcvd.chat_id, bob_chat_id);
assert_eq!(rcvd.text, "Hi channel");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_compatibility_legacy() -> Result<()> {
test_setup_contact_compatibility_ex(false, false).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_compatibility_v3() -> Result<()> {
test_setup_contact_compatibility_ex(true, false).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_compatibility_v3_without_invite() -> Result<()> {
test_setup_contact_compatibility_ex(true, true).await
}
async fn test_setup_contact_compatibility_ex(v3: bool, remove_invite: bool) -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
alice.set_config(Config::Displayname, Some("Alice")).await?;
let mut qr = get_securejoin_qr(alice, None).await?;
manipulate_qr(v3, remove_invite, &mut qr);
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
assert_eq!(bob_chat.name, "Alice");
assert!(bob_chat.can_send(bob).await?);
assert_eq!(bob_chat.typ, Chattype::Single);
assert_eq!(bob_chat.id, bob.get_chat(alice).await.id);
let alice_chat = alice.get_chat(bob).await;
assert_eq!(alice_chat.name, "bob@example.net");
assert!(alice_chat.can_send(alice).await?);
assert_eq!(alice_chat.typ, Chattype::Single);
Ok(())
}
fn manipulate_qr(v3: bool, remove_invite: bool, qr: &mut String) {
if remove_invite {
// Remove the INVITENUBMER. It's not needed in Securejoin v3,
// but still included for backwards compatibility reasons.
// We want to be able to remove it in the future,
// therefore we test that things work without it.
let new_qr = Regex::new("&i=.*?&").unwrap().replace(qr, "&");
// Broadcast channels use `j` for the INVITENUMBER
let new_qr = Regex::new("&j=.*?&").unwrap().replace(&new_qr, "&");
assert!(new_qr != *qr);
*qr = new_qr.to_string();
}
// If `!v3`, force legacy securejoin to run by removing the &v=3 parameter.
// If `remove_invite`, we can also remove the v=3 parameter,
// because a QR with AUTH but no INVITE is obviously v3 QR code.
if !v3 || remove_invite {
let new_qr = Regex::new("&v=3").unwrap().replace(qr, "");
assert!(new_qr != *qr);
*qr = new_qr.to_string();
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_group_no_qr() -> Result<()> {
let alice = TestContext::new_alice().await;
@@ -782,7 +911,18 @@ async fn test_parallel_securejoin() -> Result<()> {
/// Tests Bob scanning setup contact QR codes of Alice and Fiona
/// concurrently.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parallel_setup_contact() -> Result<()> {
async fn test_parallel_setup_contact_basic() -> Result<()> {
test_parallel_setup_contact(false).await
}
/// Tests Bob scanning setup contact QR codes of Alice and Fiona
/// concurrently, and then deleting the Fiona contact.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parallel_setup_contact_bob_deletes_fiona() -> Result<()> {
test_parallel_setup_contact(true).await
}
async fn test_parallel_setup_contact(bob_deletes_fiona_contact: bool) -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
@@ -803,16 +943,25 @@ async fn test_parallel_setup_contact() -> Result<()> {
fiona.recv_msg_trash(&sent_fiona_vc_request).await;
let sent_fiona_vc_auth_required = fiona.pop_sent_msg().await;
bob.recv_msg_trash(&sent_fiona_vc_auth_required).await;
let sent_fiona_vc_request_with_auth = bob.pop_sent_msg().await;
fiona.recv_msg_trash(&sent_fiona_vc_request_with_auth).await;
let sent_fiona_vc_contact_confirm = fiona.pop_sent_msg().await;
bob.recv_msg_trash(&sent_fiona_vc_contact_confirm).await;
let bob_fiona_contact_id = bob.add_or_lookup_contact_id(fiona).await;
let bob_fiona_contact = Contact::get_by_id(bob, bob_fiona_contact_id).await.unwrap();
assert_eq!(bob_fiona_contact.is_verified(bob).await.unwrap(), true);
if bob_deletes_fiona_contact {
bob.get_chat(fiona).await.id.delete(bob).await?;
Contact::delete(bob, bob_fiona_contact_id).await?;
bob.recv_msg_trash(&sent_fiona_vc_auth_required).await;
let sent = bob.pop_sent_msg_opt(Duration::ZERO).await;
assert!(sent.is_none());
} else {
bob.recv_msg_trash(&sent_fiona_vc_auth_required).await;
let sent_fiona_vc_request_with_auth = bob.pop_sent_msg().await;
fiona.recv_msg_trash(&sent_fiona_vc_request_with_auth).await;
let sent_fiona_vc_contact_confirm = fiona.pop_sent_msg().await;
bob.recv_msg_trash(&sent_fiona_vc_contact_confirm).await;
let bob_fiona_contact = Contact::get_by_id(bob, bob_fiona_contact_id).await.unwrap();
assert_eq!(bob_fiona_contact.is_verified(bob).await.unwrap(), true);
}
// Alice gets online and previously started SecureJoin process finishes.
alice.recv_msg_trash(&sent_alice_vc_request).await;
@@ -1370,3 +1519,68 @@ gU6dGXsFMe/RpRHrIAkMAaM5xkxMDRuRJDxiUdS/X+Y8
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_auth_token_is_synchronized() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice1 = &tcm.alice().await;
let alice2 = &tcm.alice().await;
let bob = &tcm.bob().await;
bob.set_config(Config::Displayname, Some("Bob")).await?;
alice1.set_config_bool(Config::SyncMsgs, true).await?;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
// This creates first auth token:
let qr1 = get_securejoin_qr(alice1, None).await?;
// This creates another auth token; both of them need to be synchronized
let qr2 = get_securejoin_qr(alice1, None).await?;
sync(alice1, alice2).await;
// Note that Bob will throw away the AUTH token after sending `vc-request-with-auth`.
// Therefore, he will fail to decrypt the answer from Alice's second device,
// which leads to a "decryption failed: missing key" message in the logs.
// This is fine.
tcm.exec_securejoin_qr_multi_device(bob, &[alice1, alice2], &qr2)
.await;
let contacts = Contact::get_all(alice2, 0, Some("Bob")).await?;
assert_eq!(contacts[0], alice2.add_or_lookup_contact_id(bob).await);
assert_eq!(contacts.len(), 1);
let chatlist = Chatlist::try_load(alice2, 0, Some("Bob"), None).await?;
assert_eq!(chatlist.get_chat_id(0)?, alice2.get_chat(bob).await.id);
assert_eq!(chatlist.len(), 1);
for qr in [qr1, qr2] {
let qr = check_qr(bob, &qr).await?;
let qr = QrInvite::try_from(qr)?;
assert!(token::exists(alice2, Namespace::InviteNumber, qr.invitenumber()).await?);
assert!(token::exists(alice2, Namespace::Auth, qr.authcode()).await?);
}
// Check that alice2 only saves the invite number once:
let invite_count: u32 = alice2
.sql
.query_get_value(
"SELECT COUNT(*) FROM tokens WHERE namespc=?;",
(Namespace::InviteNumber,),
)
.await?
.unwrap();
assert_eq!(invite_count, 1);
// ...but knows two AUTH tokens:
let auth_count: u32 = alice2
.sql
.query_get_value(
"SELECT COUNT(*) FROM tokens WHERE namespc=?;",
(Namespace::Auth,),
)
.await?
.unwrap();
assert_eq!(auth_count, 2);
Ok(())
}

View File

@@ -9,6 +9,7 @@ use crate::tools::IsNoneOrEmpty;
/// This escapes a bit more than actually needed by delta (e.g. also lines as "-- footer"),
/// but for non-delta-compatibility, that seems to be better.
/// (to be only compatible with delta, only "[\r\n|\n]-- {0,2}[\r\n|\n]" needs to be replaced)
#[expect(clippy::arithmetic_side_effects)]
pub fn escape_message_footer_marks(text: &str) -> String {
if let Some(text) = text.strip_prefix("--") {
"-\u{200B}-".to_string() + &text.replace("\n--", "\n-\u{200B}-")
@@ -21,6 +22,7 @@ pub fn escape_message_footer_marks(text: &str) -> String {
/// Returns `(lines, footer_lines)` tuple;
/// `footer_lines` is set to `Some` if the footer was actually removed from `lines`
/// (which is equal to the input array otherwise).
#[expect(clippy::arithmetic_side_effects)]
pub(crate) fn remove_message_footer<'a>(
lines: &'a [&str],
) -> (&'a [&'a str], Option<&'a [&'a str]>) {
@@ -175,6 +177,7 @@ fn skip_forward_header<'a>(lines: &'a [&str]) -> (&'a [&'a str], bool) {
}
}
#[expect(clippy::arithmetic_side_effects)]
fn remove_bottom_quote<'a>(lines: &'a [&str]) -> (&'a [&'a str], Option<String>) {
let mut first_quoted_line = lines.len();
let mut last_quoted_line = None;
@@ -217,6 +220,7 @@ fn remove_bottom_quote<'a>(lines: &'a [&str]) -> (&'a [&'a str], Option<String>)
}
}
#[expect(clippy::arithmetic_side_effects)]
fn remove_top_quote<'a>(
lines: &'a [&str],
is_chat_message: bool,
@@ -262,6 +266,7 @@ fn remove_top_quote<'a>(
}
}
#[expect(clippy::arithmetic_side_effects)]
fn render_message(lines: &[&str], is_cut_at_end: bool) -> String {
let mut ret = String::new();
/* we write empty lines only in case and non-empty line follows */

View File

@@ -136,7 +136,7 @@ async fn connection_attempt(
Err(err) => {
warn!(
context,
"Failed to connect to {host} ({resolved_addr}): {err:#}."
"SMTP failed to connect to {host} ({resolved_addr}): {err:#}."
);
Err(err)
}

View File

@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context as _, Result, bail, ensure};
use anyhow::{Context as _, Result, bail};
use rusqlite::{Connection, OpenFlags, Row, config::DbConfig, types::ValueRef};
use tokio::sync::RwLock;
@@ -25,7 +25,7 @@ use crate::net::http::http_cache_cleanup;
use crate::net::prune_connection_history;
use crate::param::{Param, Params};
use crate::stock_str;
use crate::tools::{SystemTime, Time, delete_file, time, time_elapsed};
use crate::tools::{SystemTime, delete_file, time};
/// Extension to [`rusqlite::ToSql`] trait
/// which also includes [`Send`] and [`Sync`].
@@ -48,7 +48,7 @@ macro_rules! params_slice {
mod migrations;
mod pool;
use pool::Pool;
use pool::{Pool, WalCheckpointStats};
/// A wrapper around the underlying Sqlite3 object.
#[derive(Debug)]
@@ -663,73 +663,30 @@ impl Sql {
&self.config_cache
}
/// Runs a checkpoint operation in TRUNCATE mode, so the WAL file is truncated to 0 bytes.
pub(crate) async fn wal_checkpoint(context: &Context) -> Result<()> {
let t_start = Time::now();
let lock = context.sql.pool.read().await;
/// Attempts to truncate the WAL file.
pub(crate) async fn wal_checkpoint(&self, context: &Context) -> Result<()> {
let lock = self.pool.read().await;
let Some(pool) = lock.as_ref() else {
// No db connections, nothing to checkpoint.
return Ok(());
};
// Do as much work as possible without blocking anybody.
let query_only = true;
let conn = pool.get(query_only).await?;
tokio::task::block_in_place(|| {
// Execute some transaction causing the WAL file to be opened so that the
// `wal_checkpoint()` can proceed, otherwise it fails when called the first time,
// see https://sqlite.org/forum/forumpost/7512d76a05268fc8.
conn.query_row("PRAGMA table_list", [], |_| Ok(()))?;
conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |_| Ok(()))
})?;
// Kick out writers.
const _: () = assert!(Sql::N_DB_CONNECTIONS > 1, "Deadlock possible");
let _write_lock = pool.write_lock().await;
let t_writers_blocked = Time::now();
// Ensure that all readers use the most recent database snapshot (are at the end of WAL) so
// that `wal_checkpoint(FULL)` isn't blocked. We could use `PASSIVE` as well, but it's
// documented poorly, https://www.sqlite.org/pragma.html#pragma_wal_checkpoint and
// https://www.sqlite.org/c3ref/wal_checkpoint_v2.html don't tell how it interacts with new
// readers.
let mut read_conns = Vec::with_capacity(Self::N_DB_CONNECTIONS - 1);
for _ in 0..(Self::N_DB_CONNECTIONS - 1) {
read_conns.push(pool.get(query_only).await?);
}
read_conns.clear();
// Checkpoint the remaining WAL pages without blocking readers.
let (pages_total, pages_checkpointed) = tokio::task::block_in_place(|| {
conn.query_row("PRAGMA wal_checkpoint(FULL)", [], |row| {
let pages_total: i64 = row.get(1)?;
let pages_checkpointed: i64 = row.get(2)?;
Ok((pages_total, pages_checkpointed))
})
})?;
let WalCheckpointStats {
total_duration,
writers_blocked_duration,
readers_blocked_duration,
pages_total,
pages_checkpointed,
} = pool.wal_checkpoint().await?;
if pages_checkpointed < pages_total {
warn!(
context,
"Cannot checkpoint whole WAL. Pages total: {pages_total}, checkpointed: {pages_checkpointed}. Make sure there are no external connections running transactions.",
);
}
// Kick out readers to avoid blocking/SQLITE_BUSY.
for _ in 0..(Self::N_DB_CONNECTIONS - 1) {
read_conns.push(pool.get(query_only).await?);
}
let t_readers_blocked = Time::now();
tokio::task::block_in_place(|| {
let blocked = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
let blocked: i64 = row.get(0)?;
Ok(blocked)
})?;
ensure!(blocked == 0);
Ok(())
})?;
info!(
context,
"wal_checkpoint: Total time: {:?}. Writers blocked for: {:?}. Readers blocked for: {:?}.",
time_elapsed(&t_start),
time_elapsed(&t_writers_blocked),
time_elapsed(&t_readers_blocked),
"wal_checkpoint: Total time: {total_duration:?}. Writers blocked for: {writers_blocked_duration:?}. Readers blocked for: {readers_blocked_duration:?}."
);
Ok(())
}
@@ -798,6 +755,7 @@ fn new_connection(path: &Path, passphrase: &str) -> Result<Connection> {
// Tries to clear the freelist to free some space on the disk.
//
// This only works if auto_vacuum is enabled.
#[expect(clippy::arithmetic_side_effects)]
async fn incremental_vacuum(context: &Context) -> Result<()> {
context
.sql
@@ -827,6 +785,10 @@ async fn incremental_vacuum(context: &Context) -> Result<()> {
/// Cleanup the account to restore some storage and optimize the database.
pub async fn housekeeping(context: &Context) -> Result<()> {
let Ok(_housekeeping_lock) = context.housekeeping_mutex.try_lock() else {
// Housekeeping is already running in another thread, do nothing.
return Ok(());
};
// Setting `Config::LastHousekeeping` at the beginning avoids endless loops when things do not
// work out for whatever reason or are interrupted by the OS.
if let Err(e) = context
@@ -881,7 +843,7 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
// bigger than 200M) and also make sure we truncate the WAL periodically. Auto-checkponting does
// not normally truncate the WAL (unless the `journal_size_limit` pragma is set), see
// https://www.sqlite.org/wal.html.
if let Err(err) = Sql::wal_checkpoint(context).await {
if let Err(err) = Sql::wal_checkpoint(&context.sql, context).await {
warn!(context, "wal_checkpoint() failed: {err:#}.");
debug_assert!(false);
}
@@ -956,6 +918,7 @@ pub fn row_get_vec(row: &Row, idx: usize) -> rusqlite::Result<Vec<u8>> {
}
/// Enumerates used files in the blobdir and removes unused ones.
#[expect(clippy::arithmetic_side_effects)]
pub async fn remove_unused_files(context: &Context) -> Result<()> {
let mut files_in_use = HashSet::new();
let mut unreferenced_count = 0;

File diff suppressed because it is too large Load Diff

View File

@@ -51,6 +51,9 @@ use anyhow::{Context, Result};
use rusqlite::Connection;
use tokio::sync::{Mutex, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore};
mod wal_checkpoint;
pub(crate) use wal_checkpoint::WalCheckpointStats;
/// Inner connection pool.
#[derive(Debug)]
struct InnerPool {
@@ -68,6 +71,24 @@ struct InnerPool {
/// This mutex is locked when write connection
/// is outside the pool.
pub(crate) write_mutex: Arc<Mutex<()>>,
/// WAL checkpointing mutex.
///
/// This mutex ensures that no more than one thread
/// runs WAL checkpointing at the same time.
///
/// Normal procedures acquire either one read connection
/// or one write connection with a write mutex,
/// and return the resources without trying to acquire
/// more connections or trying to acquire write mutex
/// without returning the read connection first.
/// WAL checkpointing is special, it tries to acquire all
/// connections and the write mutex,
/// so two threads doing this at the same time
/// may result in a deadlock with one thread
/// waiting for a write lock and the other thread
/// waiting for a connection.
wal_checkpoint_mutex: Mutex<()>,
}
impl InnerPool {
@@ -188,6 +209,7 @@ impl Pool {
connections: parking_lot::Mutex::new(connections),
semaphore,
write_mutex: Default::default(),
wal_checkpoint_mutex: Default::default(),
});
Pool { inner }
}
@@ -196,11 +218,8 @@ impl Pool {
Arc::clone(&self.inner).get(query_only).await
}
/// Returns a mutex guard guaranteeing that there are no concurrent write connections.
///
/// NB: Make sure you're not holding all connections when calling this, otherwise it deadlocks
/// if there is a concurrent writer waiting for available connection.
pub(crate) async fn write_lock(&self) -> OwnedMutexGuard<()> {
Arc::clone(&self.inner.write_mutex).lock_owned().await
/// Truncates the WAL file.
pub(crate) async fn wal_checkpoint(&self) -> Result<WalCheckpointStats> {
wal_checkpoint::wal_checkpoint(self).await
}
}

View File

@@ -0,0 +1,93 @@
//! # WAL checkpointing for SQLite connection pool.
use anyhow::{Result, ensure};
use std::sync::Arc;
use std::time::Duration;
use crate::sql::Sql;
use crate::tools::{Time, time_elapsed};
use super::Pool;
/// Information about WAL checkpointing call for logging.
#[derive(Debug)]
pub(crate) struct WalCheckpointStats {
/// Duration of the whole WAL checkpointing.
pub total_duration: Duration,
/// Duration for which WAL checkpointing blocked the writers.
pub writers_blocked_duration: Duration,
/// Duration for which WAL checkpointing blocked the readers.
pub readers_blocked_duration: Duration,
/// Number of pages in WAL before truncating.
pub pages_total: i64,
/// Number of checkpointed WAL pages.
///
/// It should be the same as `pages_total`
/// unless there are external connections to the database
/// that are not in the pool.
pub pages_checkpointed: i64,
}
/// Runs a checkpoint operation in TRUNCATE mode, so the WAL file is truncated to 0 bytes.
pub(super) async fn wal_checkpoint(pool: &Pool) -> Result<WalCheckpointStats> {
let _guard = pool.inner.wal_checkpoint_mutex.lock().await;
let t_start = Time::now();
// Do as much work as possible without blocking anybody.
let query_only = true;
let conn = pool.get(query_only).await?;
tokio::task::block_in_place(|| {
// Execute some transaction causing the WAL file to be opened so that the
// `wal_checkpoint()` can proceed, otherwise it fails when called the first time,
// see https://sqlite.org/forum/forumpost/7512d76a05268fc8.
conn.query_row("PRAGMA table_list", [], |_| Ok(()))?;
conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |_| Ok(()))
})?;
// Kick out writers.
const _: () = assert!(Sql::N_DB_CONNECTIONS > 1, "Deadlock possible");
let _write_lock = Arc::clone(&pool.inner.write_mutex).lock_owned().await;
let t_writers_blocked = Time::now();
// Ensure that all readers use the most recent database snapshot (are at the end of WAL) so
// that `wal_checkpoint(FULL)` isn't blocked. We could use `PASSIVE` as well, but it's
// documented poorly, https://www.sqlite.org/pragma.html#pragma_wal_checkpoint and
// https://www.sqlite.org/c3ref/wal_checkpoint_v2.html don't tell how it interacts with new
// readers.
let mut read_conns = Vec::with_capacity(crate::sql::Sql::N_DB_CONNECTIONS - 1);
for _ in 0..(crate::sql::Sql::N_DB_CONNECTIONS - 1) {
read_conns.push(pool.get(query_only).await?);
}
read_conns.clear();
// Checkpoint the remaining WAL pages without blocking readers.
let (pages_total, pages_checkpointed) = tokio::task::block_in_place(|| {
conn.query_row("PRAGMA wal_checkpoint(FULL)", [], |row| {
let pages_total: i64 = row.get(1)?;
let pages_checkpointed: i64 = row.get(2)?;
Ok((pages_total, pages_checkpointed))
})
})?;
// Kick out readers to avoid blocking/SQLITE_BUSY.
for _ in 0..(crate::sql::Sql::N_DB_CONNECTIONS - 1) {
read_conns.push(pool.get(query_only).await?);
}
let t_readers_blocked = Time::now();
tokio::task::block_in_place(|| {
let blocked = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
let blocked: i64 = row.get(0)?;
Ok(blocked)
})?;
ensure!(blocked == 0);
Ok(())
})?;
Ok(WalCheckpointStats {
total_duration: time_elapsed(&t_start),
writers_blocked_duration: time_elapsed(&t_writers_blocked),
readers_blocked_duration: time_elapsed(&t_readers_blocked),
pages_total,
pages_checkpointed,
})
}

View File

@@ -391,6 +391,12 @@ https://delta.chat/donate"))]
Waiting for the device of %2$s to reply…"))]
SecureJoinBroadcastStarted = 203,
#[strum(props(fallback = "Channel name changed from \"%1$s\" to \"%2$s\"."))]
MsgBroadcastNameChanged = 204,
#[strum(props(fallback = "Channel image changed."))]
MsgBroadcastImgChanged = 205,
#[strum(props(
fallback = "The attachment contains anonymous usage statistics, which helps us improve Delta Chat. Thank you!"
))]
@@ -429,6 +435,9 @@ https://delta.chat/donate"))]
#[strum(props(fallback = "Chat description changed by %1$s."))]
MsgChatDescriptionChangedBy = 241,
#[strum(props(fallback = "Messages are end-to-end encrypted."))]
MessagesAreE2ee = 242,
}
impl StockMessage {
@@ -620,10 +629,10 @@ pub(crate) async fn msg_chat_description_changed(
}
}
/// Stock string: `You added member %1$s.` or `Member %1$s added by %2$s.`.
/// Stock string: `Member %1$s added.`, `You added member %1$s.` or `Member %1$s added by %2$s.`.
///
/// The `added_member_addr` parameter should be an email address and is looked up in the
/// contacts to combine with the display name.
/// The `added_member` and `by_contact` contacts
/// are looked up in the database to get the display names.
pub(crate) async fn msg_add_member_local(
context: &Context,
added_member: ContactId,
@@ -646,10 +655,10 @@ pub(crate) async fn msg_add_member_local(
}
}
/// Stock string: `I added member %1$s.` or `Member %1$s removed by %2$s.`.
/// Stock string: `Member %1$s removed.` or `You removed member %1$s.` or `Member %1$s removed by %2$s.`
///
/// The `removed_member_addr` parameter should be an email address and is looked up in
/// the contacts to combine with the display name.
/// The `removed_member` and `by_contact` contacts
/// are looked up in the database to get the display names.
pub(crate) async fn msg_del_member_local(
context: &Context,
removed_member: ContactId,
@@ -708,6 +717,19 @@ pub(crate) async fn secure_join_broadcast_started(
}
}
/// Stock string: `Channel name changed from "1%s" to "2$s".`
pub(crate) async fn msg_broadcast_name_changed(context: &Context, from: &str, to: &str) -> String {
translated(context, StockMessage::MsgBroadcastNameChanged)
.await
.replace1(from)
.replace2(to)
}
/// Stock string `Channel image changed.`
pub(crate) async fn msg_broadcast_img_changed(context: &Context) -> String {
translated(context, StockMessage::MsgBroadcastImgChanged).await
}
/// Stock string: `You reacted %1$s to "%2$s"` or `%1$s reacted %2$s to "%3$s"`.
pub(crate) async fn msg_reacted(
context: &Context,
@@ -1049,11 +1071,16 @@ pub(crate) async fn error_no_network(context: &Context) -> String {
translated(context, StockMessage::ErrorNoNetwork).await
}
/// Stock string: `Messages are end-to-end encrypted.`
pub(crate) async fn messages_e2e_encrypted(context: &Context) -> String {
/// Stock string: `Messages are end-to-end encrypted.`, used in info-messages, UI may add smth. as `Tap to learn more.`
pub(crate) async fn messages_e2ee_info_msg(context: &Context) -> String {
translated(context, StockMessage::ChatProtectionEnabled).await
}
/// Stock string: `Messages are end-to-end encrypted.`
pub(crate) async fn messages_are_e2ee(context: &Context) -> String {
translated(context, StockMessage::MessagesAreE2ee).await
}
/// Stock string: `Reply`.
pub(crate) async fn reply_noun(context: &Context) -> String {
translated(context, StockMessage::ReplyNoun).await

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