Compare commits

..

102 Commits

Author SHA1 Message Date
link2xt
6077499f07 chore(release): prepare for 2.28.0 2025-11-23 17:08:42 +00:00
Simon Laux
94d2d8cfd7 feat: add api to get information about diskspace usage of database. (jsonrpc method: get_storage_usage_report_string) (#7486)
new jsonrpc api: `get_storage_usage_report_string(accountId)`
new rust API: `get_storage_usage(&context)`
2025-11-23 15:18:00 +00:00
iequidoo
ba3cad6ad6 docs: Mark db encryption support as deprecated (#7403)
- Db encryption does nothing with blobs, so fs/disk encryption is recommended.
- Isolation from other apps is needed anyway.
- Experimental database encryption was removed on iOS and Android.
- Delta Touch is using CFFI API with a manually entered password because Ubuntu Touch does not offer
  filesystem or disk encryption, but we don't want new users of these APIs, such as bot developers.
2025-11-22 18:36:40 -03:00
link2xt
c9c362d5ff api: get_existing_msg_ids()
This API allows to check if the message with
given ID exists and distinguish between
message not existing and database error.
It might also be faster than
checking messages one by one
if multiple messages need to be checked
because of using a single SQL transaction.
2025-11-22 18:19:44 +00:00
iequidoo
6514b4ca7f fix: Look up or create ad-hoc group if there are duplicate addresses in "To"
Fix `test_unencrypted_doesnt_goto_self_chat` as well, it was only testing the first message because
of using the same Message-ID for all messages.
2025-11-22 02:48:27 -03:00
link2xt
e7e31d7914 ci: do not use --encoding option for rst-lint
It was removed in rst-lint 2.0:
7b43036b4d
2025-11-22 05:26:03 +00:00
B. Petersen
51d6855e0d fix: add missing stock strings 2025-11-21 14:42:50 +01:00
Hocuri
2f90b55309 feat: Stock string for joining a channel (#7480)
Add a stock string `%1$s invited you to join this channel.\n\nWaiting
for the device of %2$s to reply…`, which is shown when a user starts to
join a channel.

I did _not_ add an equivalent to `%1$s replied, waiting for being added
to the group…`, which is shown when vg-auth-required was received. I
don't think that this would add any information that's interesting to
the user, other than 'something is happening, hang on'. And the more
text on the screen, the less likely that anyone reads it. But if others
think differently, we can also add it.

With this PR, joining a channel looks like this:

```
Msg#2003: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO]
Msg#2004: info (Contact#Contact#Info): Alice invited you to join this channel.

Waiting for the device of Alice to reply… [NOTICED][INFO]
Msg#2007🔒:  (Contact#Contact#2001): You joined the channel. [FRESH][INFO]
```
2025-11-20 21:13:04 +00:00
link2xt
be3e202470 feat: allow adding second transport 2025-11-20 15:51:19 +00:00
link2xt
57aadfbbf6 chore: update preloaded DNS cache 2025-11-19 19:55:53 +00:00
link2xt
849cde9757 refactor: remove some easy to remove unwrap() calls 2025-11-19 17:38:58 +00:00
link2xt
b4cd99fc56 docs: replace some references to Delta Chat with chatmail 2025-11-19 04:11:12 +00:00
iequidoo
9305a0676c fix: Assign outgoing self-sent unencrypted messages to ad-hoc groups with only SELF (#7409)
Before, outgoing self-sent unencrypted messages were assigned to the self-chat. Now we assign them
to ad-hoc groups with only SELF instead of 1:1 chats with address contacts corresponding to our own
addresses because we don't want to create such address contacts; we still use SELF for `from_id` of
such messages. Not assigning such messages to the encrypted chat should be safe enough and such
messages can actually be sent by the user from another MUA.
2025-11-18 20:34:56 -03:00
B. Petersen
39c9ba19ef docs: add missing RFC 9788, link 'Header Protection for Cryptographically Protected Email' as other RFC 2025-11-18 23:16:10 +01:00
link2xt
af574279fd docs: remove unsupported RFC 3503 ($MDNSent flag) from the list of standards 2025-11-18 21:44:41 +00:00
Hocuri
713c929e03 refactor: Rename add_encrypted_msg -> add_e2ee_notice 2025-11-18 18:58:26 +01:00
Hocuri
c83c131a37 feat: Rephrase "Establishing end-to-end encryption" -> "Establishing connection" 2025-11-18 18:58:26 +01:00
Hocuri
0d0602a4a5 fix: Sort system messages to the bottom of the chat
Fix #7435

For most messages, `calc_sort_timestamp()` makes sure that they are at the correct place; esp. that they are not above system messages or other noticed/seen messages.

Most callers of `add_info_msg()`, however, didn't call `calc_sort_timestamp()`, and just used `time()` or `smeared_time()` to get the sort timestamp. Because of this, system messages could sometimes wrongly be sorted above other messages.

This PR fixes this by making the sort timestamp optional in `add_info_msg*()`. If the sort timestamp isn't passed, then the message is sorted to the bottom of the chat. `sent_rcvd_timestamp` is not optional anymore, because we need _some_ timestamp that can be shown to the user (most callers just pass `time()` there).
2025-11-18 18:58:26 +01:00
link2xt
abfb556377 fix: set SQLite busy timeout to 1 minute on iOS
Closes <https://github.com/chatmail/core/issues/7464>
2025-11-18 17:07:27 +00:00
link2xt
72788daca0 refactor: use HashMap::extract_if() stabilized in Rust 1.88.0 2025-11-18 13:16:44 +00:00
iequidoo
16bd87c78f test: Contact shalln't be verified by another having unknown verifier
It must be verified by "unknown verifier" instead. But if the verifier has known verifier in turn,
it must reverify contacts having unknown verifier. Add a check for this also.
2025-11-18 05:42:46 -03:00
iequidoo
d44e2420bc fix: ContactId::set_name_ex(): Emit ContactsChanged when transaction is completed
This fixes flaky JSON-RPC's `test_rename_synchronization()`.
2025-11-18 02:17:31 -03:00
dependabot[bot]
88d213fcdb chore(deps): bump astral-sh/setup-uv from 7.1.2 to 7.1.3
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 7.1.2 to 7.1.3.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](85856786d1...5a7eac68fb)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-18 02:12:53 +00:00
link2xt
fb14acb0fb fix: limit the range of Date to up to 6 days in the past
Previous value (1000000 seconds) was slightly more than 11.5 days.
2025-11-17 23:17:55 +00:00
link2xt
a5c470fbae build(nix): update fenix and use it for all Rust builds
`fenix` input provides updated Rust packages.
Updating it is needed since current version is 1.86.0
and MSRV has been increased to 1.88.0.
2025-11-17 15:23:58 +00:00
link2xt
6bdba33d32 build: update rPGP from 0.17.0 to 0.18.0 2025-11-16 15:18:55 +00:00
link2xt
c6ace749e3 build: increase MSRV to 1.88.0
It is required by rPGP 0.18.0.

All the changes in `.rs` files are made automatically with `clippy --fix`.
2025-11-16 14:48:50 +00:00
link2xt
22ebd6436f feat: default bcc_self to 0 for new accounts 2025-11-16 10:00:00 +00:00
link2xt
cdfe436124 chore(release): prepare for 2.27.0 2025-11-16 06:34:11 +00:00
link2xt
e8823fcf35 test: test background_fetch() and stop_background_fetch() 2025-11-16 05:59:20 +00:00
link2xt
0136cfaf6a test: add pytest fixture for account manager 2025-11-16 05:59:20 +00:00
link2xt
07069c348b api(deltachat-rpc-client): add APIs for background fetch 2025-11-16 05:59:20 +00:00
Hocuri
26f6b85ff9 feat!: Withdraw broadcast invites. Add Qr::WithdrawJoinBroadcast and Qr::ReviveJoinBroadcast QR code types. (#7439)
Add the ability to withdraw broadcast invite codes

After merging:
- [x] Create issues in iOS, Desktop and UT repositories
2025-11-15 19:27:04 +01:00
Hocuri
10b6dd1f11 test(rpc-client): test_qr_securejoin_broadcast: Wait for incoming message before getting chatlist (#7442)
Fix flaky test by calling `get_broadcast()` after the message events
were received.

Alternative to https://github.com/chatmail/core/pull/7437
2025-11-15 18:49:16 +01:00
B. Petersen
cae642b024 fix: send webm as file, it is not supported by all UI 2025-11-15 14:55:40 +01:00
B. Petersen
54a2e94525 fix: deprecate deletion timer string for '1 Minute'
the minimum timestamp in UI is 5 minutes
and the old string is about to be removed from translations.
the 'seconds' fallback is good enough, however
2025-11-15 14:41:54 +01:00
link2xt
9d4ad00fc0 build(nix): exclude CONTRIBUTING.md from the source files 2025-11-15 10:56:08 +00:00
Nico de Haen
102b72aadd fix: escape connectivity html 2025-11-14 22:50:15 +00:00
link2xt
1c4d2dd78e api: add APIs to stop background fetch
New APIs are JSON-RPC method stop_background_fetch(),
Rust method Accounts.stop_background_fetch()
and C method dc_accounts_stop_background_fetch().

These APIs allow to cancel background fetch early
even before the initially set timeout,
for example on Android when the system calls
`Service.onTimeout()` for a `dataSync` foreground service.
2025-11-14 22:48:19 +00:00
link2xt
cd50c263e8 api!(jsonrpc): rename accounts_background_fetch() into background_fetch()
There is no JSON-RPC method to run background_fetch() for a single account,
so no need to have a qualifier saying that it is for all accounts.
2025-11-14 22:48:19 +00:00
iequidoo
1dbcd7f1f4 test: HP-Outer headers are added to messages with standard Header Protection (#7130) 2025-11-14 19:45:32 -03:00
iequidoo
c6894f56b2 feat: Add Config::StdHeaderProtectionComposing (enables composing as defined in RFC 9788) (#7130)
And enable it by default as the standard Header Protection is backward-compatible.

Also this tests extra IMF header removal when a message has standard Header Protection since now we
can send such messages.
2025-11-14 19:45:32 -03:00
iequidoo
e2ae6ae013 feat: mimeparser: Omit Legacy Display Elements (#7130)
Omit Legacy Display Elements from "text/plain" and "text/html" (implement 4.5.3.{2,3} of
https://www.rfc-editor.org/rfc/rfc9788 "Header Protection for Cryptographically Protected Email").
2025-11-14 19:45:32 -03:00
iequidoo
966ea28f83 feat: Ignore unprotected headers if Content-Type has "hp" parameter (#7130)
This is a part of implementation of https://www.rfc-editor.org/rfc/rfc9788 "Header Protection for
Cryptographically Protected Email".
2025-11-14 19:45:32 -03:00
link2xt
6611a9fa02 fix: always set bcc_self on backup import/export
Regardless of whether chatmail relay is used or not,
bcc_self should be enabled when second device is added.
It should also be enabled again even if the user
has turned it off manually.
2025-11-14 20:00:34 +00:00
Simon Laux
dc4ea1865a fix: set get_max_smtp_rcpt_to for chatmail to the actual limit of 1000 instead of unlimited. (#7432)
adb brought this up in an internal discussion.
With the recent introduction of channels it becomes easier to hit the
limit
and it becomes impossible to send messages to a channel with more than
1000 members, this pr fixes that.

---------

Co-authored-by: Hocuri <hocuri@gmx.de>
2025-11-13 18:36:35 +00:00
link2xt
4b1dff601d refactor: use wait_for_incoming_msg() in more tests 2025-11-13 00:25:16 +00:00
link2xt
a66808e25a api(rpc-client): add Account.wait_for_msg() 2025-11-13 00:25:16 +00:00
link2xt
7b54954401 test: port folder-related CFFI tests to JSON-RPC
Created new test_folders.py

Moved existing JSON-RPC tests:
- test_reactions_for_a_reordering_move
- test_delete_deltachat_folder

Ported tests:
- test_move_works_on_self_sent
- test_moved_markseen
- test_markseen_message_and_mdn
- test_mvbox_thread_and_trash (renamed to test_mvbox_and_trash)
- test_scan_folders
- test_move_works
- test_move_avoids_loop
- test_immediate_autodelete
- test_trash_multiple_messages

The change also contains fixes for direct_imap fixture
needed to use IMAP IDLE in JSON-RPC tests.
2025-11-12 08:07:40 +00:00
link2xt
d39ed9d0f1 test: fix flaky test_send_receive_locations 2025-11-12 05:50:00 +00:00
iequidoo
c499dabbe1 feat: Add Contact::get_or_gen_color. Use it in CFFI and JSON-RPC to avoid gray self-color (#7374)
`Contact::get_color()` returns gray if own keypair doesn't exist yet and we don't want any UIs
displaying it. Keypair generation can't be done in `get_color()` or `get_by_id_optional()` to avoid
breaking Core tests on key import. Also this makes the API clearer, pure getters shouldn't modify
any visible state.
2025-11-12 00:30:42 -03:00
Hocuri
e70307af1f feat: Tweak initial info-message for unencrypted chats (#7427)
Fix https://github.com/chatmail/core/issues/7404
2025-11-11 19:28:28 +01:00
link2xt
69a3a31554 chore(release): prepare for 2.26.0 2025-11-11 17:30:19 +00:00
link2xt
1cb0a25e16 fix: do not ignore I/O errors in BlobObject::store_from_base64 2025-11-08 20:06:45 +00:00
iequidoo
fdea6c8af3 feat: Error toast for "Not creating securejoin QR for old broadcast" 2025-11-08 16:23:15 -03:00
link2xt
2e9fd1c25d test: do not add QR inviter to groups right after scanning the code
The inviter may be not part of the group
by the time we scan the QR code.
2025-11-08 03:26:23 +00:00
link2xt
1b1a5f170e test: Bob has 0 members in the chat until securejoin finishes 2025-11-08 03:26:23 +00:00
link2xt
1946603be6 test: at the end of securejoin Bob has two members in a group chat 2025-11-08 03:26:23 +00:00
link2xt
c43b622c23 test: move test_two_group_securejoins from receive_imf to securejoin module 2025-11-08 03:26:23 +00:00
link2xt
73bf6983b9 fix: do not add QR inviter to groups immediately
By the time you scan the QR code,
inviter may not be in the group already.
In this case securejoin protocol will never complete.
If you then join the group in some other way,
this results in you implicitly adding that inviter
to the group.
2025-11-08 03:26:23 +00:00
link2xt
aaa0f8e245 fix: do not return an error from receive_imf if we fail to add a member because we are not in chat
This happens when we receive a vg-request-with-auth message
for a chat from which we have been removed already.
2025-11-08 03:26:23 +00:00
link2xt
5a1e0e8824 chore: rustfmt 2025-11-08 03:26:23 +00:00
link2xt
cf5b145ce0 refactor: remove unused imports 2025-11-07 17:31:34 +00:00
link2xt
dd11a0e29a refactor: replace imap:: calls in migration 73 with SQL queries 2025-11-07 07:12:08 +00:00
link2xt
3d86cb5953 test: remove ThreadPoolExecutor from test_wait_next_messages 2025-11-07 07:09:35 +00:00
link2xt
75eb94e44f docs: fix Context::set_stock_translation reference 2025-11-07 06:56:10 +00:00
link2xt
7fef812b1e refactor(imap): move resync request from Context to Imap
For multiple transports we will need to run
multiple IMAP clients in parallel.
UID validity change detected by one IMAP client
should not result in UID resync
for another IMAP client.
2025-11-06 19:16:30 +00:00
link2xt
5f174ceaf2 test: test editing saved messages 2025-11-06 18:38:11 +00:00
link2xt
06b038ab5d fix: is_encrypted() should be true for Saved Messages chat
Otherwise UIs don't allow to edit messages sent to self.
This was likely broken in b417ba86bc
2025-11-06 18:38:11 +00:00
Simon Laux
b20da3cb0e docs: readme: update language binding section to avoid usage of cffi in new projects (#7380)
Updated language bindings section to reflect deprecation of
`libdeltachat and removed outdated entries.
2025-11-06 13:04:56 +00:00
Simon Laux
a3328ea2de api!(jsonrpc): chat_type now contains a variant of a string enum/union. Affected places: FullChat.chat_type, BasicChat.chat_type, ChatListItemFetchResult::ChatListItem.chat_type, Event:: SecurejoinInviterProgress.chat_type and MessageSearchResult.chat_type (#7285)
Actually it will be not as breaking if you used the constants, because
this pr also changes the constants.

closes #7029 

Note that I had to change the constants from enum to namespace, this has
the side effect, that you can no longer also use the constants as types,
you need to instead prefix them with `typeof ` now.
2025-11-06 12:53:48 +00:00
Hocuri
ee75094bef chore(release): prepare for 2.25.0 2025-11-05 17:27:00 +01:00
Hocuri
a40fd288fc fix: add info message if user tries to create a QR code for deprecated channel (#7399)
Fix https://github.com/chatmail/core/issues/7397:
- Don't allow creating a QR code for such old channels.
2025-11-05 17:16:54 +01:00
link2xt
81ba2d20d6 fix: add device message instead of partial message when receive_imf fails 2025-11-05 14:11:27 +00:00
Hocuri
f04c881b8c feat: Put self-name into group invite codes (#7398)
Fix https://github.com/chatmail/core/issues/7015 by putting the
self-name into invite codes for group and broadcast channels.

The self-name will be truncated to 16 characters.
2025-11-04 23:17:54 +01:00
bjoern
ee6b9075aa slightly nicer and shorter QR and invite codes (#7390)
- sort garbage to the beginning, readable text to the end
- instead of `%20`, make use of `+` to encode spaces
- shorter invite links and smaller QR codes by truncation of the names

the truncation of the name uses chars() which does not respect grapheme clusters, so
that last character may be wrong. not sure if there is a nice and easy
alternative, but maybe it's good engoug - the real, full name will come
over the wire (exiting truncate() truncates on word boundaries, which is
maybe too soft here - names may be long, depending on the language, and
not contain any space)

moreover, this resolves the "name too long" issue from
https://github.com/chatmail/core/issues/7015

---------

Co-authored-by: Hocuri <hocuri@gmx.de>
2025-11-04 22:01:24 +01:00
link2xt
9c2a13b88e refactor(sql): do not expose rusqlite Error type in query_map methods
We use query_and_then() instead of query_map() function now.
The difference is that row processing function
returns anyhow::Result, so simple fallible processing
like JSON parsing can be done inside of it
when calling query_map_vec() and query_map_collect()
without having to resort to query_map()
and iterating over all rows again afterwards.
2025-11-03 23:08:56 +00:00
dependabot[bot]
1db6ea70cc chore(deps): bump astral-sh/setup-uv from 7.1.0 to 7.1.2
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 7.1.0 to 7.1.2.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](3259c6206f...85856786d1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-03 22:08:18 +00:00
dependabot[bot]
da2d9620cd chore(deps): bump actions/download-artifact from 5 to 6
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-03 22:08:00 +00:00
dependabot[bot]
d1dcb739f2 chore(deps): bump actions/upload-artifact from 4 to 5
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-03 22:07:43 +00:00
Hocuri
e34687ba42 core(release): prepare for v2.24.0 2025-11-03 22:12:36 +01:00
Hocuri
5034449009 feat!: QR codes and symmetric encryption for broadcast channels (#7268)
Follow-up for https://github.com/chatmail/core/pull/7042, part of
https://github.com/chatmail/core/issues/6884.

This will make it possible to create invite-QR codes for broadcast
channels, and make them symmetrically end-to-end encrypted.

- [x] Go through all the changes in #7042, and check which ones I still
need, and revert all other changes
- [x] Use the classical Securejoin protocol, rather than the new 2-step
protocol
- [x] Make the Rust tests pass
- [x] Make the Python tests pass
- [x] Fix TODOs in the code
- [x] Test it, and fix any bugs I find
- [x] I found a bug when exporting all profiles at once fails sometimes,
though this bug is unrelated to channels:
https://github.com/chatmail/core/issues/7281
- [x] Do a self-review (i.e. read all changes, and check if I see some
things that should be changed)
- [x] Have this PR reviewed and merged
- [ ] Open an issue for "TODO: There is a known bug in the securejoin
protocol"
- [ ] Create an issue that outlines how we can improve the Securejoin
protocol in the future (I don't have the time to do this right now, but
want to do it sometime in winter)
- [ ] Write a guide for UIs how to adapt to the changes (see
https://github.com/deltachat/deltachat-android/pull/3886)

## Backwards compatibility

This is not very backwards compatible:
- Trying to join a symmetrically-encrypted broadcast channel with an old
device will fail
- If you joined a symmetrically-encrypted broadcast channel with one
device, and use an old core on the other device, then the other device
will show a mostly empty chat (except for two device messages)
- If you created a broadcast channel in the past, then you will get an
error message when trying to send into the channel:

> The up to now "experimental channels feature" is about to become an officially supported one. By that, privacy will be improved, it will become faster, and less traffic will be consumed.
> 
> As we do not guarantee feature-stability for such experiments, this means, that you will need to create the channel again. 
> 
> Here is what to do:
>  • Create a new channel
>  • Tap on the channel name
>  • Tap on "QR Invite Code"
>  • Have all recipients scan the QR code, or send them the link
> 
> If you have any questions, please send an email to delta@merlinux.eu or ask at https://support.delta.chat/.


## The symmetric encryption

Symmetric encryption uses a shared secret. Currently, we use AES128 for
encryption everywhere in Delta Chat, so, this is what I'm using for
broadcast channels (though it wouldn't be hard to switch to AES256).

The secret shared between all members of a broadcast channel has 258
bits of entropy (see `fn create_broadcast_shared_secret` in the code).

Since the shared secrets have more entropy than the AES session keys,
it's not necessary to have a hard-to-compute string2key algorithm, so,
I'm using the string2key algorithm `salted`. This is fast enough that
Delta Chat can just try out all known shared secrets. [^1] In order to
prevent DOS attacks, Delta Chat will not attempt to decrypt with a
string2key algorithm other than `salted` [^2].

## The "Securejoin" protocol that adds members to the channel after they
scanned a QR code

This PR uses the classical securejoin protocol, the same that is also
used for group and 1:1 invitations.

The messages sent back and forth are called `vg-request`,
`vg-auth-required`, `vg-request-with-auth`, and `vg-member-added`. I
considered using the `vc-` prefix, because from a protocol-POV, the
distinction between `vc-` and `vg-` isn't important (as @link2xt pointed
out in an in-person discussion), but
1. it would be weird if groups used `vg-` while broadcasts and 1:1 chats
used `vc-`,
2. we don't have a `vc-member-added` message yet, so, this would mean
one more different kind of message
3. we anyways want to switch to a new securejoin protocol soon, which
will be a backwards incompatible change with a transition phase. When we
do this change, we can make everything `vc-`.



[^1]: In a symmetrically encrypted message, it's not visible which
secret was used to encrypt without trying out all secrets. If this does
turn out to be too slow in the future, then we can remember which secret
was used more recently, and and try the most recent secret first. If
this is still too slow, then we can assign a short, non-unique (~2
characters) id to every shared secret, and send it in cleartext. The
receiving Delta Chat will then only try out shared secrets with this id.
Of course, this would leak a little bit of metadata in cleartext, so, I
would like to avoid it.
[^2]: A DOS attacker could send a message with a lot of encrypted
session keys, all of which use a very hard-to-compute string2key
algorithm. Delta Chat would then try to decrypt all of the encrypted
session keys with all of the known shared secrets. In order to prevent
this, as I said, Delta Chat will not attempt to decrypt with a
string2key algorithm other than `salted`

BREAKING CHANGE: A new QR type AskJoinBroadcast; cloning a broadcast
channel is no longer possible; manually adding a member to a broadcast
channel is no longer possible (only by having them scan a QR code)
2025-11-03 21:02:13 +01:00
link2xt
997e8216bf refactor: split "transport" module out of "login_param"
`login_param` module is now for user-visible entered login parameters,
while the `transport` module contains structures for internal
representation of connection candidate list
created during transport configuration.
2025-11-03 18:58:36 +00:00
iequidoo
7f059140be docs: Comment why spaced en dash is used to separate message Subject from text 2025-11-01 14:06:55 -03:00
link2xt
c9b3da4a1a chore(release): prepare for 2.23.0 2025-11-01 16:03:01 +00:00
link2xt
098084b9a7 feat: temporarily disable OpenPGP recipient anonymization 2025-11-01 15:27:00 +00:00
Simon Laux
9bc2aeebb8 feat: show if proxy is enabled in connectivity view (#7359)
closes #7269
2025-10-31 23:53:05 +01:00
link2xt
56370c2f90 ci: update Rust to 1.91.0 2025-10-31 12:33:47 +00:00
link2xt
59959259bf chore: fix Rust 1.91.0 lint for derivable Default 2025-10-31 12:33:47 +00:00
link2xt
08f8f488b1 refactor: remove unused call to get_credentials() 2025-10-31 12:33:35 +00:00
link2xt
f34311d5c4 build: do not install pdbpp in the test environment for CFFI Python bindings
Closes <https://github.com/chatmail/core/issues/7376>
2025-10-31 12:33:23 +00:00
link2xt
885a5efa39 fix: stop notifying about messages in contact request chats 2025-10-31 07:31:35 +00:00
Hocuri
8b4c718b6b feat(backwards-compat): For now, send Chat-Verified header (instead of _verified) again 2025-10-29 14:52:54 +00:00
link2xt
2ada3cd613 fix: stop using leftgrps table 2025-10-28 19:41:47 +00:00
Simon Laux
b920552fc3 api: jsonrpc: typescript remove unused constants (#7355) 2025-10-28 17:59:32 +01:00
Simon Laux
92c31903c6 api: jsonrpc: add get_push_state to check push notification state (#7356) 2025-10-28 17:58:11 +01:00
dependabot[bot]
145145f0fb chore(deps): bump cachix/install-nix-action from 31.8.0 to 31.8.1
Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.8.0 to 31.8.1.
- [Release notes](https://github.com/cachix/install-nix-action/releases)
- [Changelog](https://github.com/cachix/install-nix-action/blob/master/RELEASE.md)
- [Commits](7ab6e7fd29...fd24c48048)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-28 12:28:33 +00:00
link2xt
05ba206c5a feat: allow plain domain in dcaccount: scheme
This is similar to old `dcaccount:` with URL,
but creates a 9-character username on the client
and avoids making an HTTPS request.

The scheme is reused to avoid the apps
needing to register for the new scheme.

`http` support is removed because it was
not working already, there is a check
that the scheme is `https` when the URL
is actually used and the core has
no way to make HTTP requests without TLS.
2025-10-28 12:10:52 +00:00
link2xt
9f0d106818 api(deltachat-rpc-client): add Account.add_transport_from_qr() API 2025-10-28 12:10:52 +00:00
link2xt
21caf87119 refactor: use SampleString 2025-10-28 12:10:52 +00:00
link2xt
4abc695790 refactor: use rand::fill() instead of rand::rng().fill() 2025-10-28 12:10:52 +00:00
dependabot[bot]
df1a7ca386 chore(deps): bump actions/setup-node from 5 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 22:20:58 +00:00
155 changed files with 9378 additions and 5129 deletions

View File

@@ -20,10 +20,10 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.90.0
RUST_VERSION: 1.91.0
# Minimum Supported Rust Version
MSRV: 1.85.0
MSRV: 1.88.0
jobs:
lint_rust:
@@ -168,7 +168,7 @@ jobs:
run: cargo build -p deltachat_ffi
- name: Upload C library
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: ${{ matrix.os }}-libdeltachat.a
path: target/debug/libdeltachat.a
@@ -193,7 +193,7 @@ jobs:
run: cargo build -p deltachat-rpc-server
- name: Upload deltachat-rpc-server
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: ${{ matrix.os }}-deltachat-rpc-server
path: ${{ matrix.os == 'windows-latest' && 'target/debug/deltachat-rpc-server.exe' || 'target/debug/deltachat-rpc-server' }}
@@ -252,7 +252,7 @@ jobs:
persist-credentials: false
- name: Download libdeltachat.a
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: ${{ matrix.os }}-libdeltachat.a
path: target/debug
@@ -313,7 +313,7 @@ jobs:
run: pip install tox
- name: Download deltachat-rpc-server
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: ${{ matrix.os }}-deltachat-rpc-server
path: target/debug

View File

@@ -34,13 +34,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
- name: Upload binary
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: deltachat-rpc-server-${{ matrix.arch }}-linux
path: result/bin/deltachat-rpc-server
@@ -58,13 +58,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
- name: Upload binary
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: deltachat-rpc-server-${{ matrix.arch }}
path: result/bin/deltachat-rpc-server.exe
@@ -91,7 +91,7 @@ jobs:
run: cargo build --release --package deltachat-rpc-server --target ${{ matrix.arch }}-apple-darwin --features vendored
- name: Upload binary
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: deltachat-rpc-server-${{ matrix.arch }}-macos
path: target/${{ matrix.arch }}-apple-darwin/release/deltachat-rpc-server
@@ -109,13 +109,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
- name: Upload binary
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: deltachat-rpc-server-${{ matrix.arch }}-android
path: result/bin/deltachat-rpc-server
@@ -136,70 +136,70 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-aarch64-linux
path: deltachat-rpc-server-aarch64-linux.d
- name: Download Linux armv7l binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-armv7l-linux
path: deltachat-rpc-server-armv7l-linux.d
- name: Download Linux armv6l binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-armv6l-linux
path: deltachat-rpc-server-armv6l-linux.d
- name: Download Linux i686 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-i686-linux
path: deltachat-rpc-server-i686-linux.d
- name: Download Linux x86_64 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-x86_64-linux
path: deltachat-rpc-server-x86_64-linux.d
- name: Download Win32 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-win32
path: deltachat-rpc-server-win32.d
- name: Download Win64 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-win64
path: deltachat-rpc-server-win64.d
- name: Download macOS binary for x86_64
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-x86_64-macos
path: deltachat-rpc-server-x86_64-macos.d
- name: Download macOS binary for aarch64
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-aarch64-macos
path: deltachat-rpc-server-aarch64-macos.d
- name: Download Android binary for arm64-v8a
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-arm64-v8a-android
path: deltachat-rpc-server-arm64-v8a-android.d
- name: Download Android binary for armeabi-v7a
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-armeabi-v7a-android
path: deltachat-rpc-server-armeabi-v7a-android.d
@@ -294,67 +294,67 @@ jobs:
python-version: "3.11"
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-aarch64-linux
path: deltachat-rpc-server-aarch64-linux.d
- name: Download Linux armv7l binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-armv7l-linux
path: deltachat-rpc-server-armv7l-linux.d
- name: Download Linux armv6l binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-armv6l-linux
path: deltachat-rpc-server-armv6l-linux.d
- name: Download Linux i686 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-i686-linux
path: deltachat-rpc-server-i686-linux.d
- name: Download Linux x86_64 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-x86_64-linux
path: deltachat-rpc-server-x86_64-linux.d
- name: Download Win32 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-win32
path: deltachat-rpc-server-win32.d
- name: Download Win64 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-win64
path: deltachat-rpc-server-win64.d
- name: Download macOS binary for x86_64
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-x86_64-macos
path: deltachat-rpc-server-x86_64-macos.d
- name: Download macOS binary for aarch64
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-aarch64-macos
path: deltachat-rpc-server-aarch64-macos.d
- name: Download Android binary for arm64-v8a
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-arm64-v8a-android
path: deltachat-rpc-server-arm64-v8a-android.d
- name: Download Android binary for armeabi-v7a
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: deltachat-rpc-server-armeabi-v7a-android
path: deltachat-rpc-server-armeabi-v7a-android.d
@@ -384,7 +384,7 @@ jobs:
ls -lah
- name: Upload to artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: deltachat-rpc-server-npm-package
path: deltachat-rpc-server/npm-package/*.tgz
@@ -401,7 +401,7 @@ jobs:
deltachat-rpc-server/npm-package/*.tgz
# Configure Node.js for publishing.
- uses: actions/setup-node@v5
- uses: actions/setup-node@v6
with:
node-version: 20
registry-url: "https://registry.npmjs.org"

View File

@@ -19,7 +19,7 @@ jobs:
show-progress: false
persist-credentials: false
- uses: actions/setup-node@v5
- uses: actions/setup-node@v6
with:
node-version: 20
registry-url: "https://registry.npmjs.org"

View File

@@ -21,7 +21,7 @@ jobs:
show-progress: false
persist-credentials: false
- name: Use Node.js 18.x
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: 18.x
- name: Add Rust cache

View File

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

View File

@@ -23,7 +23,7 @@ jobs:
working-directory: deltachat-rpc-client
run: python3 -m build
- name: Store the distribution packages
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: python-package-distributions
path: deltachat-rpc-client/dist/
@@ -42,7 +42,7 @@ jobs:
steps:
- name: Download all the dists
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
name: python-package-distributions
path: dist/

View File

@@ -18,11 +18,11 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- name: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: repl.exe
path: "result/bin/deltachat-repl.exe"

View File

@@ -36,7 +36,7 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- 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@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31
- uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31
- name: Build C documentation
run: nix build .#docs
- name: Upload to c.delta.chat
@@ -78,7 +78,7 @@ jobs:
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- name: Use Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '18'
- name: npm install

View File

@@ -19,7 +19,7 @@ jobs:
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3
- name: Run zizmor
run: uvx zizmor --format sarif . > results.sarif

View File

@@ -1,5 +1,267 @@
# Changelog
## [2.28.0] - 2025-11-23
### API-Changes
- New API `get_existing_msg_ids()` to check if the messages with given IDs exist.
- Add API to get storage usage information. (JSON-RPC method: `get_storage_usage_report_string`) ([#7486](https://github.com/chatmail/core/pull/7486)).
### Features / Changes
- Experimentaly allow adding second transport.
There is no synchronization yet, so UIs should not allow the user to change the address manually and only expose the ability to add transports if `bcc_self` is disabled.
- Default `bcc_self` to 0 for all new accounts.
- Rephrase "Establishing end-to-end encryption" -> "Establishing connection".
- Stock string for joining a channel ([#7480](https://github.com/chatmail/core/pull/7480)).
### Fixes
- Limit the range of `Date` to up to 6 days in the past.
- `ContactId::set_name_ex()`: Emit ContactsChanged when transaction is completed.
- Set SQLite busy timeout to 1 minute on iOS.
- Sort system messages to the bottom of the chat.
- Assign outgoing self-sent unencrypted messages to ad-hoc groups with only SELF ([#7409](https://github.com/chatmail/core/pull/7409)).
- Add missing stock strings.
- Look up or create ad-hoc group if there are duplicate addresses in "To".
### Documentation
- Add missing RFC 9788, link 'Header Protection for Cryptographically Protected Email' as other RFC.
- Remove unsupported RFC 3503 (`$MDNSent` flag) from the list of standards.
- Mark database encryption support as deprecated ([#7403](https://github.com/chatmail/core/pull/7403)).
### Build system
- Increase Minimum Supported Rust Version to 1.88.0.
- Update rPGP from 0.17.0 to 0.18.0.
- nix: Update `fenix` and use it for all Rust builds.
### CI
- Do not use --encoding option for rst-lint.
### Refactor
- Use `HashMap::extract_if()` stabilized in Rust 1.88.0.
- Remove some easy to remove unwrap() calls.
### Tests
- Contact shalln't be verified by another having unknown verifier.
## [2.27.0] - 2025-11-16
### API-Changes
- Add APIs to stop background fetch.
- [**breaking**]: rename JSON-RPC method accounts_background_fetch() into background_fetch()
- rpc-client: Add APIs for background fetch.
- rpc-client: Add Account.wait_for_msg().
- Deprecate deletion timer string for '1 Minute'.
### Features / Changes
- Implement RFC 9788 (Header Protection for Cryptographically Protected Email) ([#7130](https://github.com/chatmail/core/pull/7130)).
- Tweak initial info-message for unencrypted chats ([#7427](https://github.com/chatmail/core/pull/7427)).
- Add Contact::get_or_gen_color. Use it in CFFI and JSON-RPC to avoid gray self-color ([#7374](https://github.com/chatmail/core/pull/7374)).
- [**breaking**] Withdraw broadcast invites. Add Qr::WithdrawJoinBroadcast and Qr::ReviveJoinBroadcast QR code types. ([#7439](https://github.com/chatmail/core/pull/7439)).
### Fixes
- Set `get_max_smtp_rcpt_to` for chatmail to the actual limit of 1000 instead of unlimited. ([#7432](https://github.com/chatmail/core/pull/7432)).
- Always set bcc_self on backup import/export.
- Escape connectivity HTML.
- Send webm as file, it is not supported by all UI.
### Build system
- nix: Exclude CONTRIBUTING.md from the source files.
### Refactor
- Use wait_for_incoming_msg() in more tests.
### Tests
- Fix flaky test_send_receive_locations.
- Port folder-related CFFI tests to JSON-RPC.
- HP-Outer headers are added to messages with standard Header Protection ([#7130](https://github.com/chatmail/core/pull/7130)).
- rpc-client: Test_qr_securejoin_broadcast: Wait for incoming message before getting chatlist ([#7442](https://github.com/chatmail/core/pull/7442)).
- Add pytest fixture for account manager.
- Test background_fetch() and stop_background_fetch().
## [2.26.0] - 2025-11-11
### API-Changes
- [**breaking**] JSON-RPC: `chat_type` now contains a variant of a string enum/union. Affected places: `FullChat.chat_type`, `BasicChat.chat_type`, `ChatListItemFetchResult::ChatListItem.chat_type`, `Event:: SecurejoinInviterProgress.chat_type` and `MessageSearchResult.chat_type` ([#7285](https://github.com/chatmail/core/pull/7285))
### Features / Changes
- Error toast for "Not creating securejoin QR for old broadcast".
### Fixes
- `is_encrypted()` should be true for Saved Messages chat so messages there are editable.
- Do not return an error from `receive_imf` if we fail to add a member because we are not in chat.
- Do not add QR inviter to groups immediately.
- Do not ignore I/O errors in `BlobObject::store_from_base64`.
### Miscellaneous Tasks
- Rustfmt.
### Refactor
- imap: Move resync request from Context to Imap.
- Replace imap:: calls in migration 73 with SQL queries.
- Remove unused imports.
### Documentation
- Readme: update language binding section to avoid usage of cffi in new projects ([#7380](https://github.com/chatmail/core/pull/7380)).
- Fix Context::set_stock_translation reference.
### Tests
- Test editing saved messages.
- Remove ThreadPoolExecutor from test_wait_next_messages.
- Move test_two_group_securejoins from receive_imf to securejoin module.
- At the end of securejoin Bob has two members in a group chat.
- Bob has 0 members in the chat until securejoin finishes.
- Do not add QR inviter to groups right after scanning the code.
## [2.25.0] - 2025-11-05
### Features / Changes
- Put self-name into group invite codes ([#7398](https://github.com/chatmail/core/pull/7398)).
- Slightly nicer and shorter QR and invite codes ([#7390](https://github.com/chatmail/core/pull/7390))
### Fixes
- Add device message instead of partial message when receive_imf fails. This fixes a rare bug where the IMAP loop got stuck.
- Add info message if user tries to create a QR code for deprecated channel ([#7399](https://github.com/chatmail/core/pull/7399)).
### Miscellaneous Tasks
- deps: Bump actions/upload-artifact from 4 to 5.
- deps: Bump actions/download-artifact from 5 to 6.
- deps: Bump astral-sh/setup-uv from 7.1.0 to 7.1.2.
### Refactor
- sql: Do not expose rusqlite Error type in query_map methods.
## [2.24.0] - 2025-11-03
***Note that in v2.24.0, the IMAP loop can get stuck in rare circumstances;
use v2.23.0 or v2.25.0 instead.***
### Documentation
- Comment why spaced en dash is used to separate message Subject from text.
### Features / Changes
- [**breaking**] QR codes and symmetric encryption for broadcast channels ([#7268](https://github.com/chatmail/core/pull/7268)).
- A new QR type AskJoinBroadcast; cloning a broadcast
channel is no longer possible; manually adding a member to a broadcast
channel is no longer possible (the only way to join a channel is scanning a QR code or clicking a link)
### Refactor
- Split "transport" module out of "login_param".
## [2.23.0] - 2025-11-01
### API-Changes
- Make `dc_chat_is_protected` always return 0.
- [**breaking**] Remove public APIs to check if the chat is protected.
- [**breaking**] Remove APIs to create protected chats.
- [**breaking**] Remove Chat.is_protected().
- deltachat-rpc-client: Add Account.add_transport_from_qr() API.
- JSON-RPC: add `get_push_state` to check push notification state ([#7356](https://github.com/chatmail/core/pull/7356)).
- JSON-RPC: remove unused TypeScript constants ([#7355](https://github.com/chatmail/core/pull/7355)).
- Remove `Config::SentboxWatch` ([#7178](https://github.com/chatmail/core/pull/7178)).
- Remove `Config::ConfiguredSentboxFolder` and everything related.
### Build system
- Ignore configuration for the zed editor ([#7322](https://github.com/chatmail/core/pull/7322)).
- nix: Fix build of deltachat-rpc-server-x86_64-darwin.
- Update rand to 0.9.
- Do not install `pdbpp` in the test environment for CFFI Python bindings.
- Migrate from tokio-tar to astral-tokio-tar.
- deps: Bump actions/setup-node from 5 to 6.
- deps: Bump cachix/install-nix-action from 31.8.0 to 31.8.1.
- Fix Rust 1.91.0 lint for derivable Default.
### CI
- Pin GitHub action `astral-sh/setup-uv`.
- Set 7 days cooldown on Dependabot updates.
- Update Rust to 1.91.0.
### Documentation
- Document Autocrypt-Gossip `_verified` attribute.
### Features/Changes
Metadata reduction:
- Protect Autocrypt header.
- Anonymize OpenPGP recipients (temorarily disabled due to interoperability problems, see <https://github.com/chatmail/core/issues/7384>).
- Protect the `Date` header.
Onboarding improvements:
- Allow plain domain in `dcaccount:` scheme.
- Do not resolve MX records during configuration.
Preparation for multi-transport:
- Move the messages only from INBOX and Spam folders.
- deltachat-rpc-client: Support multiple transports in resetup_account().
Various other changes:
- Opt-in weekly sending of statistics ([#6851](https://github.com/chatmail/core/pull/6851))
- Synchronize encrypted groups creation across devices ([#7001](https://github.com/chatmail/core/pull/7001)).
- Do not send Autocrypt in MDNs.
- Do not run SecureJoin if we are already in the group.
- Show if proxy is enabled in connectivity view ([#7359](https://github.com/chatmail/core/pull/7359)).
### Fixes
- Don't ignore QR token timestamp from sync messages.
- Do not allow sync item timestamps to be in the future.
- jsonrpc: Fix `ChatListItem::is_self_in_group`.
- Delete obsolete "configured*" keys from `config` table ([#7171](https://github.com/chatmail/core/pull/7171)).
- Fix flaky tests::verified_chats::test_verified_chat_editor_reordering and receive_imf::receive_imf_tests::test_two_group_securejoins.
- Stop using `leftgrps` table.
- Stop notifying about messages in contact request chats.
### Refactor
- Remove invalid Gmail OAuth2 tokens.
- Remove ProtectionStatus.
- Rename chat::create_group_chat() to create_group().
- Remove error stock strings that are rarely used these days ([#7327](https://github.com/chatmail/core/pull/7327)).
- Jsonrpc rename change casing in names of jsonrpc structs/enums to comply with rust naming conventions. ([#7324](https://github.com/chatmail/core/pull/7324)).
- Stop using deprecated Account.configure().
- add_transport_from_qr: Do not set deprecated config values.
- sql: Change second query_map function from FnMut to FnOnce.
- sql: Add query_map_vec().
- sql: Add query_map_collect().
- Use rand::fill() instead of rand::rng().fill().
- Use SampleString.
- Remove unused call to get_credentials().
### Tests
- rpc-client: VCard color is the same as the contact color ([#7294](https://github.com/chatmail/core/pull/7294)).
- Add unique offsets to ids generated by `TestContext` to increase test correctness ([#7297](https://github.com/chatmail/core/pull/7297)).
## [2.22.0] - 2025-10-17
### Fixes
@@ -6968,3 +7230,9 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.20.0]: https://github.com/chatmail/core/compare/v2.19.0..v2.20.0
[2.21.0]: https://github.com/chatmail/core/compare/v2.20.0..v2.21.0
[2.22.0]: https://github.com/chatmail/core/compare/v2.21.0..v2.22.0
[2.23.0]: https://github.com/chatmail/core/compare/v2.22.0..v2.23.0
[2.24.0]: https://github.com/chatmail/core/compare/v2.23.0..v2.24.0
[2.25.0]: https://github.com/chatmail/core/compare/v2.24.0..v2.25.0
[2.26.0]: https://github.com/chatmail/core/compare/v2.25.0..v2.26.0
[2.27.0]: https://github.com/chatmail/core/compare/v2.26.0..v2.27.0
[2.28.0]: https://github.com/chatmail/core/compare/v2.27.0..v2.28.0

View File

@@ -1,4 +1,4 @@
# Contributing to Delta Chat
# Contributing to chatmail core
## Bug reports

1811
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
[package]
name = "deltachat"
version = "2.22.0"
version = "2.28.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.85"
rust-version = "1.88"
repository = "https://github.com/chatmail/core"
[profile.dev]
@@ -66,8 +66,8 @@ humansize = "2"
hyper = "1"
hyper-util = "0.1.16"
image = { version = "0.25.6", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
iroh-gossip = { version = "0.94", default-features = false, features = ["net"] }
iroh = { version = "0.94", default-features = false }
iroh-gossip = { version = "0.35", default-features = false, features = ["net"] }
iroh = { version = "0.35", default-features = false }
kamadak-exif = "0.6.1"
libc = { workspace = true }
mail-builder = { version = "0.4.4", default-features = false }
@@ -78,7 +78,7 @@ num-derive = "0.4"
num-traits = { workspace = true }
parking_lot = "0.12.4"
percent-encoding = "2.3"
pgp = { version = "0.17.0", default-features = false }
pgp = { version = "0.18.0", default-features = false }
pin-project = "1"
qrcodegen = "1.7.0"
quick-xml = { version = "0.38", features = ["escape-html"] }
@@ -156,6 +156,11 @@ name = "receive_emails"
required-features = ["internals"]
harness = false
[[bench]]
name = "decrypting"
required-features = ["internals"]
harness = false
[[bench]]
name = "get_chat_msgs"
harness = false

View File

@@ -197,12 +197,10 @@ and then run the script.
Language bindings are available for:
- **C** \[[📂 source](./deltachat-ffi) | [📚 docs](https://c.delta.chat)\]
- -> libdeltachat is going to be deprecated and only exists because Android, iOS and Ubuntu Touch are still using it. If you build a new project, then please use the jsonrpc api instead.
- **JS**: \[[📂 source](./deltachat-rpc-client) | [📦 npm](https://www.npmjs.com/package/@deltachat/jsonrpc-client) | [📚 docs](https://js.jsonrpc.delta.chat/)\]
- **Python** \[[📂 source](./python) | [📦 pypi](https://pypi.org/project/deltachat) | [📚 docs](https://py.delta.chat)\]
- **Go**
- over jsonrpc: \[[📂 source](https://github.com/deltachat/deltachat-rpc-client-go/)\]
- over cffi[^1]: \[[📂 source](https://github.com/deltachat/go-deltachat/)\]
- **Free Pascal**[^1] \[[📂 source](https://github.com/deltachat/deltachat-fp/)\]
- **Go** \[[📂 source](https://github.com/deltachat/deltachat-rpc-client-go/)\]
- **Java** and **Swift** (contained in the Android/iOS repos)
The following "frontend" projects make use of the Rust-library
@@ -215,5 +213,3 @@ or its language bindings:
- [Telepathy](https://code.ur.gs/lupine/telepathy-padfoot/)
- [Ubuntu Touch](https://codeberg.org/lk108/deltatouch)
- several **Bots**
[^1]: Out of date / unmaintained, if you like those languages feel free to start maintaining them. If you have questions we'll help you, please ask in the issues.

200
benches/decrypting.rs Normal file
View File

@@ -0,0 +1,200 @@
//! Benchmarks for message decryption,
//! comparing decryption of symmetrically-encrypted messages
//! to decryption of asymmetrically-encrypted messages.
//!
//! Call with
//!
//! ```text
//! cargo bench --bench decrypting --features="internals"
//! ```
//!
//! or, if you want to only run e.g. the 'Decrypt a symmetrically encrypted message' benchmark:
//!
//! ```text
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt a symmetrically encrypted message'
//! ```
//!
//! You can also pass a substring.
//! So, you can run all 'Decrypt and parse' benchmarks with:
//!
//! ```text
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt and parse'
//! ```
//!
//! 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 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::{
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, decrypt, pk_encrypt, symm_encrypt_message},
stock_str::StockStrings,
};
use rand::{Rng, rng};
use tempfile::tempdir;
const NUM_SECRETS: usize = 500;
async fn create_context() -> Context {
let dir = tempdir().unwrap();
let dbfile = dir.path().join("db.sqlite");
let context = Context::new(dbfile.as_path(), 100, Events::new(), StockStrings::new())
.await
.unwrap();
context
.set_config(Config::ConfiguredAddr, Some("bob@example.net"))
.await
.unwrap();
let secret = key_from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap();
let public = secret.signed_public_key();
let key_pair = KeyPair { public, secret };
store_self_keypair(&context, &key_pair)
.await
.expect("Failed to save key");
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())],
Some(key_pair.secret.clone()),
true,
true,
)
.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())
// ===========================================================================================
let rt = tokio::runtime::Runtime::new().unwrap();
let mut secrets = generate_secrets();
// "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();
let context = rt.block_on(async {
let context = create_context().await;
for (i, secret) in secrets.iter().enumerate() {
save_broadcast_secret(&context, ChatId::new(10 + i as u32), secret)
.await
.unwrap();
}
context
});
group.bench_function("Decrypt and parse a symmetrically encrypted message", |b| {
b.to_async(&rt).iter(|| {
let ctx = context.clone();
async move {
let text = parse_and_get_text(
&ctx,
include_bytes!("../test-data/message/text_symmetrically_encrypted.eml"),
)
.await
.unwrap();
assert_eq!(text, "Symmetrically encrypted message");
}
});
});
group.bench_function("Decrypt and parse a public-key encrypted message", |b| {
b.to_async(&rt).iter(|| {
let ctx = context.clone();
async move {
let text = parse_and_get_text(
&ctx,
include_bytes!("../test-data/message/text_from_alice_encrypted.eml"),
)
.await
.unwrap();
assert_eq!(text, "hi");
}
});
});
group.finish();
}
fn generate_secrets() -> Vec<String> {
let secrets: Vec<String> = (0..NUM_SECRETS)
.map(|_| create_broadcast_secret())
.collect();
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

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

View File

@@ -247,7 +247,7 @@ typedef struct _dc_event_emitter dc_accounts_event_emitter_t;
// create/open/config/information
/**
* Create a new context object and try to open it without passphrase. If
* Create a new context object and try to open it. If
* database is encrypted, the result is the same as using
* dc_context_new_closed() and the database should be opened with
* dc_context_open() before using.
@@ -283,8 +283,13 @@ dc_context_t* dc_context_new_closed (const char* dbfile);
/**
* Opens the database with the given passphrase. This can only be used on
* closed context, such as created by dc_context_new_closed(). If the database
* Opens the database with the given passphrase.
* NB: Nonempty passphrase (db encryption) is deprecated 2025-11:
* - Db encryption does nothing with blobs, so fs/disk encryption is recommended.
* - Isolation from other apps is needed anyway.
*
* This can only be used on closed context, such as
* created by dc_context_new_closed(). If the database
* is new, this operation sets the database passphrase. For existing databases
* the passphrase should be the one used to encrypt the database the first
* time.
@@ -301,6 +306,8 @@ int dc_context_open (dc_context_t *context, const char*
/**
* Changes the passphrase on the open database.
* Deprecated 2025-11, see `dc_context_open()` for reasoning.
*
* Existing database must already be encrypted and the passphrase cannot be NULL or empty.
* It is impossible to encrypt unencrypted database with this method and vice versa.
*
@@ -2563,6 +2570,7 @@ void dc_stop_ongoing_process (dc_context_t* context);
#define DC_QR_ASK_VERIFYCONTACT 200 // id=contact
#define DC_QR_ASK_VERIFYGROUP 202 // text1=groupname
#define DC_QR_ASK_VERIFYBROADCAST 204 // text1=broadcast name
#define DC_QR_FPR_OK 210 // id=contact
#define DC_QR_FPR_MISMATCH 220 // id=contact
#define DC_QR_FPR_WITHOUT_ADDR 230 // test1=formatted fingerprint
@@ -2577,8 +2585,10 @@ void dc_stop_ongoing_process (dc_context_t* context);
#define DC_QR_ERROR 400 // text1=error string
#define DC_QR_WITHDRAW_VERIFYCONTACT 500
#define DC_QR_WITHDRAW_VERIFYGROUP 502 // text1=groupname
#define DC_QR_WITHDRAW_JOINBROADCAST 504 // text1=broadcast name
#define DC_QR_REVIVE_VERIFYCONTACT 510
#define DC_QR_REVIVE_VERIFYGROUP 512 // text1=groupname
#define DC_QR_REVIVE_JOINBROADCAST 514 // text1=broadcast name
#define DC_QR_LOGIN 520 // text1=email_address
/**
@@ -2595,8 +2605,9 @@ void dc_stop_ongoing_process (dc_context_t* context);
* ask whether to verify the contact;
* if so, start the protocol with dc_join_securejoin().
*
* - DC_QR_ASK_VERIFYGROUP with dc_lot_t::text1=Group name:
* ask whether to join the group;
* - DC_QR_ASK_VERIFYGROUP or DC_QR_ASK_VERIFYBROADCAST
* with dc_lot_t::text1=Group name:
* ask whether to join the chat;
* if so, start the protocol with dc_join_securejoin().
*
* - DC_QR_FPR_OK with dc_lot_t::id=Contact ID:
@@ -2679,7 +2690,8 @@ dc_lot_t* dc_check_qr (dc_context_t* context, const char*
* Get QR code text that will offer an Setup-Contact or Verified-Group invitation.
*
* The scanning device will pass the scanned content to dc_check_qr() then;
* if dc_check_qr() returns DC_QR_ASK_VERIFYCONTACT or DC_QR_ASK_VERIFYGROUP
* if dc_check_qr() returns
* DC_QR_ASK_VERIFYCONTACT, DC_QR_ASK_VERIFYGROUP or DC_QR_ASK_VERIFYBROADCAST
* an out-of-band-verification can be joined using dc_join_securejoin()
*
* The returned text will also work as a normal https:-link,
@@ -2720,7 +2732,7 @@ char* dc_get_securejoin_qr_svg (dc_context_t* context, uint32_
* Continue a Setup-Contact or Verified-Group-Invite protocol
* started on another device with dc_get_securejoin_qr().
* This function is typically called when dc_check_qr() returns
* lot.state=DC_QR_ASK_VERIFYCONTACT or lot.state=DC_QR_ASK_VERIFYGROUP.
* lot.state=DC_QR_ASK_VERIFYCONTACT, lot.state=DC_QR_ASK_VERIFYGROUP or lot.state=DC_QR_ASK_VERIFYBROADCAST
*
* The function returns immediately and the handshake runs in background,
* sending and receiving several messages.
@@ -3293,12 +3305,30 @@ void dc_accounts_maybe_network_lost (dc_accounts_t* accounts);
* without forgetting to create notifications caused by timing race conditions.
*
* @memberof dc_accounts_t
* @param accounts The account manager as created by dc_accounts_new().
* @param timeout The timeout in seconds
* @return Return 1 if DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE was emitted and 0 otherwise.
*/
int dc_accounts_background_fetch (dc_accounts_t* accounts, uint64_t timeout);
/**
* Stop ongoing background fetch.
*
* Calling this function allows to stop dc_accounts_background_fetch() early.
* dc_accounts_background_fetch() will then return immediately
* and emit DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE unless
* if it has failed and returned 0.
*
* If there is no ongoing dc_accounts_background_fetch() call,
* calling this function does nothing.
*
* @memberof dc_accounts_t
* @param accounts The account manager as created by dc_accounts_new().
*/
void dc_accounts_stop_background_fetch (dc_accounts_t *accounts);
/**
* Sets device token for Apple Push Notification service.
* Returns immediately.
@@ -7515,14 +7545,13 @@ void dc_event_unref(dc_event_t* event);
/// "You set message deletion timer to 1 minute."
///
/// Used in status messages.
/// @deprecated 2025-11-14, this string is no longer needed
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_YOU 142
/// "Message deletion timer is set to 1 minute by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
///
/// Used in status messages.
/// @deprecated 2025-11-14, this string is no longer needed
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_OTHER 143
/// "You set message deletion timer to 1 hour."
@@ -7690,7 +7719,12 @@ void dc_event_unref(dc_event_t* event);
/// Used in summaries.
#define DC_STR_REACTED_BY 177
/// "Establishing guaranteed end-to-end encryption, please wait…"
/// "Member %1$s removed."
///
/// `%1$s` will be replaced by name of the removed contact.
#define DC_STR_REMOVE_MEMBER 178
/// "Establishing connection, please wait…"
///
/// Used as info message.
#define DC_STR_SECUREJOIN_WAIT 190
@@ -7734,7 +7768,37 @@ void dc_event_unref(dc_event_t* event);
/// Subtitle for channel join qrcode svg image generated by the core.
///
/// `%1$s` will be replaced with the channel name.
#define DC_STR_SECURE_JOIN_CHANNEL_QR_DESC 201
#define DC_STR_SECURE_JOIN_CHANNEL_QR_DESC 201
/// "You joined the channel."
#define DC_STR_MSG_YOU_JOINED_CHANNEL 202
/// "%1$s invited you to join this channel. Waiting for the device of %2$s to reply…"
///
/// Added as an info-message directly after scanning a QR code for joining a broadcast channel.
///
/// `%1$s` and `%2$s` will both be replaced by the name of the inviter.
#define DC_STR_SECURE_JOIN_CHANNEL_STARTED 203
/// "The attachment contains anonymous usage statistics, which help us improve Delta Chat. Thank you!"
///
/// Used as the message body for statistics sent out.
#define DC_STR_STATS_MSG_BODY 210
/// "Proxy Enabled"
///
/// Title for proxy section in connectivity view.
#define DC_STR_PROXY_ENABLED 220
/// "You are using a proxy. If you're having trouble connecting, try a different proxy."
///
/// Description in connectivity view when proxy is enabled.
#define DC_STR_PROXY_ENABLED_DESCRIPTION 221
/// "Messages in this chat use classic email and are not encrypted."
///
/// Used as the first info messages in newly created classic email threads.
#define DC_STR_CHAT_UNENCRYPTED_EXPLANATON 230
/**
* @}

View File

@@ -4240,7 +4240,17 @@ pub unsafe extern "C" fn dc_contact_get_color(contact: *mut dc_contact_t) -> u32
return 0;
}
let ffi_contact = &*contact;
ffi_contact.contact.get_color()
let ctx = &*ffi_contact.context;
block_on(async move {
ffi_contact
.contact
// We don't want any UIs displaying gray self-color.
.get_or_gen_color(ctx)
.await
.context("Contact::get_color()")
.log_err(ctx)
.unwrap_or(0)
})
}
#[no_mangle]
@@ -5017,6 +5027,17 @@ pub unsafe extern "C" fn dc_accounts_background_fetch(
1
}
#[no_mangle]
pub unsafe extern "C" fn dc_accounts_stop_background_fetch(accounts: *mut dc_accounts_t) {
if accounts.is_null() {
eprintln!("ignoring careless call to dc_accounts_stop_background_fetch()");
return;
}
let accounts = &*accounts;
block_on(accounts.read()).stop_background_fetch();
}
#[no_mangle]
pub unsafe extern "C" fn dc_accounts_set_push_device_token(
accounts: *mut dc_accounts_t,

View File

@@ -45,6 +45,7 @@ impl Lot {
Self::Qr(qr) => match qr {
Qr::AskVerifyContact { .. } => None,
Qr::AskVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::AskJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::FprOk { .. } => None,
Qr::FprMismatch { .. } => None,
Qr::FprWithoutAddr { fingerprint, .. } => Some(Cow::Borrowed(fingerprint)),
@@ -57,8 +58,10 @@ impl Lot {
Qr::Text { text } => Some(Cow::Borrowed(text)),
Qr::WithdrawVerifyContact { .. } => None,
Qr::WithdrawVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::WithdrawJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::ReviveVerifyContact { .. } => None,
Qr::ReviveVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::ReviveJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::Login { address, .. } => Some(Cow::Borrowed(address)),
},
Self::Error(err) => Some(Cow::Borrowed(err)),
@@ -98,6 +101,7 @@ impl Lot {
Self::Qr(qr) => match qr {
Qr::AskVerifyContact { .. } => LotState::QrAskVerifyContact,
Qr::AskVerifyGroup { .. } => LotState::QrAskVerifyGroup,
Qr::AskJoinBroadcast { .. } => LotState::QrAskJoinBroadcast,
Qr::FprOk { .. } => LotState::QrFprOk,
Qr::FprMismatch { .. } => LotState::QrFprMismatch,
Qr::FprWithoutAddr { .. } => LotState::QrFprWithoutAddr,
@@ -110,8 +114,10 @@ impl Lot {
Qr::Text { .. } => LotState::QrText,
Qr::WithdrawVerifyContact { .. } => LotState::QrWithdrawVerifyContact,
Qr::WithdrawVerifyGroup { .. } => LotState::QrWithdrawVerifyGroup,
Qr::WithdrawJoinBroadcast { .. } => LotState::QrWithdrawJoinBroadcast,
Qr::ReviveVerifyContact { .. } => LotState::QrReviveVerifyContact,
Qr::ReviveVerifyGroup { .. } => LotState::QrReviveVerifyGroup,
Qr::ReviveJoinBroadcast { .. } => LotState::QrReviveJoinBroadcast,
Qr::Login { .. } => LotState::QrLogin,
},
Self::Error(_err) => LotState::QrError,
@@ -124,6 +130,7 @@ impl Lot {
Self::Qr(qr) => match qr {
Qr::AskVerifyContact { contact_id, .. } => contact_id.to_u32(),
Qr::AskVerifyGroup { .. } => Default::default(),
Qr::AskJoinBroadcast { .. } => Default::default(),
Qr::FprOk { contact_id } => contact_id.to_u32(),
Qr::FprMismatch { contact_id } => contact_id.unwrap_or_default().to_u32(),
Qr::FprWithoutAddr { .. } => Default::default(),
@@ -135,9 +142,11 @@ impl Lot {
Qr::Url { .. } => Default::default(),
Qr::Text { .. } => Default::default(),
Qr::WithdrawVerifyContact { contact_id, .. } => contact_id.to_u32(),
Qr::WithdrawVerifyGroup { .. } => Default::default(),
Qr::WithdrawVerifyGroup { .. } | Qr::WithdrawJoinBroadcast { .. } => {
Default::default()
}
Qr::ReviveVerifyContact { contact_id, .. } => contact_id.to_u32(),
Qr::ReviveVerifyGroup { .. } => Default::default(),
Qr::ReviveVerifyGroup { .. } | Qr::ReviveJoinBroadcast { .. } => Default::default(),
Qr::Login { .. } => Default::default(),
},
Self::Error(_) => Default::default(),
@@ -166,6 +175,9 @@ pub enum LotState {
/// text1=groupname
QrAskVerifyGroup = 202,
/// text1=broadcast_name
QrAskJoinBroadcast = 204,
/// id=contact
QrFprOk = 210,
@@ -201,11 +213,15 @@ pub enum LotState {
/// text1=groupname
QrWithdrawVerifyGroup = 502,
/// text1=broadcast channel name
QrWithdrawJoinBroadcast = 504,
QrReviveVerifyContact = 510,
/// text1=groupname
QrReviveVerifyGroup = 512,
/// text1=groupname
QrReviveJoinBroadcast = 514,
/// text1=email_address
QrLogin = 520,

View File

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

View File

@@ -21,9 +21,9 @@ use deltachat::context::get_info;
use deltachat::ephemeral::Timer;
use deltachat::imex;
use deltachat::location;
use deltachat::message::get_msg_read_receipts;
use deltachat::message::{
self, delete_msgs_ex, markseen_msgs, Message, MessageState, MsgId, Viewtype,
self, delete_msgs_ex, get_existing_msg_ids, get_msg_read_receipts, markseen_msgs, Message,
MessageState, MsgId, Viewtype,
};
use deltachat::peer_channels::{
leave_webxdc_realtime, send_webxdc_realtime_advertisement, send_webxdc_realtime_data,
@@ -34,6 +34,7 @@ use deltachat::qr_code_generator::{generate_backup_qr, get_securejoin_qr_svg};
use deltachat::reaction::{get_msg_reactions, send_reaction};
use deltachat::securejoin;
use deltachat::stock_str::StockMessage;
use deltachat::storage_usage::get_storage_usage;
use deltachat::webxdc::StatusUpdateSerial;
use deltachat::EventEmitter;
use sanitize_filename::is_sanitized;
@@ -53,6 +54,7 @@ use types::contact::{ContactObject, VcardContact};
use types::events::Event;
use types::http::HttpResponse;
use types::message::{MessageData, MessageObject, MessageReadReceipt};
use types::notify_state::JsonrpcNotifyState;
use types::provider_info::ProviderInfo;
use types::reactions::JsonrpcReactions;
use types::webxdc::WebxdcMessageInfo;
@@ -272,7 +274,7 @@ impl CommandApi {
/// The `AccountsBackgroundFetchDone` event is emitted at the end even in case of timeout.
/// Process all events until you get this one and you can safely return to the background
/// without forgetting to create notifications caused by timing race conditions.
async fn accounts_background_fetch(&self, timeout_in_seconds: f64) -> Result<()> {
async fn background_fetch(&self, timeout_in_seconds: f64) -> Result<()> {
let future = {
let lock = self.accounts.read().await;
lock.background_fetch(std::time::Duration::from_secs_f64(timeout_in_seconds))
@@ -282,6 +284,11 @@ impl CommandApi {
Ok(())
}
async fn stop_background_fetch(&self) -> Result<()> {
self.accounts.read().await.stop_background_fetch();
Ok(())
}
// ---------------------------------------------
// Methods that work on individual accounts
// ---------------------------------------------
@@ -312,6 +319,12 @@ impl CommandApi {
}
}
/// Get the current push notification state.
async fn get_push_state(&self, account_id: u32) -> Result<JsonrpcNotifyState> {
let ctx = self.get_context(account_id).await?;
Ok(ctx.push_state().await.into())
}
/// Get the combined filesize of an account in bytes
async fn get_account_file_size(&self, account_id: u32) -> Result<u64> {
let ctx = self.get_context(account_id).await?;
@@ -354,6 +367,13 @@ impl CommandApi {
ctx.get_info().await
}
/// Get storage usage report as formatted string
async fn get_storage_usage_report_string(&self, account_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
let storage_usage = get_storage_usage(&ctx).await?;
Ok(storage_usage.to_string())
}
/// Get the blob dir.
async fn get_blob_dir(&self, account_id: u32) -> Result<Option<String>> {
let ctx = self.get_context(account_id).await?;
@@ -1023,7 +1043,7 @@ impl CommandApi {
.await
}
/// Create a new **broadcast channel**
/// Create a new, outgoing **broadcast channel**
/// (called "Channel" in the UI).
///
/// Broadcast channels are similar to groups on the sending device,
@@ -1283,6 +1303,19 @@ impl CommandApi {
.collect())
}
/// Checks if the messages with given IDs exist.
///
/// Returns IDs of existing messages.
async fn get_existing_msg_ids(&self, account_id: u32, msg_ids: Vec<u32>) -> Result<Vec<u32>> {
let context = self.get_context(account_id).await?;
let msg_ids: Vec<MsgId> = msg_ids.into_iter().map(MsgId::new).collect();
let existing_msg_ids = get_existing_msg_ids(&context, &msg_ids).await?;
Ok(existing_msg_ids
.into_iter()
.map(|msg_id| msg_id.to_u32())
.collect())
}
async fn get_message_list_items(
&self,
account_id: u32,

View File

@@ -32,7 +32,10 @@ impl Account {
let addr = ctx.get_config(Config::Addr).await?;
let profile_image = ctx.get_config(Config::Selfavatar).await?;
let color = color_int_to_hex_string(
Contact::get_by_id(ctx, ContactId::SELF).await?.get_color(),
Contact::get_by_id(ctx, ContactId::SELF)
.await?
.get_or_gen_color(ctx)
.await?,
);
let private_tag = ctx.get_config(Config::PrivateTag).await?;
Ok(Account::Configured {

View File

@@ -6,7 +6,6 @@ use deltachat::chat::{Chat, ChatId};
use deltachat::constants::Chattype;
use deltachat::contact::{Contact, ContactId};
use deltachat::context::Context;
use num_traits::cast::ToPrimitive;
use serde::{Deserialize, Serialize};
use typescript_type_def::TypeDef;
@@ -46,7 +45,7 @@ pub struct FullChat {
archived: bool,
pinned: bool,
// subtitle - will be moved to frontend because it uses translation functions
chat_type: u32,
chat_type: JsonrpcChatType,
is_unpromoted: bool,
is_self_talk: bool,
contacts: Vec<ContactObject>,
@@ -130,7 +129,7 @@ impl FullChat {
profile_image, //BLOBS ?
archived: chat.get_visibility() == chat::ChatVisibility::Archived,
pinned: chat.get_visibility() == chat::ChatVisibility::Pinned,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
chat_type: chat.get_type().into(),
is_unpromoted: chat.is_unpromoted(),
is_self_talk: chat.is_self_talk(),
contacts,
@@ -192,7 +191,7 @@ pub struct BasicChat {
profile_image: Option<String>, //BLOBS ?
archived: bool,
pinned: bool,
chat_type: u32,
chat_type: JsonrpcChatType,
is_unpromoted: bool,
is_self_talk: bool,
color: String,
@@ -220,7 +219,7 @@ impl BasicChat {
profile_image, //BLOBS ?
archived: chat.get_visibility() == chat::ChatVisibility::Archived,
pinned: chat.get_visibility() == chat::ChatVisibility::Pinned,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
chat_type: chat.get_type().into(),
is_unpromoted: chat.is_unpromoted(),
is_self_talk: chat.is_self_talk(),
color,
@@ -274,3 +273,37 @@ impl JsonrpcChatVisibility {
}
}
}
#[derive(Clone, Serialize, Deserialize, PartialEq, TypeDef, schemars::JsonSchema)]
#[serde(rename = "ChatType")]
pub enum JsonrpcChatType {
Single,
Group,
Mailinglist,
OutBroadcast,
InBroadcast,
}
impl From<Chattype> for JsonrpcChatType {
fn from(chattype: Chattype) -> Self {
match chattype {
Chattype::Single => JsonrpcChatType::Single,
Chattype::Group => JsonrpcChatType::Group,
Chattype::Mailinglist => JsonrpcChatType::Mailinglist,
Chattype::OutBroadcast => JsonrpcChatType::OutBroadcast,
Chattype::InBroadcast => JsonrpcChatType::InBroadcast,
}
}
}
impl From<JsonrpcChatType> for Chattype {
fn from(chattype: JsonrpcChatType) -> Self {
match chattype {
JsonrpcChatType::Single => Chattype::Single,
JsonrpcChatType::Group => Chattype::Group,
JsonrpcChatType::Mailinglist => Chattype::Mailinglist,
JsonrpcChatType::OutBroadcast => Chattype::OutBroadcast,
JsonrpcChatType::InBroadcast => Chattype::InBroadcast,
}
}
}

View File

@@ -11,6 +11,7 @@ use num_traits::cast::ToPrimitive;
use serde::Serialize;
use typescript_type_def::TypeDef;
use super::chat::JsonrpcChatType;
use super::color_int_to_hex_string;
use super::message::MessageViewtype;
@@ -23,7 +24,7 @@ pub enum ChatListItemFetchResult {
name: String,
avatar_path: Option<String>,
color: String,
chat_type: u32,
chat_type: JsonrpcChatType,
last_updated: Option<i64>,
summary_text1: String,
summary_text2: String,
@@ -151,7 +152,7 @@ pub(crate) async fn get_chat_list_item_by_id(
name: chat.get_name().to_owned(),
avatar_path,
color,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
chat_type: chat.get_type().into(),
last_updated,
summary_text1,
summary_text2,

View File

@@ -1,8 +1,9 @@
use deltachat::{Event as CoreEvent, EventType as CoreEventType};
use num_traits::ToPrimitive;
use serde::Serialize;
use typescript_type_def::TypeDef;
use super::chat::JsonrpcChatType;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct Event {
@@ -307,7 +308,7 @@ pub enum EventType {
/// The type of the joined chat.
/// This can take the same values
/// as `BasicChat.chatType` ([`crate::api::types::chat::BasicChat::chat_type`]).
chat_type: u32,
chat_type: JsonrpcChatType,
/// ID of the chat in case of success.
chat_id: u32,
@@ -570,7 +571,7 @@ impl From<CoreEventType> for EventType {
progress,
} => SecurejoinInviterProgress {
contact_id: contact_id.to_u32(),
chat_type: chat_type.to_u32().unwrap_or(0),
chat_type: chat_type.into(),
chat_id: chat_id.to_u32(),
progress,
},

View File

@@ -16,6 +16,7 @@ use num_traits::cast::ToPrimitive;
use serde::{Deserialize, Serialize};
use typescript_type_def::TypeDef;
use super::chat::JsonrpcChatType;
use super::color_int_to_hex_string;
use super::contact::ContactObject;
use super::reactions::JsonrpcReactions;
@@ -531,7 +532,7 @@ pub struct MessageSearchResult {
chat_profile_image: Option<String>,
chat_color: String,
chat_name: String,
chat_type: u32,
chat_type: JsonrpcChatType,
is_chat_contact_request: bool,
is_chat_archived: bool,
message: String,
@@ -569,7 +570,7 @@ impl MessageSearchResult {
chat_id: chat.id.to_u32(),
chat_name: chat.get_name().to_owned(),
chat_color,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
chat_type: chat.get_type().into(),
chat_profile_image,
is_chat_contact_request: chat.is_contact_request(),
is_chat_archived: chat.get_visibility() == ChatVisibility::Archived,

View File

@@ -8,6 +8,7 @@ pub mod http;
pub mod location;
pub mod login_param;
pub mod message;
pub mod notify_state;
pub mod provider_info;
pub mod qr;
pub mod reactions;

View File

@@ -0,0 +1,26 @@
use deltachat::push::NotifyState;
use serde::Serialize;
use typescript_type_def::TypeDef;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "NotifyState")]
pub enum JsonrpcNotifyState {
/// Not subscribed to push notifications.
NotConnected,
/// Subscribed to heartbeat push notifications.
Heartbeat,
/// Subscribed to push notifications for new messages.
Connected,
}
impl From<NotifyState> for JsonrpcNotifyState {
fn from(state: NotifyState) -> Self {
match state {
NotifyState::NotConnected => Self::NotConnected,
NotifyState::Heartbeat => Self::Heartbeat,
NotifyState::Connected => Self::Connected,
}
}
}

View File

@@ -35,6 +35,26 @@ pub enum QrObject {
/// Authentication code.
authcode: String,
},
/// Ask the user whether to join the broadcast channel.
AskJoinBroadcast {
/// The user-visible name of this broadcast channel
name: String,
/// A string of random characters,
/// uniquely identifying this broadcast channel across all databases/clients.
/// Called `grpid` for historic reasons:
/// The id of multi-user chats is always called `grpid` in the database
/// because groups were once the only multi-user chats.
grpid: String,
/// ID of the contact who owns the broadcast channel and created the QR code.
contact_id: u32,
/// Fingerprint of the broadcast channel owner's key as scanned from the QR code.
fingerprint: String,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// Contact fingerprint is verified.
///
/// Ask the user if they want to start chatting.
@@ -137,6 +157,21 @@ pub enum QrObject {
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to withdraw their own broadcast channel invite QR code.
WithdrawJoinBroadcast {
/// Broadcast name.
name: String,
/// ID, uniquely identifying this chat. Called grpid for historic reasons.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: u32,
/// Fingerprint of the contact key as scanned from the QR code.
fingerprint: String,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to revive their own QR code.
ReviveVerifyContact {
/// Contact ID.
@@ -163,6 +198,21 @@ pub enum QrObject {
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to revive their own broadcast channel invite QR code.
ReviveJoinBroadcast {
/// Broadcast name.
name: String,
/// Globally unique chat ID. Called grpid for historic reasons.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: u32,
/// Fingerprint of the contact key as scanned from the QR code.
fingerprint: String,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// `dclogin:` scheme parameters.
///
/// Ask the user if they want to login with the email address.
@@ -208,6 +258,25 @@ impl From<Qr> for QrObject {
authcode,
}
}
Qr::AskJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
authcode,
invitenumber,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
QrObject::AskJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
authcode,
invitenumber,
}
}
Qr::FprOk { contact_id } => {
let contact_id = contact_id.to_u32();
QrObject::FprOk { contact_id }
@@ -267,6 +336,25 @@ impl From<Qr> for QrObject {
authcode,
}
}
Qr::WithdrawJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
QrObject::WithdrawJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
}
}
Qr::ReviveVerifyContact {
contact_id,
fingerprint,
@@ -301,6 +389,25 @@ impl From<Qr> for QrObject {
authcode,
}
}
Qr::ReviveJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
QrObject::ReviveJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
}
}
Qr::Login { address, .. } => QrObject::Login { address },
}
}

View File

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

View File

@@ -40,15 +40,35 @@ const constants = data
key.startsWith("DC_DOWNLOAD") ||
key.startsWith("DC_INFO_") ||
(key.startsWith("DC_MSG") && !key.startsWith("DC_MSG_ID")) ||
key.startsWith("DC_QR_")
key.startsWith("DC_QR_") ||
key.startsWith("DC_CERTCK_") ||
key.startsWith("DC_SOCKET_") ||
key.startsWith("DC_LP_AUTH_") ||
key.startsWith("DC_PUSH_") ||
key.startsWith("DC_TEXT1_") ||
key.startsWith("DC_CHAT_TYPE")
);
})
.map((row) => {
return ` ${row.key}: ${row.value}`;
return ` export const ${row.key} = ${row.value};`;
})
.join(",\n");
.join("\n");
writeFileSync(
resolve(__dirname, "../generated/constants.ts"),
`// Generated!\n\nexport enum C {\n${constants.replace(/:/g, " =")},\n}\n`,
`// Generated!
export namespace C {
${constants}
/** @deprecated 10-8-2025 compare string directly with \`== "Group"\` */
export const DC_CHAT_TYPE_GROUP = "Group";
/** @deprecated 10-8-2025 compare string directly with \`== "InBroadcast"\`*/
export const DC_CHAT_TYPE_IN_BROADCAST = "InBroadcast";
/** @deprecated 10-8-2025 compare string directly with \`== "Mailinglist"\` */
export const DC_CHAT_TYPE_MAILINGLIST = "Mailinglist";
/** @deprecated 10-8-2025 compare string directly with \`== "OutBroadcast"\` */
export const DC_CHAT_TYPE_OUT_BROADCAST = "OutBroadcast";
/** @deprecated 10-8-2025 compare string directly with \`== "Single"\` */
export const DC_CHAT_TYPE_SINGLE = "Single";
}\n`,
);

View File

@@ -64,6 +64,7 @@ describe("online tests", function () {
await dc.rpc.setConfig(accountId1, "addr", account1.email);
await dc.rpc.setConfig(accountId1, "mail_pw", account1.password);
await dc.rpc.configure(accountId1);
await waitForEvent(dc, "ImapInboxIdle", accountId1);
accountId2 = await dc.rpc.addAccount();
await dc.rpc.batchSetConfig(accountId2, {
@@ -71,6 +72,7 @@ describe("online tests", function () {
mail_pw: account2.password,
});
await dc.rpc.configure(accountId2);
await waitForEvent(dc, "ImapInboxIdle", accountId2);
accountsConfigured = true;
});

View File

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

View File

@@ -70,11 +70,6 @@ async fn reset_tables(context: &Context, bits: i32) {
.await
.unwrap();
context.sql().config_cache().write().await.clear();
context
.sql()
.execute("DELETE FROM leftgrps;", ())
.await
.unwrap();
println!("(8) Rest but server config reset.");
}

View File

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

View File

@@ -125,6 +125,15 @@ class Account:
"""Add a new transport."""
yield self._rpc.add_or_update_transport.future(self.id, params)
@futuremethod
def add_transport_from_qr(self, qr: str):
"""Add a new transport using a QR code."""
yield self._rpc.add_transport_from_qr.future(self.id, qr)
def delete_transport(self, addr: str):
"""Delete a transport."""
self._rpc.delete_transport(self.id, addr)
@futuremethod
def list_transports(self):
"""Return the list of all email accounts that are used as a transport in the current profile."""
@@ -321,7 +330,7 @@ class Account:
return Chat(self, self._rpc.create_group_chat(self.id, name, False))
def create_broadcast(self, name: str) -> Chat:
"""Create a new **broadcast channel**
"""Create a new, outgoing **broadcast channel**
(called "Channel" in the UI).
Broadcast channels are similar to groups on the sending device,
@@ -394,9 +403,10 @@ class Account:
next_msg_ids = self._rpc.get_next_msgs(self.id)
return [Message(self, msg_id) for msg_id in next_msg_ids]
@futuremethod
def wait_next_messages(self) -> list[Message]:
"""Wait for new messages and return a list of them."""
next_msg_ids = self._rpc.wait_next_msgs(self.id)
next_msg_ids = yield self._rpc.wait_next_msgs.future(self.id)
return [Message(self, msg_id) for msg_id in next_msg_ids]
def wait_for_incoming_msg_event(self):
@@ -411,12 +421,21 @@ class Account:
"""Wait for messages noticed event and return it."""
return self.wait_for_event(EventType.MSGS_NOTICED)
def wait_for_msg(self, event_type) -> Message:
"""Wait for an event about the message.
Consumes all events before the matching event.
Returns a message corresponding to the msg_id field of the event.
"""
event = self.wait_for_event(event_type)
return self.get_message_by_id(event.msg_id)
def wait_for_incoming_msg(self):
"""Wait for incoming message and return it.
Consumes all events before the next incoming message event.
"""
return self.get_message_by_id(self.wait_for_incoming_msg_event().msg_id)
return self.wait_for_msg(EventType.INCOMING_MSG)
def wait_for_securejoin_inviter_success(self):
"""Wait until SecureJoin process finishes successfully on the inviter side."""

View File

@@ -91,19 +91,17 @@ class ChatId(IntEnum):
LAST_SPECIAL = 9
class ChatType(IntEnum):
class ChatType(str, Enum):
"""Chat type."""
UNDEFINED = 0
SINGLE = 100
SINGLE = "Single"
"""1:1 chat, i.e. a direct chat with a single contact"""
GROUP = 120
GROUP = "Group"
MAILINGLIST = 140
MAILINGLIST = "Mailinglist"
OUT_BROADCAST = 160
OUT_BROADCAST = "OutBroadcast"
"""Outgoing broadcast channel, called "Channel" in the UI.
The user can send into this channel,
@@ -115,7 +113,7 @@ class ChatType(IntEnum):
which would make it hard to grep for it.
"""
IN_BROADCAST = 165
IN_BROADCAST = "InBroadcast"
"""Incoming broadcast channel, called "Channel" in the UI.
This channel is read-only,

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ._utils import AttrDict
from ._utils import AttrDict, futuremethod
from .account import Account
if TYPE_CHECKING:
@@ -39,6 +39,15 @@ class DeltaChat:
"""Stop the I/O of all accounts."""
self.rpc.stop_io_for_all_accounts()
@futuremethod
def background_fetch(self, timeout_in_seconds: int) -> None:
"""Run background fetch for all accounts."""
yield self.rpc.background_fetch.future(timeout_in_seconds)
def stop_background_fetch(self) -> None:
"""Stop ongoing background fetch."""
self.rpc.stop_background_fetch()
def maybe_network(self) -> None:
"""Indicate that the network conditions might have changed."""
self.rpc.maybe_network()

View File

@@ -93,6 +93,17 @@ class Message:
if event.kind == EventType.MSG_DELIVERED and event.msg_id == self.id:
break
def resend(self) -> None:
"""Resend messages and make information available for newly added chat members.
Resending sends out the original message, however, recipients and webxdc-status may differ.
Clients that already have the original message can still ignore the resent message as
they have tracked the state by dedicated updates.
Some messages cannot be resent, eg. info-messages, drafts, already pending messages,
or messages that are not sent by SELF.
"""
self._rpc.resend_messages(self.account.id, [self.id])
@futuremethod
def send_webxdc_realtime_advertisement(self):
"""Send an advertisement to join the realtime channel."""

View File

@@ -40,13 +40,17 @@ class ACFactory:
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")
return f"dcaccount:{domain}"
@futuremethod
def new_configured_account(self):
"""Create a new configured account."""
addr, password = self.get_credentials()
account = self.get_unconfigured_account()
params = {"addr": addr, "password": password}
yield account.add_or_update_transport.future(params)
qr = self.get_account_qr()
yield account.add_transport_from_qr.future(qr)
assert account.is_configured()
return account
@@ -78,6 +82,7 @@ class ACFactory:
ac_clone = self.get_unconfigured_account()
for transport in transports:
ac_clone.add_or_update_transport(transport)
ac_clone.bring_online()
return ac_clone
def get_accepted_chat(self, ac1: Account, ac2: Account) -> Chat:
@@ -136,9 +141,15 @@ def rpc(tmp_path) -> AsyncGenerator:
@pytest.fixture
def acfactory(rpc) -> AsyncGenerator:
def dc(rpc) -> DeltaChat:
"""Return account manager."""
return DeltaChat(rpc)
@pytest.fixture
def acfactory(dc) -> AsyncGenerator:
"""Return account factory fixture."""
return ACFactory(DeltaChat(rpc))
return ACFactory(dc)
@pytest.fixture

View File

@@ -85,11 +85,11 @@ class DirectImap:
def get_all_messages(self) -> list[MailMessage]:
assert not self._idling
return list(self.conn.fetch())
return list(self.conn.fetch(mark_seen=False))
def get_unread_messages(self) -> list[str]:
assert not self._idling
return [msg.uid for msg in self.conn.fetch(AND(seen=False))]
return [msg.uid for msg in self.conn.fetch(AND(seen=False), mark_seen=False)]
def mark_all_read(self):
messages = self.get_unread_messages()
@@ -173,7 +173,6 @@ class DirectImap:
class IdleManager:
def __init__(self, direct_imap) -> None:
self.direct_imap = direct_imap
self.log = direct_imap.account.log
# fetch latest messages before starting idle so that it only
# returns messages that arrive anew
self.direct_imap.conn.fetch("1:*")
@@ -181,14 +180,11 @@ class IdleManager:
def check(self, timeout=None) -> list[bytes]:
"""(blocking) wait for next idle message from server."""
self.log("imap-direct: calling idle_check")
res = self.direct_imap.conn.idle.poll(timeout=timeout)
self.log(f"imap-direct: idle_check returned {res!r}")
return res
return self.direct_imap.conn.idle.poll(timeout=timeout)
def wait_for_new_message(self, timeout=None) -> bytes:
def wait_for_new_message(self) -> bytes:
while True:
for item in self.check(timeout=timeout):
for item in self.check():
if b"EXISTS" in item or b"RECENT" in item:
return item
@@ -196,10 +192,8 @@ class IdleManager:
"""Return first message with SEEN flag from a running idle-stream."""
while True:
for item in self.check(timeout=timeout):
if FETCH in item:
self.log(str(item))
if FLAGS in item and rb"\Seen" in item:
return int(item.split(b" ")[1])
if FETCH in item and FLAGS in item and rb"\Seen" in item:
return int(item.split(b" ")[1])
def done(self):
"""send idle-done to server if we are currently in idle mode."""

View File

@@ -103,7 +103,7 @@ def test_no_contact_request_call(acfactory) -> None:
# There should be no incoming call notification.
assert event.kind != EventType.INCOMING_CALL
if event.kind == EventType.INCOMING_MSG:
if event.kind == EventType.MSGS_CHANGED:
msg = bob.get_message_by_id(event.msg_id)
assert msg.get_snapshot().text == "Hello!"
break
if msg.get_snapshot().text == "Hello!":
break

View File

@@ -169,6 +169,8 @@ def test_imap_sync_seen_msgs(acfactory: ACFactory) -> None:
"""
alice, alice_second_device, bob, alice_chat_bob = get_multi_account_test_setup(acfactory)
bob.create_chat(alice)
alice_chat_bob.send_text("hello")
msg = bob.wait_for_incoming_msg()

View File

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

View File

@@ -84,7 +84,7 @@ def test_realtime_sequentially(acfactory, path_to_webxdc):
# share a webxdc app between ac1 and ac2
ac1_webxdc_msg = acfactory.send_message(from_account=ac1, to_account=ac2, text="play", file=path_to_webxdc)
ac2_webxdc_msg = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id)
ac2_webxdc_msg = ac2.wait_for_incoming_msg()
snapshot = ac2_webxdc_msg.get_snapshot()
assert snapshot.text == "play"
@@ -94,7 +94,7 @@ def test_realtime_sequentially(acfactory, path_to_webxdc):
acfactory.send_message(from_account=ac1, to_account=ac2, text="ping1")
log("waiting for incoming message on ac2")
snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac2.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "ping1"
log("sending ac2 -> ac1 realtime advertisement and additional message")
@@ -102,7 +102,7 @@ def test_realtime_sequentially(acfactory, path_to_webxdc):
acfactory.send_message(from_account=ac2, to_account=ac1, text="ping2")
log("waiting for incoming message on ac1")
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "ping2"
log("sending realtime data ac1 -> ac2")
@@ -214,7 +214,9 @@ def test_advertisement_after_chatting(acfactory, path_to_webxdc):
ac1_ac2_chat = ac1.create_chat(ac2)
ac1_webxdc_msg = ac1_ac2_chat.send_message(text="WebXDC", file=path_to_webxdc)
ac2_webxdc_msg = ac2.wait_for_incoming_msg()
assert ac2_webxdc_msg.get_snapshot().text == "WebXDC"
ac2_webxdc_msg_snapshot = ac2_webxdc_msg.get_snapshot()
assert ac2_webxdc_msg_snapshot.text == "WebXDC"
ac2_webxdc_msg_snapshot.chat.accept()
ac1_ac2_chat.send_text("Hello!")
ac2_hello_msg = ac2.wait_for_incoming_msg()

View File

@@ -4,6 +4,41 @@ from deltachat_rpc_client import EventType
from deltachat_rpc_client.const import MessageState
def test_bcc_self_delete_server_after_defaults(acfactory):
"""Test default values for bcc_self and delete_server_after."""
ac = acfactory.get_online_account()
# Initially after getting online
# the setting bcc_self is set to 0 because there is only one device
# and delete_server_after is "1", meaning immediate deletion.
assert ac.get_config("bcc_self") == "0"
assert ac.get_config("delete_server_after") == "1"
# Setup a second device.
ac_clone = ac.clone()
ac_clone.bring_online()
# Second device setup
# enables bcc_self and changes default delete_server_after.
assert ac.get_config("bcc_self") == "1"
assert ac.get_config("delete_server_after") == "0"
assert ac_clone.get_config("bcc_self") == "1"
assert ac_clone.get_config("delete_server_after") == "0"
# Manually disabling bcc_self
# also restores the default for delete_server_after.
ac.set_config("bcc_self", "0")
assert ac.get_config("bcc_self") == "0"
assert ac.get_config("delete_server_after") == "1"
# Cloning the account again enables bcc_self
# even though it was manually disabled.
ac_clone = ac.clone()
assert ac.get_config("bcc_self") == "1"
assert ac.get_config("delete_server_after") == "0"
def test_one_account_send_bcc_setting(acfactory, log, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone()

View File

@@ -0,0 +1,158 @@
import pytest
from deltachat_rpc_client.rpc import JsonRpcError
def test_add_second_address(acfactory) -> None:
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
# When the first transport is created,
# mvbox_move and only_fetch_mvbox should be disabled.
assert account.get_config("mvbox_move") == "0"
assert account.get_config("only_fetch_mvbox") == "0"
assert account.get_config("show_emails") == "2"
qr = acfactory.get_account_qr()
account.add_transport_from_qr(qr)
assert len(account.list_transports()) == 2
account.add_transport_from_qr(qr)
assert len(account.list_transports()) == 3
first_addr = account.list_transports()[0]["addr"]
second_addr = account.list_transports()[1]["addr"]
# Cannot delete the first address.
with pytest.raises(JsonRpcError):
account.delete_transport(first_addr)
account.delete_transport(second_addr)
assert len(account.list_transports()) == 2
# Enabling mvbox_move or only_fetch_mvbox
# is not allowed when multi-transport is enabled.
for option in ["mvbox_move", "only_fetch_mvbox"]:
with pytest.raises(JsonRpcError):
account.set_config(option, "1")
with pytest.raises(JsonRpcError):
account.set_config("show_emails", "0")
@pytest.mark.parametrize("key", ["mvbox_move", "only_fetch_mvbox"])
def test_no_second_transport_with_mvbox(acfactory, key) -> None:
"""Test that second transport cannot be configured if mvbox is used."""
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
assert account.get_config("mvbox_move") == "0"
assert account.get_config("only_fetch_mvbox") == "0"
qr = acfactory.get_account_qr()
account.set_config(key, "1")
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
def test_no_second_transport_without_classic_emails(acfactory) -> None:
"""Test that second transport cannot be configured if classic emails are not fetched."""
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
assert account.get_config("show_emails") == "2"
qr = acfactory.get_account_qr()
account.set_config("show_emails", "0")
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
def test_change_address(acfactory) -> None:
"""Test Alice configuring a second transport and setting it as a primary one."""
alice, bob = acfactory.get_online_accounts(2)
bob_addr = bob.get_config("configured_addr")
bob.create_chat(alice)
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_text("Hello!")
msg1 = bob.wait_for_incoming_msg().get_snapshot()
sender_addr1 = msg1.sender.get_snapshot().address
alice.stop_io()
old_alice_addr = alice.get_config("configured_addr")
alice_vcard = alice.self_contact.make_vcard()
assert old_alice_addr in alice_vcard
qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr)
new_alice_addr = alice.list_transports()[1]["addr"]
with pytest.raises(JsonRpcError):
# Cannot use the address that is not
# configured for any transport.
alice.set_config("configured_addr", bob_addr)
# Load old address so it is cached.
assert alice.get_config("configured_addr") == old_alice_addr
alice.set_config("configured_addr", new_alice_addr)
# Make sure that setting `configured_addr` invalidated the cache.
assert alice.get_config("configured_addr") == new_alice_addr
alice_vcard = alice.self_contact.make_vcard()
assert old_alice_addr not in alice_vcard
assert new_alice_addr in alice_vcard
with pytest.raises(JsonRpcError):
alice.delete_transport(new_alice_addr)
alice.start_io()
alice_chat_bob.send_text("Hello again!")
msg2 = bob.wait_for_incoming_msg().get_snapshot()
sender_addr2 = msg2.sender.get_snapshot().address
assert msg1.sender == msg2.sender
assert sender_addr1 != sender_addr2
assert sender_addr1 == old_alice_addr
assert sender_addr2 == new_alice_addr
@pytest.mark.parametrize("is_chatmail", ["0", "1"])
def test_mvbox_move_first_transport(acfactory, is_chatmail) -> None:
"""Test that mvbox_move is disabled by default even for non-chatmail accounts.
Disabling mvbox_move is required to be able to setup a second transport.
"""
account = acfactory.get_unconfigured_account()
account.set_config("fix_is_chatmail", "1")
account.set_config("is_chatmail", is_chatmail)
# The default value when the setting is unset is "1".
# This is not changed for compatibility with old databases
# imported from backups.
assert account.get_config("mvbox_move") == "1"
qr = acfactory.get_account_qr()
account.add_transport_from_qr(qr)
# Once the first transport is set up,
# mvbox_move is disabled.
assert account.get_config("mvbox_move") == "0"
assert account.get_config("is_chatmail") == is_chatmail
def test_reconfigure_transport(acfactory) -> None:
"""Test that reconfiguring the transport works
even if settings not supported for multi-transport
like mvbox_move are enabled."""
account = acfactory.get_online_account()
account.set_config("mvbox_move", "1")
[transport] = account.list_transports()
account.add_or_update_transport(transport)
# Reconfiguring the transport should not reset
# the settings as if when configuring the first transport.
assert account.get_config("mvbox_move") == "1"

View File

@@ -3,6 +3,7 @@ import logging
import pytest
from deltachat_rpc_client import Chat, EventType, SpecialContactId
from deltachat_rpc_client.const import ChatType
from deltachat_rpc_client.rpc import JsonRpcError
@@ -85,7 +86,7 @@ def test_qr_securejoin(acfactory):
alice_contact_bob_snapshot = alice_contact_bob.get_snapshot()
assert alice_contact_bob_snapshot.is_verified
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr"))
# Test that Bob verified Alice's profile.
@@ -109,6 +110,143 @@ def test_qr_securejoin(acfactory):
fiona.wait_for_securejoin_joiner_success()
@pytest.mark.parametrize("all_devices_online", [True, False])
def test_qr_securejoin_broadcast(acfactory, all_devices_online):
alice, bob, fiona = acfactory.get_online_accounts(3)
alice2 = alice.clone()
bob2 = bob.clone()
if all_devices_online:
alice2.start_io()
bob2.start_io()
logging.info("===================== Alice creates a broadcast =====================")
alice_chat = alice.create_broadcast("Broadcast channel!")
snapshot = alice_chat.get_basic_snapshot()
assert not snapshot.is_unpromoted # Broadcast channels are never unpromoted
logging.info("===================== Bob joins the broadcast =====================")
qr_code = alice_chat.get_qr_code()
bob.secure_join(qr_code)
alice.wait_for_securejoin_inviter_success()
bob.wait_for_securejoin_joiner_success()
alice_chat.send_text("Hello everyone!")
def get_broadcast(ac):
chat = ac.get_chatlist(query="Broadcast channel!")[0]
assert chat.get_basic_snapshot().name == "Broadcast channel!"
return chat
def wait_for_broadcast_messages(ac):
snapshot1 = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot1.text == "You joined the channel."
snapshot2 = ac.wait_for_incoming_msg().get_snapshot()
assert snapshot2.text == "Hello everyone!"
chat = get_broadcast(ac)
assert snapshot1.chat_id == chat.id
assert snapshot2.chat_id == chat.id
def check_account(ac, contact, inviter_side, please_wait_info_msg=False):
# Check that the chat partner is verified.
contact_snapshot = contact.get_snapshot()
assert contact_snapshot.is_verified
chat = get_broadcast(ac)
chat_msgs = chat.get_messages()
encrypted_msg = chat_msgs.pop(0).get_snapshot()
assert encrypted_msg.text == "Messages are end-to-end encrypted."
assert encrypted_msg.is_info
if please_wait_info_msg:
first_msg = chat_msgs.pop(0).get_snapshot()
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:
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
else:
assert member_added_msg.text == "You joined the channel."
assert member_added_msg.is_info
hello_msg = chat_msgs.pop(0).get_snapshot()
assert hello_msg.text == "Hello everyone!"
assert not hello_msg.is_info
assert hello_msg.show_padlock
assert hello_msg.error is None
assert len(chat_msgs) == 0
chat_snapshot = chat.get_full_snapshot()
assert chat_snapshot.is_encrypted
assert chat_snapshot.name == "Broadcast channel!"
if inviter_side:
assert chat_snapshot.chat_type == ChatType.OUT_BROADCAST
else:
assert chat_snapshot.chat_type == ChatType.IN_BROADCAST
assert chat_snapshot.can_send == inviter_side
chat_contacts = chat_snapshot.contact_ids
assert contact.id in chat_contacts
if inviter_side:
assert len(chat_contacts) == 1
else:
assert len(chat_contacts) == 2
assert SpecialContactId.SELF in chat_contacts
assert chat_snapshot.self_in_group
wait_for_broadcast_messages(bob)
check_account(alice, alice.create_contact(bob), inviter_side=True)
check_account(bob, bob.create_contact(alice), inviter_side=False, please_wait_info_msg=True)
logging.info("===================== Test Alice's second device =====================")
# Start second Alice device, if it wasn't started already.
alice2.start_io()
while True:
msg_id = alice2.wait_for_msgs_changed_event().msg_id
if msg_id:
snapshot = alice2.get_message_by_id(msg_id).get_snapshot()
if snapshot.text == "Hello everyone!":
break
check_account(alice2, alice2.create_contact(bob), inviter_side=True)
logging.info("===================== Test Bob's second device =====================")
# Start second Bob device, if it wasn't started already.
bob2.start_io()
bob2.wait_for_securejoin_joiner_success()
wait_for_broadcast_messages(bob2)
check_account(bob2, bob2.create_contact(alice), inviter_side=False)
# The QR code token is synced, so alice2 must be able to handle join requests.
logging.info("===================== Fiona joins the group via alice2 =====================")
alice.stop_io()
fiona.secure_join(qr_code)
alice2.wait_for_securejoin_inviter_success()
fiona.wait_for_securejoin_joiner_success()
snapshot = fiona.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You joined the channel."
get_broadcast(alice2).get_messages()[2].resend()
snapshot = fiona.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello everyone!"
check_account(fiona, fiona.create_contact(alice), inviter_side=False, please_wait_info_msg=True)
# For Bob, the channel must not have changed:
check_account(bob, bob.create_contact(alice), inviter_side=False, please_wait_info_msg=True)
def test_qr_securejoin_contact_request(acfactory) -> None:
"""Alice invites Bob to a group when Bob's chat with Alice is in a contact request mode."""
alice, bob = acfactory.get_online_accounts(2)
@@ -117,7 +255,7 @@ def test_qr_securejoin_contact_request(acfactory) -> None:
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_text("Hello!")
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello!"
bob_chat_alice = snapshot.chat
assert bob_chat_alice.get_basic_snapshot().is_contact_request
@@ -161,8 +299,7 @@ def test_qr_readreceipt(acfactory) -> None:
logging.info("Bob and Charlie receive a group")
bob_msg_id = bob.wait_for_incoming_msg_event().msg_id
bob_message = bob.get_message_by_id(bob_msg_id)
bob_message = bob.wait_for_incoming_msg()
bob_snapshot = bob_message.get_snapshot()
assert bob_snapshot.text == "Hello"
@@ -173,8 +310,7 @@ def test_qr_readreceipt(acfactory) -> None:
bob_out_message = bob_snapshot.chat.send_message(text="Hi from Bob!")
charlie_msg_id = charlie.wait_for_incoming_msg_event().msg_id
charlie_message = charlie.get_message_by_id(charlie_msg_id)
charlie_message = charlie.wait_for_incoming_msg()
charlie_snapshot = charlie_message.get_snapshot()
assert charlie_snapshot.text == "Hi from Bob!"
@@ -249,7 +385,7 @@ def test_verified_group_member_added_recovery(acfactory) -> None:
ac3_contact_ac2 = ac3.create_contact(ac2)
ac3_chat.remove_contact(ac3_contact_ac2_old)
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert "removed" in snapshot.text
ac3_chat.add_contact(ac3_contact_ac2)
@@ -262,25 +398,26 @@ def test_verified_group_member_added_recovery(acfactory) -> None:
logging.info("ac2 got event message: %s", snapshot.text)
assert "added" in snapshot.text
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert "added" in snapshot.text
chat = Chat(ac2, chat_id)
chat.send_text("Works again!")
msg_id = ac3.wait_for_incoming_msg_event().msg_id
message = ac3.get_message_by_id(msg_id)
message = ac3.wait_for_incoming_msg()
snapshot = message.get_snapshot()
assert snapshot.text == "Works again!"
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Works again!"
ac1_contact_ac2 = ac1.create_contact(ac2)
ac1_contact_ac3 = ac1.create_contact(ac3)
ac1_contact_ac2_snapshot = ac1_contact_ac2.get_snapshot()
assert ac1_contact_ac2_snapshot.is_verified
assert ac1_contact_ac2_snapshot.verifier_id == ac1_contact_ac3.id
# Until we reset verifications and then send the _verified header,
# verification is not gossiped here:
assert not ac1_contact_ac2_snapshot.is_verified
assert ac1_contact_ac2_snapshot.verifier_id != ac1_contact_ac3.id
def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory):
@@ -307,7 +444,7 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory):
# ensure ac1 can write and ac2 receives messages in verified chat
ch1.send_text("ac1 says hello")
while 1:
snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac2.wait_for_incoming_msg().get_snapshot()
if snapshot.text == "ac1 says hello":
break
@@ -328,7 +465,7 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory):
# ensure ac2 receives message in VG
vg.send_text("hello")
while 1:
msg = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
msg = ac2.wait_for_incoming_msg().get_snapshot()
if msg.text == "hello":
break
@@ -365,7 +502,7 @@ def test_qr_new_group_unblocked(acfactory):
ac2.wait_for_incoming_msg_event()
ac1_new_chat.send_text("Hello!")
ac2_msg = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
ac2_msg = ac2.wait_for_incoming_msg().get_snapshot()
assert ac2_msg.text == "Hello!"
assert ac2_msg.chat.get_basic_snapshot().is_contact_request
@@ -390,7 +527,7 @@ def test_aeap_flow_verified(acfactory):
logging.info("receiving first message")
ac2.wait_for_incoming_msg_event() # member added message
msg_in_1 = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
msg_in_1 = ac2.wait_for_incoming_msg().get_snapshot()
assert msg_in_1.text == msg_out.text
logging.info("changing email account")
@@ -404,7 +541,7 @@ def test_aeap_flow_verified(acfactory):
msg_out = chat.send_text("changed address").get_snapshot()
logging.info("receiving second message")
msg_in_2 = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id)
msg_in_2 = ac2.wait_for_incoming_msg()
msg_in_2_snapshot = msg_in_2.get_snapshot()
assert msg_in_2_snapshot.text == msg_out.text
assert msg_in_2_snapshot.chat.id == msg_in_1.chat.id
@@ -436,13 +573,15 @@ def test_gossip_verification(acfactory) -> None:
bob_group_chat.add_contact(bob_contact_carol)
bob_group_chat.send_message(text="Hello Autocrypt group")
snapshot = carol.get_message_by_id(carol.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = carol.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Autocrypt group"
assert snapshot.show_padlock
# Group propagates verification using Autocrypt-Gossip header.
carol_contact_alice_snapshot = carol_contact_alice.get_snapshot()
assert carol_contact_alice_snapshot.is_verified
# Until we reset verifications and then send the _verified header,
# verification is not gossiped here:
assert not carol_contact_alice_snapshot.is_verified
logging.info("Bob creates a Securejoin group")
bob_group_chat = bob.create_group("Securejoin Group")
@@ -450,13 +589,15 @@ def test_gossip_verification(acfactory) -> None:
bob_group_chat.add_contact(bob_contact_carol)
bob_group_chat.send_message(text="Hello Securejoin group")
snapshot = carol.get_message_by_id(carol.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = carol.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Securejoin group"
assert snapshot.show_padlock
# Securejoin propagates verification.
carol_contact_alice_snapshot = carol_contact_alice.get_snapshot()
assert carol_contact_alice_snapshot.is_verified
# Until we reset verifications and then send the _verified header,
# verification is not gossiped here:
assert not carol_contact_alice_snapshot.is_verified
def test_securejoin_after_contact_resetup(acfactory) -> None:
@@ -476,7 +617,7 @@ def test_securejoin_after_contact_resetup(acfactory) -> None:
ac1.wait_for_securejoin_joiner_success()
# ac1 waits for member added message and creates a QR code.
snapshot = ac1.get_message_by_id(ac1.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Member Me added by {}.".format(ac3.get_config("addr"))
ac1_qr_code = snapshot.chat.get_qr_code()
@@ -513,7 +654,7 @@ def test_securejoin_after_contact_resetup(acfactory) -> None:
# Wait for member added.
logging.info("ac2 waits for member added message")
snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = ac2.wait_for_incoming_msg().get_snapshot()
assert snapshot.is_info
ac2_chat = snapshot.chat
assert len(ac2_chat.get_contacts()) == 3
@@ -535,7 +676,7 @@ def test_withdraw_securejoin_qr(acfactory):
alice.clear_all_events()
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr"))
bob_chat.leave()

View File

@@ -11,7 +11,7 @@ from unittest.mock import MagicMock
import pytest
from deltachat_rpc_client import Contact, EventType, Message, events
from deltachat_rpc_client.const import ChatType, DownloadState, MessageState
from deltachat_rpc_client.const import DownloadState, MessageState
from deltachat_rpc_client.pytestplugin import E2EE_INFO_MSGS
from deltachat_rpc_client.rpc import JsonRpcError
@@ -338,44 +338,26 @@ def test_receive_imf_failure(acfactory) -> None:
bob.set_config("fail_on_receiving_full_msg", "1")
alice_chat_bob.send_text("Hello!")
event = bob.wait_for_incoming_msg_event()
chat_id = event.chat_id
event = bob.wait_for_event(EventType.MSGS_CHANGED)
assert event.chat_id == bob.get_device_chat().id
msg_id = event.msg_id
message = bob.get_message_by_id(msg_id)
snapshot = message.get_snapshot()
assert snapshot.chat_id == chat_id
assert snapshot.download_state == DownloadState.AVAILABLE
assert snapshot.error is not None
assert snapshot.show_padlock
assert (
snapshot.text == "❌ Failed to receive a message:"
" Condition failed: `!context.get_config_bool(Config::FailOnReceivingFullMsg).await?`."
" Please report this bug to delta@merlinux.eu or https://support.delta.chat/."
)
# The failed message doesn't break the IMAP loop.
bob.set_config("fail_on_receiving_full_msg", "0")
alice_chat_bob.send_text("Hello again!")
event = bob.wait_for_incoming_msg_event()
assert event.chat_id == chat_id
msg_id = event.msg_id
message1 = bob.get_message_by_id(msg_id)
snapshot = message1.get_snapshot()
assert snapshot.chat_id == chat_id
message = bob.wait_for_incoming_msg()
snapshot = message.get_snapshot()
assert snapshot.text == "Hello again!"
assert snapshot.download_state == DownloadState.DONE
assert snapshot.error is None
# The failed message can be re-downloaded later.
bob._rpc.download_full_message(bob.id, message.id)
event = bob.wait_for_event(EventType.MSGS_CHANGED)
message = bob.get_message_by_id(event.msg_id)
snapshot = message.get_snapshot()
assert snapshot.download_state == DownloadState.IN_PROGRESS
event = bob.wait_for_event(EventType.MSGS_CHANGED)
assert event.chat_id == chat_id
msg_id = event.msg_id
message = bob.get_message_by_id(msg_id)
snapshot = message.get_snapshot()
assert snapshot.chat_id == chat_id
assert snapshot.download_state == DownloadState.DONE
assert snapshot.error is None
assert snapshot.text == "Hello!"
def test_selfavatar_sync(acfactory, data, log) -> None:
alice = acfactory.get_online_account()
@@ -439,10 +421,7 @@ def test_is_bot(acfactory) -> None:
alice.set_config("bot", "1")
alice_chat_bob.send_text("Hello!")
event = bob.wait_for_incoming_msg_event()
message = bob.get_message_by_id(event.msg_id)
snapshot = message.get_snapshot()
assert snapshot.chat_id == event.chat_id
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello!"
assert snapshot.is_bot
@@ -488,7 +467,7 @@ def test_bot(acfactory) -> None:
def test_wait_next_messages(acfactory) -> None:
alice = acfactory.new_configured_account()
alice = acfactory.get_online_account()
# Create a bot account so it does not receive device messages in the beginning.
addr, password = acfactory.get_credentials()
@@ -496,26 +475,26 @@ def test_wait_next_messages(acfactory) -> None:
bot.set_config("bot", "1")
bot.add_or_update_transport({"addr": addr, "password": password})
assert bot.is_configured()
bot.bring_online()
# There are no old messages and the call returns immediately.
assert not bot.wait_next_messages()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
# Bot starts waiting for messages.
next_messages_task = executor.submit(bot.wait_next_messages)
# Bot starts waiting for messages.
next_messages_task = bot.wait_next_messages.future()
alice_contact_bot = alice.create_contact(bot, "Bot")
alice_chat_bot = alice_contact_bot.create_chat()
alice_chat_bot.send_text("Hello!")
alice_contact_bot = alice.create_contact(bot, "Bot")
alice_chat_bot = alice_contact_bot.create_chat()
alice_chat_bot.send_text("Hello!")
next_messages = next_messages_task.result()
next_messages = next_messages_task()
if len(next_messages) == E2EE_INFO_MSGS:
next_messages += bot.wait_next_messages()
if len(next_messages) == E2EE_INFO_MSGS:
next_messages += bot.wait_next_messages()
assert len(next_messages) == 1 + E2EE_INFO_MSGS
snapshot = next_messages[0 + E2EE_INFO_MSGS].get_snapshot()
assert snapshot.text == "Hello!"
assert len(next_messages) == 1 + E2EE_INFO_MSGS
snapshot = next_messages[0 + E2EE_INFO_MSGS].get_snapshot()
assert snapshot.text == "Hello!"
def test_import_export_backup(acfactory, tmp_path) -> None:
@@ -535,7 +514,7 @@ def test_import_export_keys(acfactory, tmp_path) -> None:
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_text("Hello Bob!")
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Bob!"
# Alice resetups account, but keeps the key.
@@ -547,7 +526,7 @@ def test_import_export_keys(acfactory, tmp_path) -> None:
snapshot.chat.accept()
snapshot.chat.send_text("Hello Alice!")
snapshot = alice.get_message_by_id(alice.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = alice.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Alice!"
assert snapshot.show_padlock
@@ -592,18 +571,13 @@ def test_mdn_doesnt_break_autocrypt(acfactory) -> None:
# Alice sends a message to Bob.
alice_chat_bob.send_text("Hello Bob!")
event = bob.wait_for_incoming_msg_event()
msg_id = event.msg_id
message = bob.get_message_by_id(msg_id)
snapshot = message.get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
# Bob sends a message to Alice.
bob_chat_alice = snapshot.chat
bob_chat_alice.accept()
bob_chat_alice.send_text("Hello Alice!")
event = alice.wait_for_incoming_msg_event()
msg_id = event.msg_id
message = alice.get_message_by_id(msg_id)
message = alice.wait_for_incoming_msg()
snapshot = message.get_snapshot()
assert snapshot.show_padlock
@@ -613,10 +587,7 @@ def test_mdn_doesnt_break_autocrypt(acfactory) -> None:
# Bob sends a message to Alice, it should also be encrypted.
bob_chat_alice.send_text("Hi Alice!")
event = alice.wait_for_incoming_msg_event()
msg_id = event.msg_id
message = alice.get_message_by_id(msg_id)
snapshot = message.get_snapshot()
snapshot = alice.wait_for_incoming_msg().get_snapshot()
assert snapshot.show_padlock
@@ -674,50 +645,6 @@ def test_reaction_to_partially_fetched_msg(acfactory, tmp_path):
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
def test_reactions_for_a_reordering_move(acfactory, direct_imap):
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
messages they refer to and thus dropped.
"""
(ac1,) = acfactory.get_online_accounts(1)
addr, password = acfactory.get_credentials()
ac2 = acfactory.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password})
ac2.set_config("mvbox_move", "1")
assert ac2.is_configured()
ac2.bring_online()
chat1 = acfactory.get_accepted_chat(ac1, ac2)
ac2.stop_io()
logging.info("sending message + reaction from ac1 to ac2")
msg1 = chat1.send_text("hi")
msg1.wait_until_delivered()
# It's is sad, but messages must differ in their INTERNALDATEs to be processed in the correct
# order by DC, and most (if not all) mail servers provide only seconds precision.
time.sleep(1.1)
react_str = "\N{THUMBS UP SIGN}"
msg1.send_reaction(react_str).wait_until_delivered()
logging.info("moving messages to ac2's DeltaChat folder in the reverse order")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.connect()
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()], reverse=True):
ac2_direct_imap.conn.move(uid, "DeltaChat")
logging.info("receiving messages by ac2")
ac2.start_io()
msg2 = Message(ac2, ac2.wait_for_reactions_changed().msg_id)
assert msg2.get_snapshot().text == msg1.get_snapshot().text
reactions = msg2.get_reactions()
contacts = [Contact(ac2, int(i)) for i in reactions.reactions_by_contact]
assert len(contacts) == 1
assert contacts[0].get_snapshot().address == ac1.get_config("addr")
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
@pytest.mark.parametrize("n_accounts", [3, 2])
def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
download_limit = 300000
@@ -729,7 +656,7 @@ def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
for account in others:
chat = account.create_chat(alice)
chat.send_text("Hello Alice!")
assert alice.get_message_by_id(alice.wait_for_incoming_msg_event().msg_id).get_snapshot().text == "Hello Alice!"
assert alice.wait_for_incoming_msg().get_snapshot().text == "Hello Alice!"
contact = alice.create_contact(account)
alice_group.add_contact(contact)
@@ -739,7 +666,7 @@ def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
bob.set_config("download_limit", str(download_limit))
alice_group.send_text("hi")
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "hi"
bob_group = snapshot.chat
@@ -749,7 +676,7 @@ def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
for i in range(10):
logging.info("Sending message %s", i)
alice_group.send_file(str(path))
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.download_state == DownloadState.AVAILABLE
if n_accounts > 2:
assert snapshot.chat == bob_group
@@ -776,8 +703,8 @@ def test_markseen_contact_request(acfactory):
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_text("Hello Bob!")
message = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id)
message2 = bob2.get_message_by_id(bob2.wait_for_incoming_msg_event().msg_id)
message = bob.wait_for_incoming_msg()
message2 = bob2.wait_for_incoming_msg()
assert message2.get_snapshot().state == MessageState.IN_FRESH
message.mark_seen()
@@ -799,7 +726,7 @@ def test_read_receipt(acfactory):
msg = bob.wait_for_incoming_msg()
msg.mark_seen()
read_msg = alice.get_message_by_id(alice.wait_for_event(EventType.MSG_READ).msg_id)
read_msg = alice.wait_for_msg(EventType.MSG_READ)
read_receipts = read_msg.get_read_receipts()
assert len(read_receipts) == 1
assert read_receipts[0].contact_id == alice_contact_bob.id
@@ -816,7 +743,7 @@ def test_configured_imap_certificate_checks(acfactory):
alice = acfactory.new_configured_account()
# Certificate checks should be configured (not None)
assert "cert_automatic" in alice.get_info().used_account_settings
assert "cert_strict" in alice.get_info().used_account_settings
# "cert_old_automatic" is the value old Delta Chat core versions used
# to mean user entered "imap_certificate_checks=0" (Automatic)
@@ -888,10 +815,12 @@ def test_rename_group(acfactory):
bob_msg = bob.wait_for_incoming_msg()
bob_chat = bob_msg.get_snapshot().chat
assert bob_chat.get_basic_snapshot().name == "Test group"
bob.wait_for_event(EventType.CHATLIST_ITEM_CHANGED)
for name in ["Baz", "Foo bar", "Xyzzy"]:
alice_group.set_name(name)
bob.wait_for_incoming_msg_event()
bob.wait_for_event(EventType.CHATLIST_ITEM_CHANGED)
bob.wait_for_event(EventType.CHATLIST_ITEM_CHANGED)
assert bob_chat.get_basic_snapshot().name == name
@@ -903,58 +832,174 @@ def test_get_all_accounts_deadlock(rpc):
all_accounts()
def test_delete_deltachat_folder(acfactory, direct_imap):
"""Test that DeltaChat folder is recreated if user deletes it manually."""
ac1 = acfactory.new_configured_account()
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.conn.folder.delete("DeltaChat")
assert "DeltaChat" not in ac1_direct_imap.list_folders()
# Wait until new folder is created and UIDVALIDITY is updated.
while True:
event = ac1.wait_for_event()
if event.kind == EventType.INFO and "uid/validity change folder DeltaChat" in event.msg:
break
ac2 = acfactory.get_online_account()
ac2.create_chat(ac1).send_text("hello")
msg = ac1.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
assert "DeltaChat" in ac1_direct_imap.list_folders()
def test_broadcast(acfactory):
@pytest.mark.parametrize("all_devices_online", [True, False])
def test_leave_broadcast(acfactory, all_devices_online):
alice, bob = acfactory.get_online_accounts(2)
alice_chat = alice.create_broadcast("My great channel")
snapshot = alice_chat.get_basic_snapshot()
assert snapshot.name == "My great channel"
assert snapshot.is_unpromoted
assert snapshot.is_encrypted
assert snapshot.chat_type == ChatType.OUT_BROADCAST
bob2 = bob.clone()
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat.add_contact(alice_contact_bob)
if all_devices_online:
bob2.start_io()
alice_msg = alice_chat.send_message(text="hello").get_snapshot()
assert alice_msg.text == "hello"
assert alice_msg.show_padlock
logging.info("===================== Alice creates a broadcast =====================")
alice_chat = alice.create_broadcast("Broadcast channel!")
bob_msg = bob.wait_for_incoming_msg().get_snapshot()
assert bob_msg.text == "hello"
assert bob_msg.show_padlock
assert bob_msg.error is None
logging.info("===================== Bob joins the broadcast =====================")
qr_code = alice_chat.get_qr_code()
bob.secure_join(qr_code)
alice.wait_for_securejoin_inviter_success()
bob.wait_for_securejoin_joiner_success()
bob_chat = bob.get_chat_by_id(bob_msg.chat_id)
bob_chat_snapshot = bob_chat.get_basic_snapshot()
assert bob_chat_snapshot.name == "My great channel"
assert not bob_chat_snapshot.is_unpromoted
assert bob_chat_snapshot.is_encrypted
assert bob_chat_snapshot.chat_type == ChatType.IN_BROADCAST
assert bob_chat_snapshot.is_contact_request
alice_bob_contact = alice.create_contact(bob)
alice_contacts = alice_chat.get_contacts()
assert len(alice_contacts) == 1 # 1 recipient
assert alice_contacts[0].id == alice_bob_contact.id
assert not bob_chat.can_send()
member_added_msg = bob.wait_for_incoming_msg()
assert member_added_msg.get_snapshot().text == "You joined the channel."
def get_broadcast(ac):
chat = ac.get_chatlist(query="Broadcast channel!")[0]
assert chat.get_basic_snapshot().name == "Broadcast channel!"
return chat
def check_account(ac, contact, inviter_side, please_wait_info_msg=False):
chat = get_broadcast(ac)
contact_snapshot = contact.get_snapshot()
chat_msgs = chat.get_messages()
encrypted_msg = chat_msgs.pop(0).get_snapshot()
assert encrypted_msg.text == "Messages are end-to-end encrypted."
assert encrypted_msg.is_info
if please_wait_info_msg:
first_msg = chat_msgs.pop(0).get_snapshot()
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:
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
else:
assert member_added_msg.text == "You joined the channel."
assert member_added_msg.is_info
if not inviter_side:
leave_msg = chat_msgs.pop(0).get_snapshot()
assert leave_msg.text == "You left the channel."
assert len(chat_msgs) == 0
chat_snapshot = chat.get_full_snapshot()
# On Alice's side, SELF is not in the list of contact ids
# because OutBroadcast chats never contain SELF in the list.
# On Bob's side, SELF is not in the list because he left.
if inviter_side:
assert len(chat_snapshot.contact_ids) == 0
else:
assert chat_snapshot.contact_ids == [contact.id]
logging.info("===================== Bob leaves the broadcast =====================")
bob_chat = get_broadcast(bob)
assert bob_chat.get_full_snapshot().self_in_group
assert len(bob_chat.get_contacts()) == 2 # Alice and Bob
bob_chat.leave()
assert not bob_chat.get_full_snapshot().self_in_group
# After Bob left, only Alice will be left in Bob's memberlist
assert len(bob_chat.get_contacts()) == 1
check_account(bob, bob.create_contact(alice), inviter_side=False, please_wait_info_msg=True)
logging.info("===================== Test Alice's device =====================")
while len(alice_chat.get_contacts()) != 0: # After Bob left, there will be 0 recipients
alice.wait_for_event(EventType.CHAT_MODIFIED)
check_account(alice, alice.create_contact(bob), inviter_side=True)
logging.info("===================== Test Bob's second device =====================")
# Start second Bob device, if it wasn't started already.
bob2.start_io()
member_added_msg = bob2.wait_for_incoming_msg()
assert member_added_msg.get_snapshot().text == "You joined the channel."
bob2_chat = get_broadcast(bob2)
# After Bob left, only Alice will be left in Bob's memberlist
while len(bob2_chat.get_contacts()) != 1:
bob2.wait_for_event(EventType.CHAT_MODIFIED)
check_account(bob2, bob2.create_contact(alice), inviter_side=False)
def test_immediate_autodelete(acfactory, direct_imap, log):
ac1, ac2 = acfactory.get_online_accounts(2)
# "1" means delete immediately, while "0" means do not delete
ac2.set_config("delete_server_after", "1")
log.section("ac1: create chat with ac2")
chat1 = ac1.create_chat(ac2)
ac2.create_chat(ac1)
log.section("ac1: send message to ac2")
sent_msg = chat1.send_text("hello")
msg = ac2.wait_for_incoming_msg()
assert msg.get_snapshot().text == "hello"
log.section("ac2: wait for close/expunge on autodelete")
ac2.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
while True:
event = ac2.wait_for_event()
if event.kind == EventType.INFO and "Close/expunge succeeded." in event.msg:
break
log.section("ac2: check that message was autodeleted on server")
ac2_direct_imap = direct_imap(ac2)
assert len(ac2_direct_imap.get_all_messages()) == 0
log.section("ac2: Mark deleted message as seen and check that read receipt arrives")
msg.mark_seen()
ev = ac1.wait_for_event(EventType.MSG_READ)
assert ev.chat_id == chat1.id
assert ev.msg_id == sent_msg.id
def test_background_fetch(acfactory, dc):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.stop_io()
ac1_chat = ac1.create_chat(ac2)
ac2_chat = ac2.create_chat(ac1)
ac2_chat.send_text("Hello!")
while True:
dc.background_fetch(300)
messages = ac1_chat.get_messages()
snapshot = messages[-1].get_snapshot()
if snapshot.text == "Hello!":
break
# Stopping background fetch immediately after starting
# does not result in any errors.
background_fetch_future = dc.background_fetch.future(300)
dc.stop_background_fetch()
background_fetch_future()
# Starting background fetch with zero timeout is ok,
# it should terminate immediately.
dc.background_fetch(0)
# Background fetch can still be used to send and receive messages.
ac2_chat.send_text("Hello again!")
while True:
dc.background_fetch(300)
messages = ac1_chat.get_messages()
snapshot = messages[-1].get_snapshot()
if snapshot.text == "Hello again!":
break

View File

@@ -1,6 +1,7 @@
def test_vcard(acfactory) -> None:
alice, bob, fiona = acfactory.get_online_accounts(3)
bob.create_chat(alice)
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_contact_charlie = alice.create_contact("charlie@example.org", "Charlie")
alice_contact_charlie_snapshot = alice_contact_charlie.get_snapshot()

View File

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

View File

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

View File

@@ -7,9 +7,6 @@ ignore = [
# <https://rustsec.org/advisories/RUSTSEC-2023-0071>
"RUSTSEC-2023-0071",
# Archived repository
"RUSTSEC-2023-0089",
# Unmaintained instant
"RUSTSEC-2024-0384",
@@ -29,20 +26,21 @@ skip = [
{ name = "derive_more", version = "1.0.0" },
{ name = "event-listener", version = "2.5.3" },
{ name = "getrandom", version = "0.2.12" },
{ name = "hashbrown", version = "0.14.5" },
{ name = "heck", version = "0.4.1" },
{ name = "http", version = "0.2.12" },
{ name = "linux-raw-sys", version = "0.4.14" },
{ name = "netdev", version = "0.36.0" },
{ name = "netlink-packet-route", version = "0.22.0" },
{ name = "lru", version = "0.12.3" },
{ name = "netlink-packet-route", version = "0.17.1" },
{ name = "nom", version = "7.1.3" },
{ name = "rand_chacha", version = "0.3.1" },
{ name = "rand_core", version = "0.6.4" },
{ name = "rand", version = "0.8.5" },
{ name = "rustix", version = "0.38.44" },
{ name = "rustls-webpki", version = "0.102.8" },
{ name = "serdect", version = "0.2.0" },
{ name = "socket2", version = "0.5.9" },
{ name = "spin", version = "0.9.8" },
{ name = "strum_macros", version = "0.26.2" },
{ name = "strum", version = "0.26.2" },
{ name = "syn", version = "1.0.109" },
{ name = "thiserror-impl", version = "1.0.69" },
{ name = "thiserror", version = "1.0.69" },
@@ -57,7 +55,6 @@ skip = [
{ name = "windows_i686_msvc" },
{ name = "windows-implement" },
{ name = "windows-interface" },
{ name = "windows-link" },
{ name = "windows-result" },
{ name = "windows-strings" },
{ name = "windows-sys" },

18
flake.lock generated
View File

@@ -47,11 +47,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1747291057,
"narHash": "sha256-9Wir6aLJAeJKqdoQUiwfKdBn7SyNXTJGRSscRyVOo2Y=",
"lastModified": 1763361733,
"narHash": "sha256-ka7dpwH3HIXCyD2wl5F7cPLeRbqZoY2ullALsvxdPt8=",
"owner": "nix-community",
"repo": "fenix",
"rev": "76ffc1b7b3ec8078fe01794628b6abff35cbda8f",
"rev": "6c8d48e3b0ae371b19ac1485744687b788e80193",
"type": "github"
},
"original": {
@@ -147,11 +147,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1747179050,
"narHash": "sha256-qhFMmDkeJX9KJwr5H32f1r7Prs7XbQWtO0h3V0a0rFY=",
"lastModified": 1762977756,
"narHash": "sha256-4PqRErxfe+2toFJFgcRKZ0UI9NSIOJa+7RXVtBhy4KE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "adaa24fbf46737f3f1b5497bf64bae750f82942e",
"rev": "c5ae371f1a6a7fd27823bc500d9390b38c05fa55",
"type": "github"
},
"original": {
@@ -202,11 +202,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1746889290,
"narHash": "sha256-h3LQYZgyv2l3U7r+mcsrEOGRldaK0zJFwAAva4hV/6g=",
"lastModified": 1762860488,
"narHash": "sha256-rMfWMCOo/pPefM2We0iMBLi2kLBAnYoB9thi4qS7uk4=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "2bafe9d96c6734aacfd49e115f6cf61e7adc68bc",
"rev": "2efc80078029894eec0699f62ec8d5c1a56af763",
"type": "github"
},
"original": {

View File

@@ -1,5 +1,5 @@
{
description = "Delta Chat core";
description = "Chatmail core";
inputs = {
fenix.url = "github:nix-community/fenix";
flake-utils.url = "github:numtide/flake-utils";
@@ -14,7 +14,15 @@
pkgs = nixpkgs.legacyPackages.${system};
inherit (pkgs.stdenv) isDarwin;
fenixPkgs = fenix.packages.${system};
naersk' = pkgs.callPackage naersk { };
fenixToolchain = fenixPkgs.combine [
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
fenixPkgs.stable.rust-std
];
naersk' = pkgs.callPackage naersk {
cargo = fenixToolchain;
rustc = fenixToolchain;
};
manifest = (pkgs.lib.importTOML ./Cargo.toml).package;
androidSdk = android.sdk.${system} (sdkPkgs:
builtins.attrValues {
@@ -34,7 +42,6 @@
./Cargo.lock
./Cargo.toml
./CMakeLists.txt
./CONTRIBUTING.md
./deltachat_derive
./deltachat-contact-tools
./deltachat-ffi
@@ -471,6 +478,12 @@
};
libdeltachat =
let
rustPlatform = (pkgs.makeRustPlatform {
cargo = fenixToolchain;
rustc = fenixToolchain;
});
in
pkgs.stdenv.mkDerivation {
pname = "libdeltachat";
version = manifest.version;
@@ -480,8 +493,9 @@
nativeBuildInputs = [
pkgs.perl # Needed to build vendored OpenSSL.
pkgs.cmake
pkgs.rustPlatform.cargoSetupHook
pkgs.cargo
rustPlatform.cargoSetupHook
fenixPkgs.stable.rustc
fenixPkgs.stable.cargo
];
postInstall = ''

View File

@@ -14,6 +14,7 @@ def datadir():
return None
@pytest.mark.skip("The test is flaky in CI and crashes the interpreter as of 2025-11-12")
def test_echo_quit_plugin(acfactory, lp):
lp.sec("creating one echo_and_quit bot")
botproc = acfactory.run_bot_process(echo_and_quit)

View File

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

View File

@@ -116,7 +116,6 @@ class TestGroupStressTests:
def test_qr_verified_group_and_chatting(acfactory, lp):
ac1, ac2, ac3 = acfactory.get_online_accounts(3)
ac1_addr = ac1.get_self_contact().addr
lp.sec("ac1: create verified-group QR, ac2 scans and joins")
chat1 = ac1.create_group_chat("hello")
qr = chat1.get_join_qr()
@@ -174,7 +173,9 @@ def test_qr_verified_group_and_chatting(acfactory, lp):
for ac2_contact in chat2.get_contacts():
if ac2_contact == ac2_ac1_contact or ac2_contact.id == dc.const.DC_CONTACT_ID_SELF:
continue
assert ac2.get_self_contact().get_verifier(ac2_contact).addr == ac1_addr
# Until we reset verifications and then send the _verified header,
# verification is not gossiped here:
assert ac2.get_self_contact().get_verifier(ac2_contact) is None
lp.sec("ac2: send message and let ac3 read it")
chat2.send_text("hi")
@@ -409,7 +410,9 @@ def test_verified_group_vs_delete_server_after(acfactory, tmp_path, lp):
assert msg_in.text == "hi2"
assert msg_in.chat == chat2_offl
assert msg_in.get_sender_contact().addr == ac2.get_config("addr")
assert ac2_offl_ac1_contact.is_verified()
# Until we reset verifications and then send the _verified header,
# verification is not gossiped here:
assert not ac2_offl_ac1_contact.is_verified()
def test_deleted_msgs_dont_reappear(acfactory):

View File

@@ -5,7 +5,7 @@ import base64
from datetime import datetime, timezone
import pytest
from imap_tools import AND, U
from imap_tools import AND
import deltachat as dc
from deltachat import account_hookimpl, Message
@@ -269,112 +269,6 @@ def test_enable_mvbox_move(acfactory, lp):
assert ac2._evtracker.wait_next_incoming_message().text == "message1"
def test_mvbox_thread_and_trash(acfactory, lp):
lp.sec("ac1: start with mvbox thread")
ac1 = acfactory.new_online_configuring_account(mvbox_move=True)
lp.sec("ac2: start without a mvbox thread")
ac2 = acfactory.new_online_configuring_account(mvbox_move=False)
lp.sec("ac2 and ac1: waiting for configuration")
acfactory.bring_accounts_online()
lp.sec("ac1: create trash")
ac1.direct_imap.create_folder("Trash")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.stop_io()
ac1.start_io()
lp.sec("ac1: send message and wait for ac2 to receive it")
acfactory.get_accepted_chat(ac1, ac2).send_text("message1")
assert ac2._evtracker.wait_next_incoming_message().text == "message1"
assert ac1.get_config("configured_mvbox_folder") == "DeltaChat"
while ac1.get_config("configured_trash_folder") != "Trash":
ac1._evtracker.get_matching("DC_EVENT_CONNECTIVITY_CHANGED")
def test_move_works(acfactory):
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account(mvbox_move=True)
acfactory.bring_accounts_online()
chat = acfactory.get_accepted_chat(ac1, ac2)
chat.send_text("message1")
# Message is moved to the movebox
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
# Message is downloaded
ev = ac2._evtracker.get_matching("DC_EVENT_INCOMING_MSG")
assert ev.data2 > dc.const.DC_CHAT_ID_LAST_SPECIAL
def test_move_avoids_loop(acfactory):
"""Test that the message is only moved from INBOX to DeltaChat.
This is to avoid busy loop if moved message reappears in the Inbox
or some scanned folder later.
For example, this happens on servers that alias `INBOX.DeltaChat` to `DeltaChat` folder,
so the message moved to `DeltaChat` appears as a new message in the `INBOX.DeltaChat` folder.
We do not want to move this message from `INBOX.DeltaChat` to `DeltaChat` again.
"""
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account(mvbox_move=True)
acfactory.bring_accounts_online()
# Create INBOX.DeltaChat folder and make sure
# it is detected by full folder scan.
ac2.direct_imap.create_folder("INBOX.DeltaChat")
ac2.stop_io()
ac2.start_io()
ac2._evtracker.get_info_contains("Found folders:") # Wait until the end of folder scan.
ac1_chat = acfactory.get_accepted_chat(ac1, ac2)
ac1_chat.send_text("Message 1")
# Message is moved to the DeltaChat folder and downloaded.
ac2_msg1 = ac2._evtracker.wait_next_incoming_message()
assert ac2_msg1.text == "Message 1"
# Move the message to the INBOX.DeltaChat again.
# We assume that test server uses "." as the delimiter.
ac2.direct_imap.select_folder("DeltaChat")
ac2.direct_imap.conn.move(["*"], "INBOX.DeltaChat")
ac1_chat.send_text("Message 2")
ac2_msg2 = ac2._evtracker.wait_next_incoming_message()
assert ac2_msg2.text == "Message 2"
# Stop and start I/O to trigger folder scan.
ac2.stop_io()
ac2.start_io()
ac2._evtracker.get_info_contains("Found folders:") # Wait until the end of folder scan.
# Check that Message 1 is still in the INBOX.DeltaChat folder
# and Message 2 is in the DeltaChat folder.
ac2.direct_imap.select_folder("INBOX")
assert len(ac2.direct_imap.get_all_messages()) == 0
ac2.direct_imap.select_folder("DeltaChat")
assert len(ac2.direct_imap.get_all_messages()) == 1
ac2.direct_imap.select_folder("INBOX.DeltaChat")
assert len(ac2.direct_imap.get_all_messages()) == 1
def test_move_works_on_self_sent(acfactory):
ac1 = acfactory.new_online_configuring_account(mvbox_move=True)
ac2 = acfactory.new_online_configuring_account()
acfactory.bring_accounts_online()
ac1.set_config("bcc_self", "1")
chat = acfactory.get_accepted_chat(ac1, ac2)
chat.send_text("message1")
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
chat.send_text("message2")
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
chat.send_text("message3")
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
def test_move_sync_msgs(acfactory):
ac1 = acfactory.new_online_configuring_account(bcc_self=True, sync_msgs=True, fix_is_chatmail=True)
acfactory.bring_accounts_online()
@@ -460,7 +354,7 @@ def test_forward_own_message(acfactory, lp):
def test_resend_message(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat1 = ac1.create_chat(ac2)
chat1 = acfactory.get_accepted_chat(ac1, ac2)
lp.sec("ac1: send message to ac2")
chat1.send_text("message")
@@ -607,39 +501,6 @@ def test_send_and_receive_message_markseen(acfactory, lp):
pass # mark_seen_messages() has generated events before it returns
def test_moved_markseen(acfactory):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account(mvbox_move=True)
acfactory.bring_accounts_online()
ac2.stop_io()
with ac2.direct_imap.idle() as idle2:
ac1.create_chat(ac2).send_text("Hello!")
idle2.wait_for_new_message()
# Emulate moving of the message to DeltaChat folder by Sieve rule.
ac2.direct_imap.conn.move(["*"], "DeltaChat")
ac2.direct_imap.select_folder("DeltaChat")
with ac2.direct_imap.idle() as idle2:
ac2.start_io()
ev = ac2._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
msg = ac2.get_message_by_id(ev.data2)
assert msg.text == "Messages are end-to-end encrypted."
ev = ac2._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
msg = ac2.get_message_by_id(ev.data2)
# Accept the contact request.
msg.chat.accept()
ac2.mark_seen_messages([msg])
uid = idle2.wait_for_seen()
assert len(list(ac2.direct_imap.conn.fetch(AND(seen=True, uid=U(uid, "*"))))) == 1
def test_message_override_sender_name(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("displayname", "ac1-default-displayname")
@@ -674,36 +535,6 @@ def test_message_override_sender_name(acfactory, lp):
assert not msg2.override_sender_name
@pytest.mark.parametrize("mvbox_move", [True, False])
def test_markseen_message_and_mdn(acfactory, mvbox_move):
# Please only change this test if you are very sure that it will still catch the issues it catches now.
# We had so many problems with markseen, if in doubt, rather create another test, it can't harm.
ac1 = acfactory.new_online_configuring_account(mvbox_move=mvbox_move)
ac2 = acfactory.new_online_configuring_account(mvbox_move=mvbox_move)
acfactory.bring_accounts_online()
# Do not send BCC to self, we only want to test MDN on ac1.
ac1.set_config("bcc_self", "0")
acfactory.get_accepted_chat(ac1, ac2).send_text("hi")
msg = ac2._evtracker.wait_next_incoming_message()
ac2.mark_seen_messages([msg])
folder = "mvbox" if mvbox_move else "inbox"
for ac in [ac1, ac2]:
if mvbox_move:
ac._evtracker.get_info_contains("Marked messages [0-9]+ in folder DeltaChat as seen.")
else:
ac._evtracker.get_info_contains("Marked messages [0-9]+ in folder INBOX as seen.")
ac1.direct_imap.select_config_folder(folder)
ac2.direct_imap.select_config_folder(folder)
# Check that the mdn is marked as seen
assert len(list(ac1.direct_imap.conn.fetch(AND(seen=True)))) == 1
# Check original message is marked as seen
assert len(list(ac2.direct_imap.conn.fetch(AND(seen=True)))) == 1
def test_reply_privately(acfactory):
ac1, ac2 = acfactory.get_online_accounts(2)
@@ -853,140 +684,6 @@ def test_no_draft_if_cant_send(acfactory):
assert device_chat.get_draft() is None
def test_dont_show_emails(acfactory, lp):
"""Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them.
So: If it's outgoing AND there is no Received header, then ignore the email.
If the draft email is sent out and received later (i.e. it's in "Inbox"), it must be shown.
Also, test that unknown emails in the Spam folder are not shown."""
ac1 = acfactory.new_online_configuring_account()
ac1.set_config("show_emails", "2")
ac1.create_contact("alice@example.org").create_chat()
acfactory.wait_configured(ac1)
ac1.direct_imap.create_folder("Drafts")
ac1.direct_imap.create_folder("Spam")
ac1.direct_imap.create_folder("Junk")
acfactory.bring_accounts_online()
ac1.stop_io()
ac1.direct_imap.append(
"Drafts",
"""
From: ac1 <{}>
Subject: subj
To: alice@example.org
Message-ID: <aepiors@example.org>
Content-Type: text/plain; charset=utf-8
message in Drafts received later
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org, unkwnown.add@junk.org
Subject: subj
To: {}
Message-ID: <spam.message2@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Spam",
"""
From: delta<address: inbox@nhroy.com>
Subject: subj
To: {}
Message-ID: <spam.message99@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Spam",
"""
From: alice@example.org
Subject: subj
To: {}
Message-ID: <spam.message3@junk.org>
Content-Type: text/plain; charset=utf-8
Actually interesting message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.direct_imap.append(
"Junk",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Junk
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.set_config("scan_all_folders_debounce_secs", "0")
lp.sec("All prepared, now let DC find the message")
ac1.start_io()
# Wait until each folder was scanned, this is necessary for this test to test what it should test:
ac1._evtracker.wait_idle_inbox_ready()
fresh_msgs = list(ac1.get_fresh_messages())
msg = fresh_msgs[0]
chat_msgs = msg.chat.get_messages()
assert len(chat_msgs) == 1
assert any(msg.text == "subj Actually interesting message in Spam" for msg in chat_msgs)
assert not any("unknown.address" in c.get_name() for c in ac1.get_chats())
ac1.direct_imap.select_folder("Spam")
assert ac1.direct_imap.get_uid_by_message_id("spam.message@junk.org")
ac1.stop_io()
lp.sec("'Send out' the draft by moving it to Inbox, and wait for DC to display it this time")
ac1.direct_imap.select_folder("Drafts")
uid = ac1.direct_imap.get_uid_by_message_id("aepiors@example.org")
ac1.direct_imap.conn.move(uid, "Inbox")
ac1.start_io()
msg2 = ac1._evtracker.wait_next_messages_changed()
assert msg2.text == "subj message in Drafts received later"
assert len(msg.chat.get_messages()) == 2
def test_bot(acfactory, lp):
"""Test that bot messages can be identified as such"""
ac1, ac2 = acfactory.get_online_accounts(2)
@@ -1238,8 +935,11 @@ def test_qr_email_capitalization(acfactory, lp):
# ac1 should see both ac3 and ac2 as verified.
assert len(ac1_chat.get_contacts()) == 3
# Until we reset verifications and then send the _verified header,
# the verification of ac2 is not gossiped here:
for contact in ac1_chat.get_contacts():
assert contact.is_verified()
is_ac2 = contact.addr == ac2.get_config("addr")
assert contact.is_verified() != is_ac2
def test_set_get_contact_avatar(acfactory, data, lp):
@@ -1506,9 +1206,15 @@ def test_send_receive_locations(acfactory, lp):
assert locations[0].latitude == 2.0
assert locations[0].longitude == 3.0
assert locations[0].accuracy == 0.5
assert locations[0].timestamp > now
assert locations[0].marker is None
# Make sure the timestamp is not in the past.
# Note that location timestamp has only 1 second precision,
# while `now` has a fractional part, so we have to truncate it
# first, otherwise `now` may appear to be in the future
# even though it is the same second.
assert int(locations[0].timestamp.timestamp()) >= int(now.timestamp())
contact = ac2.create_contact(ac1)
locations2 = chat2.get_locations(contact=contact)
assert len(locations2) == 1
@@ -1519,38 +1225,6 @@ def test_send_receive_locations(acfactory, lp):
assert not locations3
def test_immediate_autodelete(acfactory, lp):
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account()
acfactory.bring_accounts_online()
# "1" means delete immediately, while "0" means do not delete
ac2.set_config("delete_server_after", "1")
lp.sec("ac1: create chat with ac2")
chat1 = ac1.create_chat(ac2)
ac2.create_chat(ac1)
lp.sec("ac1: send message to ac2")
sent_msg = chat1.send_text("hello")
msg = ac2._evtracker.wait_next_incoming_message()
assert msg.text == "hello"
lp.sec("ac2: wait for close/expunge on autodelete")
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_DELETED")
ac2._evtracker.get_info_contains("Close/expunge succeeded.")
lp.sec("ac2: check that message was autodeleted on server")
assert len(ac2.direct_imap.get_all_messages()) == 0
lp.sec("ac2: Mark deleted message as seen and check that read receipt arrives")
msg.mark_seen()
ev = ac1._evtracker.get_matching("DC_EVENT_MSG_READ")
assert ev.data1 == chat1.id
assert ev.data2 == sent_msg.id
def test_delete_multiple_messages(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat12 = acfactory.get_accepted_chat(ac1, ac2)
@@ -1583,55 +1257,6 @@ def test_delete_multiple_messages(acfactory, lp):
break
def test_trash_multiple_messages(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.stop_io()
# Create the Trash folder on IMAP server and configure deletion to it. There was a bug that if
# Trash wasn't configured initially, it can't be configured later, let's check this.
lp.sec("Creating trash folder")
ac2.direct_imap.create_folder("Trash")
ac2.set_config("delete_to_trash", "1")
lp.sec("Check that Trash can be configured initially as well")
ac3 = acfactory.new_online_configuring_account(cloned_from=ac2)
acfactory.bring_accounts_online()
assert ac3.get_config("configured_trash_folder")
ac3.stop_io()
ac2.start_io()
chat12 = acfactory.get_accepted_chat(ac1, ac2)
lp.sec("ac1: sending 3 messages")
texts = ["first", "second", "third"]
for text in texts:
chat12.send_text(text)
lp.sec("ac2: waiting for all messages on the other side")
to_delete = []
for text in texts:
msg = ac2._evtracker.wait_next_incoming_message()
assert msg.text in texts
if text != "second":
to_delete.append(msg)
# ac2 has received some messages, this is impossible w/o the trash folder configured, let's
# check the configuration.
assert ac2.get_config("configured_trash_folder") == "Trash"
lp.sec("ac2: deleting all messages except second")
assert len(to_delete) == len(texts) - 1
ac2.delete_messages(to_delete)
lp.sec("ac2: test that only one message is left")
while 1:
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
ac2.direct_imap.select_config_folder("inbox")
nr_msgs = len(ac2.direct_imap.get_all_messages())
assert nr_msgs > 0
if nr_msgs == 1:
break
def test_configure_error_msgs_wrong_pw(acfactory):
(ac1,) = acfactory.get_online_accounts(1)
@@ -1755,71 +1380,6 @@ def test_group_quote(acfactory, lp):
assert received_reply.quote.id == out_msg.id
@pytest.mark.parametrize(
("folder", "move", "expected_destination"),
[
(
"xyz",
False,
"xyz",
), # Test that emails aren't found in a random folder
(
"xyz",
True,
"xyz",
), # ...emails are found in a random folder and downloaded without moving
(
"Spam",
False,
"INBOX",
), # ...emails are moved from the spam folder to the Inbox
],
)
# Testrun.org does not support the CREATE-SPECIAL-USE capability, which means that we can't create a folder with
# the "\Junk" flag (see https://tools.ietf.org/html/rfc6154). So, we can't test spam folder detection by flag.
def test_scan_folders(acfactory, lp, folder, move, expected_destination):
"""Delta Chat periodically scans all folders for new messages to make sure we don't miss any."""
variant = folder + "-" + str(move) + "-" + expected_destination
lp.sec("Testing variant " + variant)
ac1 = acfactory.new_online_configuring_account(mvbox_move=move)
ac2 = acfactory.new_online_configuring_account()
acfactory.wait_configured(ac1)
ac1.direct_imap.create_folder(folder)
# Wait until each folder was selected once and we are IDLEing:
acfactory.bring_accounts_online()
ac1.stop_io()
assert folder in ac1.direct_imap.list_folders()
lp.sec("Send a message to from ac2 to ac1 and manually move it to `folder`")
ac1.direct_imap.select_config_folder("inbox")
with ac1.direct_imap.idle() as idle1:
acfactory.get_accepted_chat(ac2, ac1).send_text("hello")
idle1.wait_for_new_message()
ac1.direct_imap.conn.move(["*"], folder) # "*" means "biggest UID in mailbox"
lp.sec("start_io() and see if DeltaChat finds the message (" + variant + ")")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.start_io()
chat = ac1.create_chat(ac2)
n_msgs = 1 # "Messages are end-to-end encrypted."
if folder == "Spam":
msg = ac1._evtracker.wait_next_incoming_message()
assert msg.text == "hello"
n_msgs += 1
else:
ac1._evtracker.wait_idle_inbox_ready()
assert len(chat.get_messages()) == n_msgs
# The message has reached its destination.
ac1.direct_imap.select_folder(expected_destination)
assert len(ac1.direct_imap.get_all_messages()) == 1
if folder != expected_destination:
ac1.direct_imap.select_folder(folder)
assert len(ac1.direct_imap.get_all_messages()) == 0
def test_archived_muted_chat(acfactory, lp):
"""If an archived and muted chat receives a new message, DC_EVENT_MSGS_CHANGED for
DC_CHAT_ID_ARCHIVED_LINK must be generated if the chat had only seen messages previously.

View File

@@ -35,7 +35,7 @@ class TestOfflineAccountBasic:
d = ac1.get_info()
assert d["arch"]
assert d["number_of_chats"] == "0"
assert d["bcc_self"] == "1"
assert d["bcc_self"] == "0"
def test_is_not_configured(self, acfactory):
ac1 = acfactory.get_unconfigured_account()
@@ -69,7 +69,7 @@ class TestOfflineAccountBasic:
def test_has_bccself(self, acfactory):
ac1 = acfactory.get_unconfigured_account()
assert "bcc_self" in ac1.get_config("sys.config_keys").split()
assert ac1.get_config("bcc_self") == "1"
assert ac1.get_config("bcc_self") == "0"
def test_selfcontact_if_unconfigured(self, acfactory):
ac1 = acfactory.get_unconfigured_account()

View File

@@ -23,7 +23,6 @@ deps =
pytest
pytest-timeout
pytest-xdist
pdbpp
requests
# urllib3 2.0 does not work in manylinux2014 containers.
# https://github.com/deltachat/deltachat-core-rust/issues/4788
@@ -47,7 +46,7 @@ deps =
commands =
ruff format --diff setup.py src/deltachat examples/ tests/
ruff check src/deltachat tests/ examples/
rst-lint --encoding 'utf-8' README.rst
rst-lint README.rst
[testenv:mypy]
deps =

View File

@@ -1 +1 @@
2025-10-17
2025-11-23

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

View File

@@ -3,8 +3,12 @@
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context as _, Result, bail, ensure};
use async_channel::{self, Receiver, Sender};
use futures::FutureExt as _;
use futures_lite::FutureExt as _;
use serde::{Deserialize, Serialize};
use tokio::fs;
use tokio::io::AsyncWriteExt;
@@ -18,7 +22,7 @@ use tokio::time::{Duration, sleep};
use crate::context::{Context, ContextBuilder};
use crate::events::{Event, EventEmitter, EventType, Events};
use crate::log::{info, warn};
use crate::log::warn;
use crate::push::PushSubscriber;
use crate::stock_str::StockStrings;
@@ -41,6 +45,13 @@ pub struct Accounts {
/// Push notification subscriber shared between accounts.
push_subscriber: PushSubscriber,
/// Channel sender to cancel ongoing background_fetch().
///
/// If background_fetch() is not running, this is `None`.
/// New background_fetch() should not be started if this
/// contains `Some`.
background_fetch_interrupt_sender: Arc<parking_lot::Mutex<Option<Sender<()>>>>,
}
impl Accounts {
@@ -96,6 +107,7 @@ impl Accounts {
events,
stockstrings,
push_subscriber,
background_fetch_interrupt_sender: Default::default(),
})
}
@@ -352,6 +364,11 @@ impl Accounts {
///
/// This is an auxiliary function and not part of public API.
/// Use [Accounts::background_fetch] instead.
///
/// This function is cancellation-safe.
/// It is intended to be cancellable,
/// either because of the timeout or because background
/// fetch was explicitly cancelled.
async fn background_fetch_no_timeout(accounts: Vec<Context>, events: Events) {
let n_accounts = accounts.len();
events.emit(Event {
@@ -378,14 +395,33 @@ impl Accounts {
}
/// Auxiliary function for [Accounts::background_fetch].
///
/// Runs `background_fetch` until it finishes
/// or until the timeout.
///
/// Produces `AccountsBackgroundFetchDone` event in every case
/// and clears [`Self::background_fetch_interrupt_sender`]
/// so a new background fetch can be started.
///
/// This function is not cancellation-safe.
/// Cancelling it before it returns may result
/// in not being able to run any new background fetch
/// if interrupt sender was not cleared.
async fn background_fetch_with_timeout(
accounts: Vec<Context>,
events: Events,
timeout: std::time::Duration,
interrupt_sender: Arc<parking_lot::Mutex<Option<Sender<()>>>>,
interrupt_receiver: Option<Receiver<()>>,
) {
let Some(interrupt_receiver) = interrupt_receiver else {
// Nothing to do if we got no interrupt receiver.
return;
};
if let Err(_err) = tokio::time::timeout(
timeout,
Self::background_fetch_no_timeout(accounts, events.clone()),
Self::background_fetch_no_timeout(accounts, events.clone())
.race(interrupt_receiver.recv().map(|_| ())),
)
.await
{
@@ -398,10 +434,16 @@ impl Accounts {
id: 0,
typ: EventType::AccountsBackgroundFetchDone,
});
(*interrupt_sender.lock()) = None;
}
/// Performs a background fetch for all accounts in parallel with a timeout.
///
/// Ongoing background fetch can also be cancelled manually
/// by calling `stop_background_fetch()`, in which case it will
/// return immediately even before the timeout expiration
/// or finishing fetching.
///
/// The `AccountsBackgroundFetchDone` event is emitted at the end,
/// process all events until you get this one and you can safely return to the background
/// without forgetting to create notifications caused by timing race conditions.
@@ -414,7 +456,39 @@ impl Accounts {
) -> impl Future<Output = ()> + use<> {
let accounts: Vec<Context> = self.accounts.values().cloned().collect();
let events = self.events.clone();
Self::background_fetch_with_timeout(accounts, events, timeout)
let (sender, receiver) = async_channel::bounded(1);
let receiver = {
let mut lock = self.background_fetch_interrupt_sender.lock();
if (*lock).is_some() {
// Another background_fetch() is already running,
// return immeidately.
None
} else {
*lock = Some(sender);
Some(receiver)
}
};
Self::background_fetch_with_timeout(
accounts,
events,
timeout,
self.background_fetch_interrupt_sender.clone(),
receiver,
)
}
/// Interrupts ongoing background_fetch() call,
/// making it return early.
///
/// This method allows to cancel background_fetch() early,
/// e.g. on Android, when `Service.onTimeout` is called.
///
/// If there is no ongoing background_fetch(), does nothing.
pub fn stop_background_fetch(&self) {
let mut lock = self.background_fetch_interrupt_sender.lock();
if let Some(sender) = lock.take() {
sender.try_send(()).ok();
}
}
/// Emits a single event.
@@ -604,13 +678,12 @@ impl Config {
// Convert them to relative paths.
let mut modified = false;
for account in &mut config.inner.accounts {
if account.dir.is_absolute() {
if let Some(old_path_parent) = account.dir.parent() {
if let Ok(new_path) = account.dir.strip_prefix(old_path_parent) {
account.dir = new_path.to_path_buf();
modified = true;
}
}
if account.dir.is_absolute()
&& let Some(old_path_parent) = account.dir.parent()
&& let Ok(new_path) = account.dir.strip_prefix(old_path_parent)
{
account.dir = new_path.to_path_buf();
modified = true;
}
}
if modified && writable {

View File

@@ -61,9 +61,11 @@ impl fmt::Display for Aheader {
if self.prefer_encrypt == EncryptPreference::Mutual {
write!(fmt, " prefer-encrypt=mutual;")?;
}
if self.verified {
write!(fmt, " _verified=1;")?;
}
// TODO After we reset all existing verifications,
// we want to start sending the _verified attribute
// if self.verified {
// write!(fmt, " _verified=1;")?;
// }
// adds a whitespace every 78 characters, this allows
// email crate to wrap the lines according to RFC 5322
@@ -282,8 +284,9 @@ mod tests {
.contains("test@example.com")
);
// We don't send the _verified header yet:
assert!(
format!(
!format!(
"{}",
Aheader {
addr: "test@example.com".to_string(),

View File

@@ -20,7 +20,7 @@ use crate::config::Config;
use crate::constants::{self, MediaQuality};
use crate::context::Context;
use crate::events::EventType;
use crate::log::{LogExt, error, info, warn};
use crate::log::{LogExt, warn};
use crate::message::Viewtype;
use crate::tools::sanitize_filename;
@@ -234,8 +234,13 @@ impl<'a> BlobObject<'a> {
/// If `data` represents an image of known format, this adds the corresponding extension.
///
/// Even though this function is not async, it's OK to call it from an async context.
pub(crate) fn store_from_base64(context: &Context, data: &str) -> Result<String> {
let buf = base64::engine::general_purpose::STANDARD.decode(data)?;
///
/// Returns an error if there is an I/O problem,
/// but in case of a failure to decode base64 returns `Ok(None)`.
pub(crate) fn store_from_base64(context: &Context, data: &str) -> Result<Option<String>> {
let Ok(buf) = base64::engine::general_purpose::STANDARD.decode(data) else {
return Ok(None);
};
let name = if let Ok(format) = image::guess_format(&buf) {
if let Some(ext) = format.extensions_str().first() {
format!("file.{ext}")
@@ -246,7 +251,7 @@ impl<'a> BlobObject<'a> {
String::new()
};
let blob = BlobObject::create_and_deduplicate_from_bytes(context, &buf, &name)?;
Ok(blob.as_name().to_string())
Ok(Some(blob.as_name().to_string()))
}
/// Recode image to avatar size.

View File

@@ -9,7 +9,7 @@ use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::log::{info, warn};
use crate::log::warn;
use crate::message::{self, Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::net::dns::lookup_host_with_cache;

View File

@@ -32,7 +32,7 @@ use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers};
use crate::events::EventType;
use crate::key::self_fingerprint;
use crate::location;
use crate::log::{LogExt, error, info, warn};
use crate::log::{LogExt, warn};
use crate::logged_debug_assert;
use crate::message::{self, Message, MessageState, MsgId, Viewtype};
use crate::mimefactory::MimeFactory;
@@ -43,13 +43,15 @@ use crate::smtp::send_msg_to_smtp;
use crate::stock_str;
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::{
IsNoneOrEmpty, SystemTime, buf_compress, create_id, create_outgoing_rfc724_mid,
create_smeared_timestamp, create_smeared_timestamps, get_abs_path, gm2local_offset,
smeared_time, time, truncate_msg_text,
IsNoneOrEmpty, SystemTime, buf_compress, create_broadcast_secret, create_id,
create_outgoing_rfc724_mid, create_smeared_timestamp, create_smeared_timestamps, get_abs_path,
gm2local_offset, smeared_time, time, truncate_msg_text,
};
use crate::webxdc::StatusUpdateSerial;
use crate::{chatlist_events, imap};
pub(crate) const PARAM_BROADCAST_SECRET: Param = Param::Arg3;
/// An chat item, such as a message or a marker.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum ChatItem {
@@ -299,12 +301,12 @@ impl ChatId {
let chat = Chat::load_from_db(context, chat_id).await?;
if chat.is_encrypted(context).await? {
chat_id.add_encrypted_msg(context, timestamp).await?;
chat_id.add_e2ee_notice(context, timestamp).await?;
}
info!(
context,
"Created group/mailinglist '{}' grpid={} as {}, blocked={}.",
"Created group/broadcast '{}' grpid={} as {}, blocked={}.",
&grpname,
grpid,
chat_id,
@@ -460,15 +462,15 @@ impl ChatId {
}
/// Adds message "Messages are end-to-end encrypted".
async fn add_encrypted_msg(self, context: &Context, timestamp_sort: i64) -> Result<()> {
pub(crate) async fn add_e2ee_notice(self, context: &Context, timestamp: i64) -> Result<()> {
let text = stock_str::messages_e2e_encrypted(context).await;
add_info_msg_with_cmd(
context,
self,
&text,
SystemMessage::ChatE2ee,
timestamp_sort,
None,
Some(timestamp),
timestamp,
None,
None,
None,
@@ -734,16 +736,15 @@ impl ChatId {
}
}
_ => {
if msg.viewtype == Viewtype::File {
if let Some((better_type, _)) = message::guess_msgtype_from_suffix(msg)
if msg.viewtype == Viewtype::File
&& let Some((better_type, _)) = message::guess_msgtype_from_suffix(msg)
// We do not do an automatic conversion to other viewtypes here so that
// users can send images as "files" to preserve the original quality
// (usually we compress images). The remaining conversions are done by
// `prepare_msg_blob()` later.
.filter(|&(vt, _)| vt == Viewtype::Webxdc || vt == Viewtype::Vcard)
{
msg.viewtype = better_type;
}
{
msg.viewtype = better_type;
}
if msg.viewtype == Viewtype::Vcard {
let blob = msg
@@ -761,13 +762,13 @@ impl ChatId {
msg.chat_id = self;
// if possible, replace existing draft and keep id
if !msg.id.is_special() {
if let Some(old_draft) = self.get_draft(context).await? {
if old_draft.id == msg.id
&& old_draft.chat_id == self
&& old_draft.state == MessageState::OutDraft
{
let affected_rows = context
if !msg.id.is_special()
&& let Some(old_draft) = self.get_draft(context).await?
&& old_draft.id == msg.id
&& old_draft.chat_id == self
&& old_draft.state == MessageState::OutDraft
{
let affected_rows = context
.sql.execute(
"UPDATE msgs
SET timestamp=?1,type=?2,txt=?3,txt_normalized=?4,param=?5,mime_in_reply_to=?6
@@ -787,9 +788,7 @@ impl ChatId {
msg.id,
),
).await?;
return Ok(affected_rows > 0);
}
}
return Ok(affected_rows > 0);
}
let row_id = context
@@ -987,11 +986,11 @@ impl ChatId {
let mut res = Vec::new();
let now = time();
for (chat_id, metric) in chats_with_metrics {
if let Some(chat_timestamp) = chat_id.get_timestamp(context).await? {
if now > chat_timestamp + 42 * 24 * 3600 {
// Chat was inactive for 42 days, skip.
continue;
}
if let Some(chat_timestamp) = chat_id.get_timestamp(context).await?
&& now > chat_timestamp + 42 * 24 * 3600
{
// Chat was inactive for 42 days, skip.
continue;
}
if metric < 0.1 {
@@ -1246,10 +1245,10 @@ impl ChatId {
None
};
if let Some(last_msg_time) = last_msg_time {
if last_msg_time > sort_timestamp {
sort_timestamp = last_msg_time;
}
if let Some(last_msg_time) = last_msg_time
&& last_msg_time > sort_timestamp
{
sort_timestamp = last_msg_time;
}
Ok(sort_timestamp)
@@ -1370,10 +1369,10 @@ impl Chat {
let mut chat_name = "Err [Name not found]".to_owned();
match get_chat_contacts(context, chat.id).await {
Ok(contacts) => {
if let Some(contact_id) = contacts.first() {
if let Ok(contact) = Contact::get_by_id(context, *contact_id).await {
contact.get_display_name().clone_into(&mut chat_name);
}
if let Some(contact_id) = contacts.first()
&& let Ok(contact) = Contact::get_by_id(context, *contact_id).await
{
contact.get_display_name().clone_into(&mut chat_name);
}
}
Err(err) => {
@@ -1489,8 +1488,9 @@ impl Chat {
pub async fn is_self_in_chat(&self, context: &Context) -> Result<bool> {
match self.typ {
Chattype::Single | Chattype::OutBroadcast | Chattype::Mailinglist => Ok(true),
Chattype::Group => is_contact_in_chat(context, self.id, ContactId::SELF).await,
Chattype::InBroadcast => Ok(false),
Chattype::Group | Chattype::InBroadcast => {
is_contact_in_chat(context, self.id, ContactId::SELF).await
}
}
}
@@ -1569,10 +1569,10 @@ impl Chat {
if self.typ == Chattype::Single {
let contacts = get_chat_contacts(context, self.id).await?;
if let Some(contact_id) = contacts.first() {
if let Ok(contact) = Contact::get_by_id(context, *contact_id).await {
color = contact.get_color();
}
if let Some(contact_id) = contacts.first()
&& let Ok(contact) = Contact::get_by_id(context, *contact_id).await
{
color = contact.get_color();
}
} else if !self.grpid.is_empty() {
color = str_to_color(&self.grpid);
@@ -1636,36 +1636,37 @@ impl Chat {
/// Returns true if the chat is encrypted.
pub async fn is_encrypted(&self, context: &Context) -> Result<bool> {
let is_encrypted = match self.typ {
Chattype::Single => {
match context
.sql
.query_row_optional(
"SELECT cc.contact_id, c.fingerprint<>''
let is_encrypted = self.is_self_talk()
|| match self.typ {
Chattype::Single => {
match context
.sql
.query_row_optional(
"SELECT cc.contact_id, c.fingerprint<>''
FROM chats_contacts cc LEFT JOIN contacts c
ON c.id=cc.contact_id
WHERE cc.chat_id=?
",
(self.id,),
|row| {
let id: ContactId = row.get(0)?;
let is_key: bool = row.get(1)?;
Ok((id, is_key))
},
)
.await?
{
Some((id, is_key)) => is_key || id == ContactId::DEVICE,
None => true,
(self.id,),
|row| {
let id: ContactId = row.get(0)?;
let is_key: bool = row.get(1)?;
Ok((id, is_key))
},
)
.await?
{
Some((id, is_key)) => is_key || id == ContactId::DEVICE,
None => true,
}
}
}
Chattype::Group => {
// Do not encrypt ad-hoc groups.
!self.grpid.is_empty()
}
Chattype::Mailinglist => false,
Chattype::OutBroadcast | Chattype::InBroadcast => true,
};
Chattype::Group => {
// Do not encrypt ad-hoc groups.
!self.grpid.is_empty()
}
Chattype::Mailinglist => false,
Chattype::OutBroadcast | Chattype::InBroadcast => true,
};
Ok(is_encrypted)
}
@@ -1833,8 +1834,8 @@ impl Chat {
}
// add independent location to database
if msg.param.exists(Param::SetLatitude) {
if let Ok(row_id) = context
if msg.param.exists(Param::SetLatitude)
&& let Ok(row_id) = context
.sql
.insert(
"INSERT INTO locations \
@@ -1849,9 +1850,8 @@ impl Chat {
),
)
.await
{
location_id = row_id;
}
{
location_id = row_id;
}
let ephemeral_timer = if msg.param.get_cmd() == SystemMessage::EphemeralTimerChanged {
@@ -2029,7 +2029,10 @@ impl Chat {
ON c.id=cc.contact_id \
WHERE cc.chat_id=? AND cc.add_timestamp >= cc.remove_timestamp",
(self.id,),
|row| row.get::<_, String>(0),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)
.await?;
self.sync(context, SyncAction::SetContacts(addrs)).await?;
@@ -2418,7 +2421,7 @@ impl ChatIdBlocked {
&& !chat.param.exists(Param::Devicetalk)
&& !chat.param.exists(Param::Selftalk)
{
chat_id.add_encrypted_msg(context, smeared_time).await?;
chat_id.add_e2ee_notice(context, smeared_time).await?;
}
Ok(Self {
@@ -2486,18 +2489,18 @@ async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
msg.param.set(Param::File, blob.as_name());
}
if !msg.param.exists(Param::MimeType) {
if let Some((viewtype, mime)) = message::guess_msgtype_from_suffix(msg) {
// If we unexpectedly didn't recognize the file as image, don't send it as such,
// either the format is unsupported or the image is corrupted.
let mime = match viewtype != Viewtype::Image
|| matches!(msg.viewtype, Viewtype::Image | Viewtype::Sticker)
{
true => mime,
false => "application/octet-stream",
};
msg.param.set(Param::MimeType, mime);
}
if !msg.param.exists(Param::MimeType)
&& let Some((viewtype, mime)) = message::guess_msgtype_from_suffix(msg)
{
// If we unexpectedly didn't recognize the file as image, don't send it as such,
// either the format is unsupported or the image is corrupted.
let mime = match viewtype != Viewtype::Image
|| matches!(msg.viewtype, Viewtype::Image | Viewtype::Sticker)
{
true => mime,
false => "application/octet-stream",
};
msg.param.set(Param::MimeType, mime);
}
msg.try_calc_and_set_dimensions(context).await?;
@@ -2568,8 +2571,9 @@ pub async fn is_contact_in_chat(
) -> Result<bool> {
// this function works for group and for normal chats, however, it is more useful
// for group chats.
// ContactId::SELF may be used to check, if the user itself is in a group
// chat (ContactId::SELF is not added to normal chats)
// ContactId::SELF may be used to check whether oneself
// is in a group or incoming broadcast chat
// (ContactId::SELF is not added to 1:1 chats or outgoing broadcast channels)
let exists = context
.sql
@@ -2659,8 +2663,12 @@ async fn prepare_send_msg(
// Allow to send "Member removed" messages so we can leave the group/broadcast.
// Necessary checks should be made anyway before removing contact
// from the chat.
CantSendReason::NotAMember | CantSendReason::InBroadcast => {
msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup
CantSendReason::NotAMember => msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup,
CantSendReason::InBroadcast => {
matches!(
msg.param.get_cmd(),
SystemMessage::MemberRemovedFromGroup | SystemMessage::SecurejoinMessage
)
}
CantSendReason::MissingKey => msg
.param
@@ -2676,15 +2684,15 @@ async fn prepare_send_msg(
// This is meant as a last line of defence, the UI should check that before as well.
// (We allow Chattype::Single in general for "Reply Privately";
// checking for exact contact_id will produce false positives when ppl just left the group)
if chat.typ != Chattype::Single && !context.get_config_bool(Config::Bot).await? {
if let Some(quoted_message) = msg.quoted_message(context).await? {
if quoted_message.chat_id != chat_id {
bail!(
"Quote of message from {} cannot be sent to {chat_id}",
quoted_message.chat_id
);
}
}
if chat.typ != Chattype::Single
&& !context.get_config_bool(Config::Bot).await?
&& let Some(quoted_message) = msg.quoted_message(context).await?
&& quoted_message.chat_id != chat_id
{
bail!(
"Quote of message from {} cannot be sent to {chat_id}",
quoted_message.chat_id
);
}
// check current MessageState for drafts (to keep msg_id) ...
@@ -2814,16 +2822,15 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
let now = smeared_time(context);
if rendered_msg.last_added_location_id.is_some() {
if let Err(err) = location::set_kml_sent_timestamp(context, msg.chat_id, now).await {
error!(context, "Failed to set kml sent_timestamp: {err:#}.");
}
if rendered_msg.last_added_location_id.is_some()
&& let Err(err) = location::set_kml_sent_timestamp(context, msg.chat_id, now).await
{
error!(context, "Failed to set kml sent_timestamp: {err:#}.");
}
if attach_selfavatar {
if let Err(err) = msg.chat_id.set_selfavatar_timestamp(context, now).await {
error!(context, "Failed to set selfavatar timestamp: {err:#}.");
}
if attach_selfavatar && let Err(err) = msg.chat_id.set_selfavatar_timestamp(context, now).await
{
error!(context, "Failed to set selfavatar timestamp: {err:#}.");
}
if rendered_msg.is_encrypted {
@@ -3038,7 +3045,7 @@ pub async fn get_chat_msgs_ex(
))
}
};
let process_rows = |rows: rusqlite::MappedRows<_>| {
let process_rows = |rows: rusqlite::AndThenRows<_>| {
// It is faster to sort here rather than
// let sqlite execute an ORDER BY clause.
let mut sorted_rows = Vec::new();
@@ -3120,7 +3127,10 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()>
LEFT JOIN chats c ON m.chat_id=c.id
WHERE m.state=10 AND m.hidden=0 AND m.chat_id>9 AND c.archived=1",
(),
|row| row.get::<_, ChatId>(0),
|row| {
let chat_id: ChatId = row.get(0)?;
Ok(chat_id)
},
)
.await?;
if chat_ids_in_archive.is_empty() {
@@ -3298,7 +3308,10 @@ pub async fn get_chat_media(
DC_CHAT_ID_TRASH,
Viewtype::Webxdc,
),
|row| row.get::<_, MsgId>(0),
|row| {
let msg_id: MsgId = row.get(0)?;
Ok(msg_id)
},
)
.await?
} else {
@@ -3328,7 +3341,10 @@ pub async fn get_chat_media(
msg_type
},
),
|row| row.get::<_, MsgId>(0),
|row| {
let msg_id: MsgId = row.get(0)?;
Ok(msg_id)
},
)
.await?
};
@@ -3349,7 +3365,10 @@ pub async fn get_chat_contacts(context: &Context, chat_id: ChatId) -> Result<Vec
WHERE cc.chat_id=? AND cc.add_timestamp >= cc.remove_timestamp
ORDER BY c.id=1, c.last_seen DESC, c.id DESC;",
(chat_id,),
|row| row.get::<_, ContactId>(0),
|row| {
let contact_id: ContactId = row.get(0)?;
Ok(contact_id)
},
)
.await
}
@@ -3371,7 +3390,10 @@ pub async fn get_past_chat_contacts(context: &Context, chat_id: ChatId) -> Resul
AND ? < cc.remove_timestamp
ORDER BY c.id=1, cc.remove_timestamp DESC, c.id DESC",
(chat_id, now.saturating_sub(60 * 24 * 3600)),
|row| row.get::<_, ContactId>(0),
|row| {
let contact_id: ContactId = row.get(0)?;
Ok(contact_id)
},
)
.await
}
@@ -3426,14 +3448,20 @@ pub(crate) async fn create_group_ex(
if !grpid.is_empty() {
// Add "Messages are end-to-end encrypted." message.
chat_id.add_encrypted_msg(context, timestamp).await?;
chat_id.add_e2ee_notice(context, timestamp).await?;
}
if !context.get_config_bool(Config::Bot).await?
&& !context.get_config_bool(Config::SkipStartMessages).await?
{
let text = stock_str::new_group_send_first_message(context).await;
add_info_msg(context, chat_id, &text, create_smeared_timestamp(context)).await?;
let text = if !grpid.is_empty() {
// Add "Others will only see this group after you sent a first message." message.
stock_str::new_group_send_first_message(context).await
} else {
// Add "Messages in this chat use classic email and are not encrypted." message.
stock_str::chat_unencrypted_explanation(context).await
};
add_info_msg(context, chat_id, &text).await?;
}
if let (true, true) = (sync.into(), !grpid.is_empty()) {
let id = SyncId::Grpid(grpid);
@@ -3443,7 +3471,7 @@ pub(crate) async fn create_group_ex(
Ok(chat_id)
}
/// Create a new **broadcast channel**
/// Create a new, outgoing **broadcast channel**
/// (called "Channel" in the UI).
///
/// Broadcast channels are similar to groups on the sending device,
@@ -3460,60 +3488,99 @@ pub(crate) async fn create_group_ex(
/// Returns the created chat's id.
pub async fn create_broadcast(context: &Context, chat_name: String) -> Result<ChatId> {
let grpid = create_id();
create_broadcast_ex(context, Sync, grpid, chat_name).await
let secret = create_broadcast_secret();
create_out_broadcast_ex(context, Sync, grpid, chat_name, secret).await
}
pub(crate) async fn create_broadcast_ex(
const SQL_INSERT_BROADCAST_SECRET: &str =
"INSERT INTO broadcast_secrets (chat_id, secret) VALUES (?, ?)
ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.secret";
pub(crate) async fn create_out_broadcast_ex(
context: &Context,
sync: sync::Sync,
grpid: String,
chat_name: String,
secret: String,
) -> Result<ChatId> {
let row_id = {
let chat_name = &chat_name;
let grpid = &grpid;
let trans_fn = |t: &mut rusqlite::Transaction| {
let cnt = t.execute("UPDATE chats SET name=? WHERE grpid=?", (chat_name, grpid))?;
ensure!(cnt <= 1, "{cnt} chats exist with grpid {grpid}");
if cnt == 1 {
return Ok(t.query_row(
"SELECT id FROM chats WHERE grpid=? AND type=?",
(grpid, Chattype::OutBroadcast),
|row| {
let id: isize = row.get(0)?;
Ok(id)
},
)?);
}
t.execute(
"INSERT INTO chats \
(type, name, grpid, param, created_timestamp) \
VALUES(?, ?, ?, \'U=1\', ?);",
(
Chattype::OutBroadcast,
&chat_name,
&grpid,
create_smeared_timestamp(context),
),
)?;
Ok(t.last_insert_rowid().try_into()?)
};
context.sql.transaction(trans_fn).await?
let chat_name = sanitize_single_line(&chat_name);
if chat_name.is_empty() {
bail!("Invalid broadcast channel name: {chat_name}.");
}
let timestamp = create_smeared_timestamp(context);
let trans_fn = |t: &mut rusqlite::Transaction| -> Result<ChatId> {
let cnt: u32 = t.query_row(
"SELECT COUNT(*) FROM chats WHERE grpid=?",
(&grpid,),
|row| row.get(0),
)?;
ensure!(cnt == 0, "{cnt} chats exist with grpid {grpid}");
t.execute(
"INSERT INTO chats
(type, name, grpid, created_timestamp)
VALUES(?, ?, ?, ?);",
(Chattype::OutBroadcast, &chat_name, &grpid, timestamp),
)?;
let chat_id = ChatId::new(t.last_insert_rowid().try_into()?);
t.execute(SQL_INSERT_BROADCAST_SECRET, (chat_id, &secret))?;
Ok(chat_id)
};
let chat_id = ChatId::new(u32::try_from(row_id)?);
let chat_id = context.sql.transaction(trans_fn).await?;
chat_id.add_e2ee_notice(context, timestamp).await?;
context.emit_msgs_changed_without_ids();
chatlist_events::emit_chatlist_changed(context);
chatlist_events::emit_chatlist_item_changed(context, chat_id);
if sync.into() {
let id = SyncId::Grpid(grpid);
let action = SyncAction::CreateBroadcast(chat_name);
let action = SyncAction::CreateOutBroadcast { chat_name, secret };
self::sync(context, id, action).await.log_err(context).ok();
}
Ok(chat_id)
}
pub(crate) async fn load_broadcast_secret(
context: &Context,
chat_id: ChatId,
) -> Result<Option<String>> {
context
.sql
.query_get_value(
"SELECT secret FROM broadcast_secrets WHERE chat_id=?",
(chat_id,),
)
.await
}
pub(crate) async fn save_broadcast_secret(
context: &Context,
chat_id: ChatId,
secret: &str,
) -> Result<()> {
info!(context, "Saving broadcast secret for chat {chat_id}");
context
.sql
.execute(SQL_INSERT_BROADCAST_SECRET, (chat_id, secret))
.await?;
Ok(())
}
pub(crate) async fn delete_broadcast_secret(context: &Context, chat_id: ChatId) -> Result<()> {
info!(context, "Removing broadcast secret for chat {chat_id}");
context
.sql
.execute("DELETE FROM broadcast_secrets WHERE chat_id=?", (chat_id,))
.await?;
Ok(())
}
/// Set chat contacts in the `chats_contacts` table.
pub(crate) async fn update_chat_contacts_table(
context: &Context,
@@ -3601,6 +3668,30 @@ pub(crate) async fn remove_from_chat_contacts_table(
Ok(())
}
/// Removes a contact from the chat
/// without leaving a trace.
///
/// Note that if we call this function,
/// and then receive a message from another device
/// that doesn't know that this this member was removed
/// then the group membership algorithm will wrongly re-add this member.
pub(crate) async fn remove_from_chat_contacts_table_without_trace(
context: &Context,
chat_id: ChatId,
contact_id: ContactId,
) -> Result<()> {
context
.sql
.execute(
"DELETE FROM chats_contacts
WHERE chat_id=? AND contact_id=?",
(chat_id, contact_id),
)
.await?;
Ok(())
}
/// Adds a contact to the chat.
/// If the group is promoted, also sends out a system message to all group members
pub async fn add_contact_to_chat(
@@ -3628,14 +3719,13 @@ pub(crate) async fn add_contact_to_chat_ex(
// this also makes sure, no contacts are added to special or normal chats
let mut chat = Chat::load_from_db(context, chat_id).await?;
ensure!(
chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast,
"{chat_id} is not a group/broadcast where one can add members"
chat.typ == Chattype::Group || (from_handshake && chat.typ == Chattype::OutBroadcast),
"{chat_id} is not a group where one can add members",
);
ensure!(
Contact::real_exists_by_id(context, contact_id).await? || contact_id == ContactId::SELF,
"invalid contact_id {contact_id} for adding to group"
);
ensure!(!chat.is_mailing_list(), "Mailing lists can't be changed");
ensure!(
chat.typ != Chattype::OutBroadcast || contact_id != ContactId::SELF,
"Cannot add SELF to broadcast channel."
@@ -3649,7 +3739,11 @@ pub(crate) async fn add_contact_to_chat_ex(
context.emit_event(EventType::ErrorSelfNotInGroup(
"Cannot add contact to group; self not in group.".into(),
));
bail!("can not add contact because the account is not part of the group/broadcast");
warn!(
context,
"Can not add contact because the account is not part of the group/broadcast."
);
return Ok(false);
}
let sync_qr_code_tokens;
@@ -3679,21 +3773,35 @@ pub(crate) async fn add_contact_to_chat_ex(
}
} else {
// else continue and send status mail
if is_contact_in_chat(context, chat_id, contact_id).await? {
return Ok(false);
}
add_to_chat_contacts_table(context, time(), chat_id, &[contact_id]).await?;
}
if chat.typ == Chattype::Group && chat.is_promoted() {
if chat.is_promoted() {
msg.viewtype = Viewtype::Text;
let contact_addr = contact.get_addr().to_lowercase();
msg.text = stock_str::msg_add_member_local(context, contact.id, ContactId::SELF).await;
let added_by = if from_handshake && chat.typ == Chattype::OutBroadcast {
// The contact was added via a QR code rather than explicit user action,
// so it could be confusing to say 'You added member Alice'.
// And in a broadcast, SELF is the only one who can add members,
// so, no information is lost by just writing 'Member Alice added' instead.
ContactId::UNDEFINED
} else {
ContactId::SELF
};
msg.text = stock_str::msg_add_member_local(context, contact.id, added_by).await;
msg.param.set_cmd(SystemMessage::MemberAddedToGroup);
msg.param.set(Param::Arg, contact_addr);
msg.param.set_int(Param::Arg2, from_handshake.into());
let fingerprint = contact.fingerprint().map(|f| f.hex());
msg.param.set_optional(Param::Arg4, fingerprint);
msg.param
.set_int(Param::ContactAddedRemoved, contact.id.to_u32() as i32);
if chat.typ == Chattype::OutBroadcast {
let secret = load_broadcast_secret(context, chat_id)
.await?
.context("Failed to find broadcast shared secret")?;
msg.param.set(PARAM_BROADCAST_SECRET, secret);
}
send_msg(context, chat_id, &mut msg).await?;
sync = Nosync;
@@ -3734,11 +3842,13 @@ pub(crate) async fn shall_attach_selfavatar(context: &Context, chat_id: ChatId)
LEFT JOIN contacts c ON c.id=cc.contact_id
WHERE cc.chat_id=? AND cc.contact_id!=? AND cc.add_timestamp >= cc.remove_timestamp",
(chat_id, ContactId::SELF),
|row| Ok(row.get::<_, i64>(0)),
|row| {
let selfavatar_sent: i64 = row.get(0)?;
Ok(selfavatar_sent)
},
|rows| {
let mut needs_attach = false;
for row in rows {
let row = row?;
let selfavatar_sent = row?;
if selfavatar_sent < timestamp_some_days_ago {
needs_attach = true;
@@ -3847,7 +3957,18 @@ pub async fn remove_contact_from_chat(
);
let chat = Chat::load_from_db(context, chat_id).await?;
if chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast {
if chat.typ == Chattype::InBroadcast {
ensure!(
contact_id == ContactId::SELF,
"Cannot remove other member from incoming broadcast channel"
);
delete_broadcast_secret(context, chat_id).await?;
}
if matches!(
chat.typ,
Chattype::Group | Chattype::OutBroadcast | Chattype::InBroadcast
) {
if !chat.is_self_in_chat(context).await? {
let err_msg = format!(
"Cannot remove contact {contact_id} from chat {chat_id}: self not in group."
@@ -3860,28 +3981,28 @@ pub async fn remove_contact_from_chat(
if chat.is_promoted() {
remove_from_chat_contacts_table(context, chat_id, contact_id).await?;
} else {
context
.sql
.execute(
"DELETE FROM chats_contacts
WHERE chat_id=? AND contact_id=?",
(chat_id, contact_id),
)
.await?;
remove_from_chat_contacts_table_without_trace(context, chat_id, contact_id).await?;
}
// We do not return an error if the contact does not exist in the database.
// This allows to delete dangling references to deleted contacts
// in case of the database becoming inconsistent due to a bug.
if let Some(contact) = Contact::get_by_id_optional(context, contact_id).await? {
if chat.typ == Chattype::Group && chat.is_promoted() {
if chat.is_promoted() {
let addr = contact.get_addr();
let fingerprint = contact.fingerprint().map(|f| f.hex());
let res = send_member_removal_msg(context, &chat, contact_id, addr).await;
let res = send_member_removal_msg(
context,
&chat,
contact_id,
addr,
fingerprint.as_deref(),
)
.await;
if contact_id == ContactId::SELF {
res?;
set_group_explicitly_left(context, &chat.grpid).await?;
} else if let Err(e) = res {
warn!(
context,
@@ -3897,11 +4018,6 @@ pub async fn remove_contact_from_chat(
chat.sync_contacts(context).await.log_err(context).ok();
}
}
} else if chat.typ == Chattype::InBroadcast && contact_id == ContactId::SELF {
// For incoming broadcast channels, it's not possible to remove members,
// but it's possible to leave:
let self_addr = context.get_primary_self_addr().await?;
send_member_removal_msg(context, &chat, contact_id, &self_addr).await?;
} else {
bail!("Cannot remove members from non-group chats.");
}
@@ -3914,6 +4030,7 @@ async fn send_member_removal_msg(
chat: &Chat,
contact_id: ContactId,
addr: &str,
fingerprint: Option<&str>,
) -> Result<MsgId> {
let mut msg = Message::new(Viewtype::Text);
@@ -3929,31 +4046,13 @@ async fn send_member_removal_msg(
msg.param.set_cmd(SystemMessage::MemberRemovedFromGroup);
msg.param.set(Param::Arg, addr.to_lowercase());
msg.param.set_optional(Param::Arg4, fingerprint);
msg.param
.set(Param::ContactAddedRemoved, contact_id.to_u32());
send_msg(context, chat.id, &mut msg).await
}
async fn set_group_explicitly_left(context: &Context, grpid: &str) -> Result<()> {
if !is_group_explicitly_left(context, grpid).await? {
context
.sql
.execute("INSERT INTO leftgrps (grpid) VALUES(?);", (grpid,))
.await?;
}
Ok(())
}
pub(crate) async fn is_group_explicitly_left(context: &Context, grpid: &str) -> Result<bool> {
let exists = context
.sql
.exists("SELECT COUNT(*) FROM leftgrps WHERE grpid=?;", (grpid,))
.await?;
Ok(exists)
}
/// Sets group or mailing list chat name.
pub async fn set_chat_name(context: &Context, chat_id: ChatId, new_name: &str) -> Result<()> {
rename_ex(context, Sync, chat_id, new_name).await
@@ -4375,11 +4474,11 @@ pub async fn add_device_msg_with_importance(
let mut chat_id = ChatId::new(0);
let mut msg_id = MsgId::new_unset();
if let Some(label) = label {
if was_device_msg_ever_added(context, label).await? {
info!(context, "Device-message {label} already added.");
return Ok(msg_id);
}
if let Some(label) = label
&& was_device_msg_ever_added(context, label).await?
{
info!(context, "Device-message {label} already added.");
return Ok(msg_id);
}
if let Some(msg) = msg {
@@ -4391,10 +4490,10 @@ pub async fn add_device_msg_with_importance(
// makes sure, the added message is the last one,
// even if the date is wrong (useful esp. when warning about bad dates)
msg.timestamp_sort = timestamp_sent;
if let Some(last_msg_time) = chat_id.get_timestamp(context).await? {
if msg.timestamp_sort <= last_msg_time {
msg.timestamp_sort = last_msg_time + 1;
}
if let Some(last_msg_time) = chat_id.get_timestamp(context).await?
&& msg.timestamp_sort <= last_msg_time
{
msg.timestamp_sort = last_msg_time + 1;
}
prepare_msg_blob(context, msg).await?;
let state = MessageState::InFresh;
@@ -4513,9 +4612,11 @@ pub(crate) async fn add_info_msg_with_cmd(
chat_id: ChatId,
text: &str,
cmd: SystemMessage,
timestamp_sort: i64,
// Timestamp to show to the user (if this is None, `timestamp_sort` will be shown to the user)
timestamp_sent_rcvd: Option<i64>,
// Timestamp where in the chat the message will be sorted.
// If this is None, the message will be sorted to the bottom.
timestamp_sort: Option<i64>,
// Timestamp to show to the user
timestamp_sent_rcvd: i64,
parent: Option<&Message>,
from_id: Option<ContactId>,
added_removed_id: Option<ContactId>,
@@ -4531,6 +4632,22 @@ pub(crate) async fn add_info_msg_with_cmd(
param.set(Param::ContactAddedRemoved, contact_id.to_u32().to_string());
}
let timestamp_sort = if let Some(ts) = timestamp_sort {
ts
} else {
let sort_to_bottom = true;
let (received, incoming) = (false, false);
chat_id
.calc_sort_timestamp(
context,
smeared_time(context),
sort_to_bottom,
received,
incoming,
)
.await?
};
let row_id =
context.sql.insert(
"INSERT INTO msgs (chat_id,from_id,to_id,timestamp,timestamp_sent,timestamp_rcvd,type,state,txt,txt_normalized,rfc724_mid,ephemeral_timer,param,mime_in_reply_to)
@@ -4540,8 +4657,8 @@ pub(crate) async fn add_info_msg_with_cmd(
from_id.unwrap_or(ContactId::INFO),
ContactId::INFO,
timestamp_sort,
timestamp_sent_rcvd.unwrap_or(0),
timestamp_sent_rcvd.unwrap_or(0),
timestamp_sent_rcvd,
timestamp_sent_rcvd,
Viewtype::Text,
MessageState::InNoticed,
text,
@@ -4561,19 +4678,14 @@ pub(crate) async fn add_info_msg_with_cmd(
}
/// Adds info message with a given text and `timestamp` to the chat.
pub(crate) async fn add_info_msg(
context: &Context,
chat_id: ChatId,
text: &str,
timestamp: i64,
) -> Result<MsgId> {
pub(crate) async fn add_info_msg(context: &Context, chat_id: ChatId, text: &str) -> Result<MsgId> {
add_info_msg_with_cmd(
context,
chat_id,
text,
SystemMessage::Unknown,
timestamp,
None,
time(),
None,
None,
None,
@@ -4714,7 +4826,10 @@ pub(crate) enum SyncAction {
SetVisibility(ChatVisibility),
SetMuted(MuteDuration),
/// Create broadcast channel with the given name.
CreateBroadcast(String),
CreateOutBroadcast {
chat_name: String,
secret: String,
},
/// Create encrypted group chat with the given name.
CreateGroupEncrypted(String),
Rename(String),
@@ -4779,12 +4894,23 @@ impl Context {
.id
}
SyncId::Grpid(grpid) => {
if let SyncAction::CreateBroadcast(name) = action {
create_broadcast_ex(self, Nosync, grpid.clone(), name.clone()).await?;
return Ok(());
} else if let SyncAction::CreateGroupEncrypted(name) = action {
create_group_ex(self, Nosync, grpid.clone(), name).await?;
return Ok(());
match action {
SyncAction::CreateOutBroadcast { chat_name, secret } => {
create_out_broadcast_ex(
self,
Nosync,
grpid.to_string(),
chat_name.clone(),
secret.to_string(),
)
.await?;
return Ok(());
}
SyncAction::CreateGroupEncrypted(name) => {
create_group_ex(self, Nosync, grpid.clone(), name).await?;
return Ok(());
}
_ => {}
}
get_chat_id_by_grpid(self, grpid)
.await?
@@ -4806,7 +4932,8 @@ impl Context {
SyncAction::Accept => chat_id.accept_ex(self, Nosync).await,
SyncAction::SetVisibility(v) => chat_id.set_visibility_ex(self, Nosync, *v).await,
SyncAction::SetMuted(duration) => set_muted_ex(self, Nosync, chat_id, *duration).await,
SyncAction::CreateBroadcast(_) | SyncAction::CreateGroupEncrypted(..) => {
SyncAction::CreateOutBroadcast { .. } | SyncAction::CreateGroupEncrypted(..) => {
// Create action should have been handled above already.
Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request."))
}
SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await,

File diff suppressed because it is too large Load Diff

View File

@@ -107,11 +107,6 @@ impl Chatlist {
Ok((chat_id, msg_id))
};
let process_rows = |rows: rusqlite::MappedRows<_>| {
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
};
let skip_id = if flag_for_forwarding {
ChatId::lookup_by_contact(context, ContactId::DEVICE)
.await?
@@ -132,7 +127,7 @@ impl Chatlist {
// groups. Otherwise it would be hard to follow conversations.
let ids = if let Some(query_contact_id) = query_contact_id {
// show chats shared with a given contact
context.sql.query_map(
context.sql.query_map_vec(
"SELECT c.id, m.id
FROM chats c
LEFT JOIN msgs m
@@ -150,7 +145,6 @@ impl Chatlist {
ORDER BY c.archived=?3 DESC, IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft, query_contact_id, ChatVisibility::Pinned),
process_row,
process_rows,
).await?
} else if flag_archived_only {
// show archived chats
@@ -159,7 +153,7 @@ impl Chatlist {
// and adapting the number requires larger refactorings and seems not to be worth the effort)
context
.sql
.query_map(
.query_map_vec(
"SELECT c.id, m.id
FROM chats c
LEFT JOIN msgs m
@@ -177,7 +171,6 @@ impl Chatlist {
ORDER BY IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft,),
process_row,
process_rows,
)
.await?
} else if let Some(query) = query {
@@ -195,7 +188,7 @@ impl Chatlist {
let str_like_cmd = format!("%{query}%");
context
.sql
.query_map(
.query_map_vec(
"SELECT c.id, m.id
FROM chats c
LEFT JOIN msgs m
@@ -214,7 +207,6 @@ impl Chatlist {
ORDER BY IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft, skip_id, str_like_cmd, only_unread, MessageState::InFresh),
process_row,
process_rows,
)
.await?
} else {
@@ -229,7 +221,7 @@ impl Chatlist {
let msg_id: Option<MsgId> = row.get(3)?;
Ok((chat_id, typ, param, msg_id))
};
let process_rows = |rows: rusqlite::MappedRows<_>| {
let process_rows = |rows: rusqlite::AndThenRows<_>| {
rows.filter_map(|row: std::result::Result<(_, _, Params, _), _>| match row {
Ok((chat_id, typ, param, msg_id)) => {
if typ == Chattype::Mailinglist
@@ -243,7 +235,6 @@ impl Chatlist {
Err(e) => Some(Err(e)),
})
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
};
context.sql.query_map(
"SELECT c.id, c.type, c.param, m.id
@@ -272,7 +263,7 @@ impl Chatlist {
).await?
} else {
// show normal chatlist
context.sql.query_map(
context.sql.query_map_vec(
"SELECT c.id, m.id
FROM chats c
LEFT JOIN msgs m
@@ -290,7 +281,6 @@ impl Chatlist {
ORDER BY c.id=0 DESC, c.archived=? DESC, IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft, skip_id, ChatVisibility::Archived, ChatVisibility::Pinned),
process_row,
process_rows,
).await?
};
if !flag_no_specials && get_archived_cnt(context).await? > 0 {

View File

@@ -16,12 +16,12 @@ use crate::blob::BlobObject;
use crate::configure::EnteredLoginParam;
use crate::context::Context;
use crate::events::EventType;
use crate::log::{LogExt, info};
use crate::login_param::ConfiguredLoginParam;
use crate::log::LogExt;
use crate::mimefactory::RECOMMENDED_FILE_SIZE;
use crate::provider::{Provider, get_provider_by_id};
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::get_abs_path;
use crate::transport::ConfiguredLoginParam;
use crate::{constants, stats};
/// The available configuration keys.
@@ -144,11 +144,11 @@ pub enum Config {
/// Send BCC copy to self.
///
/// Should be enabled for multidevice setups.
/// Default is 0 for chatmail accounts, 1 otherwise.
/// Should be enabled for multi-device setups.
///
/// This is automatically enabled when importing/exporting a backup,
/// setting up a second device, or receiving a sync message.
#[strum(props(default = "0"))]
BccSelf,
/// True if Message Delivery Notifications (read receipts) should
@@ -438,8 +438,19 @@ pub enum Config {
/// storing the same token multiple times on the server.
EncryptedDeviceToken,
/// Enables running test hooks, e.g. see `InnerContext::pre_encrypt_mime_hook`.
/// This way is better than conditional compilation, i.e. `#[cfg(test)]`, because tests not
/// using this still run unmodified code.
TestHooks,
/// Return an error from `receive_imf_inner()` for a fully downloaded message. For tests.
FailOnReceivingFullMsg,
/// Enable composing emails with Header Protection as defined in
/// <https://www.rfc-editor.org/rfc/rfc9788.html> "Header Protection for Cryptographically
/// Protected Email".
#[strum(props(default = "1"))]
StdHeaderProtectionComposing,
}
impl Config {
@@ -466,7 +477,10 @@ impl Config {
/// Whether the config option needs an IO scheduler restart to take effect.
pub(crate) fn needs_io_restart(&self) -> bool {
matches!(self, Config::MvboxMove | Config::OnlyFetchMvbox)
matches!(
self,
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ConfiguredAddr
)
}
}
@@ -512,10 +526,6 @@ impl Context {
// Default values
let val = match key {
Config::BccSelf => match Box::pin(self.is_chatmail()).await? {
false => Some("1".to_string()),
true => Some("0".to_string()),
},
Config::ConfiguredInboxFolder => Some("INBOX".to_string()),
Config::DeleteServerAfter => {
match !Box::pin(self.get_config_bool(Config::BccSelf)).await?
@@ -665,7 +675,7 @@ impl Context {
Config::Selfavatar if value.is_empty() => None,
Config::Selfavatar => {
config_value = BlobObject::store_from_base64(self, value)?;
Some(config_value.as_str())
config_value.as_deref()
}
_ => Some(value),
};
@@ -706,6 +716,16 @@ impl Context {
pub async fn set_config(&self, key: Config, value: Option<&str>) -> Result<()> {
Self::check_config(key, value)?;
let n_transports = self.count_transports().await?;
if n_transports > 1
&& matches!(
key,
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ShowEmails
)
{
bail!("Cannot reconfigure {key} when multiple transports are configured");
}
let _pause = match key.needs_io_restart() {
true => self.scheduler.pause(self).await?,
_ => Default::default(),
@@ -791,10 +811,11 @@ impl Context {
.await?;
}
Config::ConfiguredAddr => {
if self.is_configured().await? {
bail!("Cannot change ConfiguredAddr");
}
if let Some(addr) = value {
let Some(addr) = value else {
bail!("Cannot unset configured_addr");
};
if !self.is_configured().await? {
info!(
self,
"Creating a pseudo configured account which will not be able to send or receive messages. Only meant for tests!"
@@ -805,6 +826,36 @@ impl Context {
.save_to_transports_table(self, &EnteredLoginParam::default())
.await?;
}
self.sql
.transaction(|transaction| {
if transaction.query_row(
"SELECT COUNT(*) FROM transports WHERE addr=?",
(addr,),
|row| {
let res: i64 = row.get(0)?;
Ok(res)
},
)? == 0
{
bail!("Address does not belong to any transport.");
}
transaction.execute(
"UPDATE config SET value=? WHERE keyname='configured_addr'",
(addr,),
)?;
// Clean up SMTP and IMAP APPEND queue.
//
// The messages in the queue have a different
// From address so we cannot send them over
// the new SMTP transport.
transaction.execute("DELETE FROM smtp", ())?;
transaction.execute("DELETE FROM imap_send", ())?;
Ok(())
})
.await?;
self.sql.uncache_raw_config("configured_addr").await;
}
_ => {
self.sql.set_raw_config(key.as_ref(), value).await?;

View File

@@ -27,19 +27,21 @@ use crate::config::{self, Config};
use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
use crate::context::Context;
use crate::imap::Imap;
use crate::log::{LogExt, info, warn};
use crate::log::{LogExt, warn};
use crate::login_param::EnteredCertificateChecks;
pub use crate::login_param::EnteredLoginParam;
use crate::login_param::{
ConfiguredCertificateChecks, ConfiguredLoginParam, ConfiguredServerLoginParam,
ConnectionCandidate, EnteredCertificateChecks, ProxyConfig,
};
use crate::message::Message;
use crate::net::proxy::ProxyConfig;
use crate::oauth2::get_oauth2_addr;
use crate::provider::{Protocol, Provider, Socket, UsernamePattern};
use crate::qr::{login_param_from_account_qr, login_param_from_login_qr};
use crate::smtp::Smtp;
use crate::sync::Sync::*;
use crate::tools::time;
use crate::transport::{
ConfiguredCertificateChecks, ConfiguredLoginParam, ConfiguredServerLoginParam,
ConnectionCandidate,
};
use crate::{EventType, stock_str};
use crate::{chat, provider};
use deltachat_contact_tools::addr_cmp;
@@ -128,12 +130,6 @@ impl Context {
"cannot configure, database not opened."
);
param.addr = addr_normalize(&param.addr);
let old_addr = self.get_config(Config::ConfiguredAddr).await?;
if self.is_configured().await? && !addr_cmp(&old_addr.unwrap_or_default(), &param.addr) {
let error_msg = "Changing your email address is not supported right now. Check back in a few months!";
progress!(self, 0, Some(error_msg.to_string()));
bail!(error_msg);
}
let cancel_channel = self.alloc_ongoing().await?;
let res = self
@@ -192,34 +188,82 @@ impl Context {
pub async fn list_transports(&self) -> Result<Vec<EnteredLoginParam>> {
let transports = self
.sql
.query_map(
"SELECT entered_param FROM transports",
(),
|row| row.get::<_, String>(0),
|rows| {
rows.flatten()
.map(|s| Ok(serde_json::from_str(&s)?))
.collect::<Result<Vec<EnteredLoginParam>>>()
},
)
.query_map_vec("SELECT entered_param FROM transports", (), |row| {
let entered_param: String = row.get(0)?;
let transport: EnteredLoginParam = serde_json::from_str(&entered_param)?;
Ok(transport)
})
.await?;
Ok(transports)
}
/// Returns the number of configured transports.
pub async fn count_transports(&self) -> Result<usize> {
self.sql.count("SELECT COUNT(*) FROM transports", ()).await
}
/// Removes the transport with the specified email address
/// (i.e. [EnteredLoginParam::addr]).
#[expect(clippy::unused_async)]
pub async fn delete_transport(&self, _addr: &str) -> Result<()> {
bail!(
"Adding and removing additional transports is not supported yet. Check back in a few months!"
)
pub async fn delete_transport(&self, addr: &str) -> Result<()> {
self.sql
.transaction(|transaction| {
let primary_addr = transaction.query_row(
"SELECT value FROM config WHERE keyname='configured_addr'",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)?;
if primary_addr == addr {
bail!("Cannot delete primary transport");
}
let transport_id = transaction.query_row(
"DELETE FROM transports WHERE addr=? RETURNING id",
(addr,),
|row| {
let id: u32 = row.get(0)?;
Ok(id)
},
)?;
transaction.execute("DELETE FROM imap WHERE transport_id=?", (transport_id,))?;
transaction.execute(
"DELETE FROM imap_sync WHERE transport_id=?",
(transport_id,),
)?;
Ok(())
})
.await?;
Ok(())
}
async fn inner_configure(&self, param: &EnteredLoginParam) -> Result<()> {
info!(self, "Configure ...");
let old_addr = self.get_config(Config::ConfiguredAddr).await?;
if old_addr.is_some()
&& !self
.sql
.exists(
"SELECT COUNT(*) FROM transports WHERE addr=?",
(&param.addr,),
)
.await?
{
if self.get_config(Config::MvboxMove).await?.as_deref() != Some("0") {
bail!("Cannot use multi-transport with mvbox_move enabled.");
}
if self.get_config(Config::OnlyFetchMvbox).await?.as_deref() != Some("0") {
bail!("Cannot use multi-transport with only_fetch_mvbox enabled.");
}
if self.get_config(Config::ShowEmails).await?.as_deref() != Some("2") {
bail!("Cannot use multi-transport with disabled fetching of classic emails.");
}
}
let provider = configure(self, param).await?;
self.set_config_internal(Config::NotifyAboutWrongPw, Some("1"))
.await?;
@@ -261,19 +305,18 @@ async fn on_configure_completed(
}
}
if let Some(new_addr) = context.get_config(Config::ConfiguredAddr).await? {
if let Some(old_addr) = old_addr {
if !addr_cmp(&new_addr, &old_addr) {
let mut msg = Message::new_text(
stock_str::aeap_explanation_and_link(context, &old_addr, &new_addr).await,
);
chat::add_device_msg(context, None, Some(&mut msg))
.await
.context("Cannot add AEAP explanation")
.log_err(context)
.ok();
}
}
if let Some(new_addr) = context.get_config(Config::ConfiguredAddr).await?
&& let Some(old_addr) = old_addr
&& !addr_cmp(&new_addr, &old_addr)
{
let mut msg = Message::new_text(
stock_str::aeap_explanation_and_link(context, &old_addr, &new_addr).await,
);
chat::add_device_msg(context, None, Some(&mut msg))
.await
.context("Cannot add AEAP explanation")
.log_err(context)
.ok();
}
Ok(())
@@ -507,16 +550,9 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
// Configure IMAP
let transport_id = 0;
let (_s, r) = async_channel::bounded(1);
let mut imap = Imap::new(
configured_param.imap.clone(),
configured_param.imap_password.clone(),
proxy_config,
&configured_param.addr,
strict_tls,
configured_param.oauth2,
r,
);
let mut imap = Imap::new(ctx, transport_id, configured_param.clone(), r).await?;
let configuring = true;
let mut imap_session = match imap.connect(ctx, configuring).await {
Ok(session) => session,
@@ -529,53 +565,23 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
progress!(ctx, 850);
// Wait for SMTP configuration
smtp_config_task.await.unwrap()?;
smtp_config_task.await??;
progress!(ctx, 900);
let is_chatmail = match ctx.get_config_bool(Config::FixIsChatmail).await? {
false => {
let is_chatmail = imap_session.is_chatmail();
ctx.set_config(
Config::IsChatmail,
Some(match is_chatmail {
false => "0",
true => "1",
}),
)
.await?;
is_chatmail
}
true => ctx.get_config_bool(Config::IsChatmail).await?,
};
if is_chatmail {
ctx.set_config(Config::MvboxMove, Some("0")).await?;
ctx.set_config(Config::OnlyFetchMvbox, None).await?;
ctx.set_config(Config::ShowEmails, None).await?;
if !ctx.is_configured().await? {
ctx.sql.set_raw_config("mvbox_move", Some("0")).await?;
ctx.sql.set_raw_config("only_fetch_mvbox", None).await?;
}
let create_mvbox = !is_chatmail;
let create_mvbox = false;
imap.configure_folders(ctx, &mut imap_session, create_mvbox)
.await?;
let create = true;
imap_session
.select_with_uidvalidity(ctx, "INBOX", create)
.await
.context("could not read INBOX status")?;
drop(imap);
progress!(ctx, 910);
if let Some(configured_addr) = ctx.get_config(Config::ConfiguredAddr).await? {
if configured_addr != param.addr {
// Switched account, all server UIDs we know are invalid
info!(ctx, "Scheduling resync because the address has changed.");
ctx.schedule_resync().await?;
}
}
let provider = configured_param.provider;
configured_param
.save_to_transports_table(ctx, param)

View File

@@ -28,8 +28,9 @@ struct MozAutoconfigure {
pub outgoing_servers: Vec<Server>,
}
#[derive(Debug)]
#[derive(Debug, Default)]
enum MozConfigTag {
#[default]
Undefined,
Hostname,
Port,
@@ -37,12 +38,6 @@ enum MozConfigTag {
Username,
}
impl Default for MozConfigTag {
fn default() -> Self {
Self::Undefined
}
}
impl FromStr for MozConfigTag {
type Err = ();
@@ -159,10 +154,10 @@ fn parse_xml_reader<B: BufRead>(
if let Some(incoming_server) = parse_server(reader, event)? {
incoming_servers.push(incoming_server);
}
} else if tag == "outgoingserver" {
if let Some(outgoing_server) = parse_server(reader, event)? {
outgoing_servers.push(outgoing_server);
}
} else if tag == "outgoingserver"
&& let Some(outgoing_server) = parse_server(reader, event)?
{
outgoing_servers.push(outgoing_server);
}
}
Event::Eof => break,

View File

@@ -223,6 +223,9 @@ pub(crate) const DC_FOLDERS_CONFIGURED_VERSION: i32 = 5;
// `max_smtp_rcpt_to` in the provider db.
pub(crate) const DEFAULT_MAX_SMTP_RCPT_TO: usize = 50;
/// Same as `DEFAULT_MAX_SMTP_RCPT_TO`, but for chatmail relays.
pub(crate) const DEFAULT_CHATMAIL_MAX_SMTP_RCPT_TO: usize = 999;
/// How far the last quota check needs to be in the past to be checked by the background function (in seconds).
pub(crate) const DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT: u64 = 12 * 60 * 60; // 12 hours
@@ -250,6 +253,18 @@ pub(crate) const ASM_BODY: &str = "This is the Autocrypt Setup Message \
/// Period between `sql::housekeeping()` runs.
pub(crate) const HOUSEKEEPING_PERIOD: i64 = 24 * 60 * 60;
pub(crate) const BROADCAST_INCOMPATIBILITY_MSG: &str = r#"The up to now "experimental channels feature" is about to become an officially supported one. By that, privacy will be improved, it will become faster, and less traffic will be consumed.
As we do not guarantee feature-stability for such experiments, this means, that you will need to create the channel again.
Here is what to do:
• Create a new channel
• Tap on the channel name
• Tap on "QR Invite Code"
• Have all recipients scan the QR code, or send them the link
If you have any questions, please send an email to delta@merlinux.eu or ask at https://support.delta.chat/."#;
#[cfg(test)]
mod tests {
use num_traits::FromPrimitive;

View File

@@ -31,7 +31,7 @@ use crate::key::{
DcKey, Fingerprint, SignedPublicKey, load_self_public_key, self_fingerprint,
self_fingerprint_opt,
};
use crate::log::{LogExt, info, warn};
use crate::log::{LogExt, warn};
use crate::message::MessageState;
use crate::mimeparser::AvatarAction;
use crate::param::{Param, Params};
@@ -130,35 +130,37 @@ impl ContactId {
Ok((addr, fingerprint))
},
)?;
context.emit_event(EventType::ContactsChanged(Some(self)));
Ok(Some((addr, fingerprint)))
} else {
Ok(None)
}
})
.await?;
if row.is_some() {
context.emit_event(EventType::ContactsChanged(Some(self)));
}
if sync.into() {
if let Some((addr, fingerprint)) = row {
if fingerprint.is_empty() {
chat::sync(
context,
chat::SyncId::ContactAddr(addr),
chat::SyncAction::Rename(name.to_string()),
)
.await
.log_err(context)
.ok();
} else {
chat::sync(
context,
chat::SyncId::ContactFingerprint(fingerprint),
chat::SyncAction::Rename(name.to_string()),
)
.await
.log_err(context)
.ok();
}
if sync.into()
&& let Some((addr, fingerprint)) = row
{
if fingerprint.is_empty() {
chat::sync(
context,
chat::SyncId::ContactAddr(addr),
chat::SyncAction::Rename(name.to_string()),
)
.await
.log_err(context)
.ok();
} else {
chat::sync(
context,
chat::SyncId::ContactFingerprint(fingerprint),
chat::SyncAction::Rename(name.to_string()),
)
.await
.log_err(context)
.ok();
}
}
Ok(())
@@ -369,16 +371,15 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu
return Ok(id);
}
let path = match &contact.profile_image {
Some(image) => match BlobObject::store_from_base64(context, image) {
Err(e) => {
Some(image) => match BlobObject::store_from_base64(context, image)? {
None => {
warn!(
context,
"import_vcard_contact: Could not decode and save avatar for {}: {e:#}.",
contact.addr
"import_vcard_contact: Could not decode avatar for {}.", contact.addr
);
None
}
Ok(path) => Some(path),
Some(path) => Some(path),
},
None => None,
};
@@ -394,13 +395,13 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu
);
}
}
if let Some(biography) = &contact.biography {
if let Err(e) = set_status(context, id, biography.to_owned(), false, false).await {
warn!(
context,
"import_vcard_contact: Could not set biography for {}: {e:#}.", contact.addr
);
}
if let Some(biography) = &contact.biography
&& let Err(e) = set_status(context, id, biography.to_owned(), false, false).await
{
warn!(
context,
"import_vcard_contact: Could not set biography for {}: {e:#}.", contact.addr
);
}
Ok(id)
}
@@ -1285,7 +1286,10 @@ impl Contact {
.query_map_vec(
"SELECT id FROM contacts WHERE id>? AND blocked!=0 ORDER BY last_seen DESC, id DESC;",
(ContactId::LAST_SPECIAL,),
|row| row.get::<_, ContactId>(0),
|row| {
let contact_id: ContactId = row.get(0)?;
Ok(contact_id)
}
)
.await?;
Ok(list)
@@ -1562,20 +1566,33 @@ impl Contact {
if show_fallback_icon && !self.id.is_special() && !self.is_key_contact() {
return Ok(Some(chat::get_unencrypted_icon(context).await?));
}
if let Some(image_rel) = self.param.get(Param::ProfileImage) {
if !image_rel.is_empty() {
return Ok(Some(get_abs_path(context, Path::new(image_rel))));
}
if let Some(image_rel) = self.param.get(Param::ProfileImage)
&& !image_rel.is_empty()
{
return Ok(Some(get_abs_path(context, Path::new(image_rel))));
}
Ok(None)
}
/// Returns a color for the contact.
/// See [`self::get_color`].
/// For self-contact this returns gray if own keypair doesn't exist yet.
/// See also [`self::get_color`].
pub fn get_color(&self) -> u32 {
get_color(self.id == ContactId::SELF, &self.addr, &self.fingerprint())
}
/// Returns a color for the contact.
/// Ensures that the color isn't gray. For self-contact this generates own keypair if it doesn't
/// exist yet.
/// See also [`self::get_color`].
pub async fn get_or_gen_color(&self, context: &Context) -> Result<u32> {
let mut fpr = self.fingerprint();
if fpr.is_none() && self.id == ContactId::SELF {
fpr = Some(load_self_public_key(context).await?.dc_fingerprint());
}
Ok(get_color(self.id == ContactId::SELF, &self.addr, &fpr))
}
/// Gets the contact's status.
///
/// Status is the last signature received in a message from this contact.
@@ -1785,10 +1802,11 @@ WHERE type=? AND id IN (
// also unblock mailinglist
// if the contact is a mailinglist address explicitly created to allow unblocking
if !new_blocking && contact.origin == Origin::MailinglistAddress {
if let Some((chat_id, ..)) = chat::get_chat_id_by_grpid(context, &contact.addr).await? {
chat_id.unblock_ex(context, Nosync).await?;
}
if !new_blocking
&& contact.origin == Origin::MailinglistAddress
&& let Some((chat_id, ..)) = chat::get_chat_id_by_grpid(context, &contact.addr).await?
{
chat_id.unblock_ex(context, Nosync).await?;
}
if sync.into() {
@@ -2044,7 +2062,7 @@ impl RecentlySeenLoop {
// become unseen in the future.
let mut unseen_queue: BinaryHeap<MyHeapElem> = context
.sql
.query_map(
.query_map_collect(
"SELECT id, last_seen FROM contacts
WHERE last_seen > ?",
(now_ts - SEEN_RECENTLY_SECONDS,),
@@ -2053,10 +2071,6 @@ impl RecentlySeenLoop {
let last_seen: i64 = row.get("last_seen")?;
Ok((Reverse(last_seen + SEEN_RECENTLY_SECONDS), contact_id))
},
|rows| {
rows.collect::<std::result::Result<BinaryHeap<MyHeapElem>, _>>()
.map_err(Into::into)
},
)
.await
.unwrap_or_default();

View File

@@ -4,7 +4,6 @@ use super::*;
use crate::chat::{Chat, get_chat_contacts, send_text_msg};
use crate::chatlist::Chatlist;
use crate::receive_imf::receive_imf;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote};
#[test]
@@ -775,16 +774,21 @@ async fn test_contact_get_color() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_self_color_vs_key() -> Result<()> {
async fn test_self_color() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.unconfigured().await;
t.configure_addr("alice@example.org").await;
assert!(t.is_configured().await?);
let color = Contact::get_by_id(t, ContactId::SELF).await?.get_color();
let self_contact = Contact::get_by_id(t, ContactId::SELF).await?;
let color = self_contact.get_color();
assert_eq!(color, 0x808080);
get_securejoin_qr(t, None).await?;
let color1 = Contact::get_by_id(t, ContactId::SELF).await?.get_color();
assert_ne!(color1, color);
let color = self_contact.get_or_gen_color(t).await?;
assert_ne!(color, 0x808080);
let color1 = self_contact.get_or_gen_color(t).await?;
assert_eq!(color1, color);
let bob = &tcm.bob().await;
assert_eq!(bob.add_or_lookup_contact(t).await.get_color(), color);
Ok(())
}

View File

@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, HashMap};
use std::ffi::OsString;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
@@ -21,9 +21,9 @@ use crate::debug_logging::DebugLogging;
use crate::events::{Event, EventEmitter, EventType, Events};
use crate::imap::{FolderMeaning, Imap, ServerMetadata};
use crate::key::self_fingerprint;
use crate::log::{info, warn};
use crate::log::warn;
use crate::logged_debug_assert;
use crate::login_param::{ConfiguredLoginParam, EnteredLoginParam};
use crate::login_param::EnteredLoginParam;
use crate::message::{self, MessageState, MsgId};
use crate::net::tls::TlsSessionStore;
use crate::peer_channels::Iroh;
@@ -34,6 +34,7 @@ use crate::sql::Sql;
use crate::stock_str::StockStrings;
use crate::timesmearing::SmearedTimestamp;
use crate::tools::{self, duration_to_str, time, time_elapsed};
use crate::transport::ConfiguredLoginParam;
use crate::{chatlist_events, stats};
/// Builder for the [`Context`].
@@ -45,7 +46,7 @@ use crate::{chatlist_events, stats};
///
/// # Examples
///
/// Creating a new unencrypted database:
/// Creating a new database:
///
/// ```
/// # let rt = tokio::runtime::Runtime::new().unwrap();
@@ -60,24 +61,6 @@ use crate::{chatlist_events, stats};
/// drop(context);
/// # });
/// ```
///
/// To use an encrypted database provide a password. If the database does not yet exist it
/// will be created:
///
/// ```
/// # let rt = tokio::runtime::Runtime::new().unwrap();
/// # rt.block_on(async move {
/// use deltachat::context::ContextBuilder;
///
/// let dir = tempfile::tempdir().unwrap();
/// let context = ContextBuilder::new(dir.path().join("db"))
/// .with_password("secret".into())
/// .open()
/// .await
/// .unwrap();
/// drop(context);
/// # });
/// ```
#[derive(Clone, Debug)]
pub struct ContextBuilder {
dbfile: PathBuf,
@@ -137,7 +120,7 @@ impl ContextBuilder {
///
/// This is useful in order to share the same translation strings in all [`Context`]s.
/// The mapping may be empty when set, it will be populated by
/// [`Context::set_stock-translation`] or [`Accounts::set_stock_translation`] calls.
/// [`Context::set_stock_translation`] or [`Accounts::set_stock_translation`] calls.
///
/// Note that the [account manager](crate::accounts::Accounts) is designed to handle the
/// common case for using multiple [`Context`] instances.
@@ -149,9 +132,13 @@ impl ContextBuilder {
}
/// Sets the password to unlock the database.
/// Deprecated 2025-11:
/// - Db encryption does nothing with blobs, so fs/disk encryption is recommended.
/// - Isolation from other apps is needed anyway.
///
/// If an encrypted database is used it must be opened with a password. Setting a
/// password on a new database will enable encryption.
#[deprecated(since = "TBD")]
pub fn with_password(mut self, password: String) -> Self {
self.password = Some(password);
self
@@ -179,7 +166,7 @@ impl ContextBuilder {
/// Builds the [`Context`] and opens it.
///
/// Returns error if context cannot be opened with the given passphrase.
/// Returns error if context cannot be opened.
pub async fn open(self) -> Result<Context> {
let password = self.password.clone().unwrap_or_default();
let context = self.build().await?;
@@ -242,9 +229,6 @@ pub struct InnerContext {
/// Set to `None` if quota was never tried to load.
pub(crate) quota: RwLock<Option<QuotaInfo>>,
/// IMAP UID resync request.
pub(crate) resync_request: AtomicBool,
/// Notify about new messages.
///
/// This causes [`Context::wait_next_msgs`] to wake up.
@@ -305,10 +289,21 @@ pub struct InnerContext {
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
/// see [`Context::get_connectivity()`].
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
#[expect(clippy::type_complexity)]
/// Transforms the root of the cryptographic payload before encryption.
pub(crate) pre_encrypt_mime_hook: parking_lot::Mutex<
Option<
for<'a> fn(
&Context,
mail_builder::mime::MimePart<'a>,
) -> mail_builder::mime::MimePart<'a>,
>,
>,
}
/// The state of ongoing process.
#[derive(Debug)]
#[derive(Debug, Default)]
enum RunningState {
/// Ongoing process is allocated.
Running { cancel_sender: Sender<()> },
@@ -317,15 +312,10 @@ enum RunningState {
ShallStop { request: tools::Time },
/// There is no ongoing process, a new one can be allocated.
#[default]
Stopped,
}
impl Default for RunningState {
fn default() -> Self {
Self::Stopped
}
}
/// Return some info about deltachat-core
///
/// This contains information mostly about the library itself, the
@@ -396,9 +386,12 @@ impl Context {
}
/// Opens the database with the given passphrase.
/// NB: Db encryption is deprecated, so `passphrase` should be empty normally. See
/// [`ContextBuilder::with_password()`] for reasoning.
///
/// Returns true if passphrase is correct, false is passphrase is not correct. Fails on other
/// errors.
#[deprecated(since = "TBD")]
pub async fn open(&self, passphrase: String) -> Result<bool> {
if self.sql.check_passphrase(passphrase.clone()).await? {
self.sql.open(self, passphrase).await?;
@@ -409,6 +402,7 @@ impl Context {
}
/// Changes encrypted database passphrase.
/// Deprecated 2025-11, see [`ContextBuilder::with_password()`] for reasoning.
pub async fn change_passphrase(&self, passphrase: String) -> Result<()> {
self.sql.change_passphrase(passphrase).await?;
Ok(())
@@ -461,7 +455,6 @@ impl Context {
scheduler: SchedulerState::new(),
ratelimit: RwLock::new(Ratelimit::new(Duration::new(60, 0), 6.0)), // Allow at least 1 message every 10 seconds + a burst of 6.
quota: RwLock::new(None),
resync_request: AtomicBool::new(false),
new_msgs_notify,
server_id: RwLock::new(None),
metadata: RwLock::new(None),
@@ -475,6 +468,7 @@ impl Context {
iroh: Arc::new(RwLock::new(None)),
self_fingerprint: OnceLock::new(),
connectivities: parking_lot::Mutex::new(Vec::new()),
pre_encrypt_mime_hook: None.into(),
};
let ctx = Context {
@@ -553,7 +547,7 @@ impl Context {
.and_then(|provider| provider.opt.max_smtp_rcpt_to)
.map_or_else(
|| match is_chatmail {
true => usize::MAX,
true => constants::DEFAULT_CHATMAIL_MAX_SMTP_RCPT_TO,
false => constants::DEFAULT_MAX_SMTP_RCPT_TO,
},
usize::from,
@@ -604,10 +598,9 @@ impl Context {
if self
.quota_needs_update(DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT)
.await
&& let Err(err) = self.update_recent_quota(&mut session).await
{
if let Err(err) = self.update_recent_quota(&mut session).await {
warn!(self, "Failed to update quota: {err:#}.");
}
warn!(self, "Failed to update quota: {err:#}.");
}
}
@@ -620,12 +613,6 @@ impl Context {
Ok(())
}
pub(crate) async fn schedule_resync(&self) -> Result<()> {
self.resync_request.store(true, Ordering::Relaxed);
self.scheduler.interrupt_inbox().await;
Ok(())
}
/// Returns a reference to the underlying SQL instance.
///
/// Warning: this is only here for testing, not part of the public API.
@@ -810,9 +797,10 @@ impl Context {
/// Returns information about the context as key-value pairs.
pub async fn get_info(&self) -> Result<BTreeMap<&'static str, String>> {
let l = EnteredLoginParam::load(self).await?;
let l2 = ConfiguredLoginParam::load(self)
.await?
.map_or_else(|| "Not configured".to_string(), |param| param.to_string());
let l2 = ConfiguredLoginParam::load(self).await?.map_or_else(
|| "Not configured".to_string(),
|(_transport_id, param)| param.to_string(),
);
let secondary_addrs = self.get_secondary_self_addrs().await?.join(", ");
let chats = get_chat_cnt(self).await?;
let unblocked_msgs = message::get_unblocked_msg_cnt(self).await;
@@ -1065,6 +1053,13 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"test_hooks",
self.sql
.get_raw_config("test_hooks")
.await?
.unwrap_or_default(),
);
res.insert(
"fail_on_receiving_full_msg",
self.sql
@@ -1072,6 +1067,13 @@ impl Context {
.await?
.unwrap_or_default(),
);
res.insert(
"std_header_protection_composing",
self.sql
.get_raw_config("std_header_protection_composing")
.await?
.unwrap_or_default(),
);
let elapsed = time_elapsed(&self.creation_time);
res.insert("uptime", duration_to_str(elapsed));
@@ -1105,7 +1107,10 @@ impl Context {
" ORDER BY m.timestamp DESC,m.id DESC;"
),
(MessageState::InFresh, time()),
|row| row.get::<_, MsgId>(0),
|row| {
let msg_id: MsgId = row.get(0)?;
Ok(msg_id)
},
)
.await?;
Ok(list)
@@ -1209,7 +1214,10 @@ impl Context {
AND IFNULL(txt_normalized, txt) LIKE ?
ORDER BY m.timestamp,m.id;",
(chat_id, str_like_in_text),
|row| row.get::<_, MsgId>("id"),
|row| {
let msg_id: MsgId = row.get("id")?;
Ok(msg_id)
},
)
.await?
} else {
@@ -1238,7 +1246,10 @@ impl Context {
AND IFNULL(txt_normalized, txt) LIKE ?
ORDER BY m.id DESC LIMIT 1000",
(str_like_in_text,),
|row| row.get::<_, MsgId>("id"),
|row| {
let msg_id: MsgId = row.get("id")?;
Ok(msg_id)
},
)
.await?
};

View File

@@ -3,7 +3,6 @@ use crate::chat::ChatId;
use crate::config::Config;
use crate::context::Context;
use crate::events::EventType;
use crate::log::{error, info};
use crate::message::{Message, MsgId, Viewtype};
use crate::param::Param;
use crate::tools::time;
@@ -116,15 +115,13 @@ pub async fn maybe_set_logging_xdc_inner(
filename: Option<&str>,
msg_id: MsgId,
) -> anyhow::Result<()> {
if viewtype == Viewtype::Webxdc {
if let Some(filename) = filename {
if filename.starts_with("debug_logging")
&& filename.ends_with(".xdc")
&& chat_id.is_self_talk(context).await?
{
set_debug_logging_xdc(context, Some(msg_id)).await?;
}
}
if viewtype == Viewtype::Webxdc
&& let Some(filename) = filename
&& filename.starts_with("debug_logging")
&& filename.ends_with(".xdc")
&& chat_id.is_self_talk(context).await?
{
set_debug_logging_xdc(context, Some(msg_id)).await?;
}
Ok(())
}

View File

@@ -10,17 +10,19 @@ use crate::pgp;
/// Tries to decrypt a message, but only if it is structured as an Autocrypt message.
///
/// If successful and the message is encrypted, returns decrypted body.
/// 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>>> {
let Some(encrypted_data_part) = get_encrypted_mime(mail) else {
return Ok(None);
};
let data = encrypted_data_part.get_body_raw()?;
let msg = pgp::pk_decrypt(data, private_keyring)?;
let msg = pgp::decrypt(data, private_keyring, shared_secrets)?;
Ok(Some(msg))
}

View File

@@ -13,6 +13,7 @@ use quick_xml::{
use crate::simplify::{SimplifiedText, simplify_quote};
#[derive(Default)]
struct Dehtml {
strbuilder: String,
quote: String,
@@ -25,6 +26,9 @@ struct Dehtml {
/// Everything between `<div name="quote">` and `<div name="quoted-content">` is usually metadata
/// If this is > `0`, then we are inside a `<div name="quoted-content">`.
divs_since_quoted_content_div: u32,
/// `<div class="header-protection-legacy-display">` elements should be omitted, see
/// <https://www.rfc-editor.org/rfc/rfc9788.html#section-4.5.3.3>.
divs_since_hp_legacy_display: u32,
/// All-Inkl just puts the quote into `<blockquote> </blockquote>`. This count is
/// increased at each `<blockquote>` and decreased at each `</blockquote>`.
blockquotes_since_blockquote: u32,
@@ -48,20 +52,25 @@ impl Dehtml {
}
fn get_add_text(&self) -> AddText {
if self.divs_since_quote_div > 0 && self.divs_since_quoted_content_div == 0 {
AddText::No // Everything between `<div name="quoted">` and `<div name="quoted_content">` is metadata which we don't want
// Everything between `<div name="quoted">` and `<div name="quoted_content">` is
// metadata which we don't want.
if self.divs_since_quote_div > 0 && self.divs_since_quoted_content_div == 0
|| self.divs_since_hp_legacy_display > 0
{
AddText::No
} else {
self.add_text
}
}
}
#[derive(Debug, PartialEq, Clone, Copy)]
#[derive(Debug, Default, PartialEq, Clone, Copy)]
enum AddText {
/// Inside `<script>`, `<style>` and similar tags
/// which contents should not be displayed.
No,
#[default]
YesRemoveLineEnds,
/// Inside `<pre>`.
@@ -121,12 +130,7 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
let mut dehtml = Dehtml {
strbuilder: String::with_capacity(buf.len()),
quote: String::new(),
add_text: AddText::YesRemoveLineEnds,
last_href: None,
divs_since_quote_div: 0,
divs_since_quoted_content_div: 0,
blockquotes_since_blockquote: 0,
..Default::default()
};
let mut reader = quick_xml::Reader::from_str(buf);
@@ -244,6 +248,7 @@ fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
"div" => {
pop_tag(&mut dehtml.divs_since_quote_div);
pop_tag(&mut dehtml.divs_since_quoted_content_div);
pop_tag(&mut dehtml.divs_since_hp_legacy_display);
*dehtml.get_buf() += "\n\n";
dehtml.add_text = AddText::YesRemoveLineEnds;
@@ -295,6 +300,8 @@ fn dehtml_starttag_cb<B: std::io::BufRead>(
"div" => {
maybe_push_tag(event, reader, "quote", &mut dehtml.divs_since_quote_div);
maybe_push_tag(event, reader, "quoted-content", &mut dehtml.divs_since_quoted_content_div);
maybe_push_tag(event, reader, "header-protection-legacy-display",
&mut dehtml.divs_since_hp_legacy_display);
*dehtml.get_buf() += "\n\n";
dehtml.add_text = AddText::YesRemoveLineEnds;
@@ -539,6 +546,27 @@ mod tests {
assert_eq!(txt.text.trim(), "two\nlines");
}
#[test]
fn test_hp_legacy_display() {
let input = r#"
<html><head><title></title></head><body>
<div class="header-protection-legacy-display">
<pre>Subject: Dinner plans</pre>
</div>
<p>
Let's meet at Rama's Roti Shop at 8pm and go to the park
from there.
</p>
</body>
</html>
"#;
let txt = dehtml(input).unwrap();
assert_eq!(
txt.text.trim(),
"Let's meet at Rama's Roti Shop at 8pm and go to the park from there."
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quote_div() {
let input = include_str!("../test-data/message/gmx-quote-body.eml");

View File

@@ -10,7 +10,6 @@ use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::context::Context;
use crate::imap::session::Session;
use crate::log::info;
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, Part};
use crate::tools::time;
@@ -221,21 +220,14 @@ impl MimeMessage {
/// To create the placeholder, only the outermost header can be used,
/// the mime-structure itself is not available.
///
/// The placeholder part currently contains a text with size and availability of the message;
/// `error` is set as the part error;
/// in the future, we may do more advanced things as previews here.
/// The placeholder part currently contains a text with size and availability of the message.
pub(crate) async fn create_stub_from_partial_download(
&mut self,
context: &Context,
org_bytes: u32,
error: Option<String>,
) -> Result<()> {
let prefix = match error {
None => "",
Some(_) => "[❗] ",
};
let mut text = format!(
"{prefix}[{}]",
"[{}]",
stock_str::partial_download_msg_body(context, org_bytes).await
);
if let Some(delete_server_after) = context.get_config_delete_server_after().await? {
@@ -252,7 +244,6 @@ impl MimeMessage {
self.do_add_single_part(Part {
typ: Viewtype::Text,
msg: text,
error,
..Default::default()
});

View File

@@ -46,6 +46,7 @@ impl EncryptHelper {
keyring: Vec<SignedPublicKey>,
mail_to_encrypt: MimePart<'static>,
compress: bool,
anonymous_recipients: bool,
) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;
@@ -53,7 +54,35 @@ impl EncryptHelper {
let cursor = Cursor::new(&mut raw_message);
mail_to_encrypt.clone().write_part(cursor).ok();
let ctext = pgp::pk_encrypt(raw_message, keyring, Some(sign_key), compress).await?;
let ctext = pgp::pk_encrypt(
raw_message,
keyring,
Some(sign_key),
compress,
anonymous_recipients,
)
.await?;
Ok(ctext)
}
/// Symmetrically encrypt the message. This is used for broadcast channels.
/// `shared secret` is the secret that will be used for symmetric encryption.
pub async fn encrypt_symmetrically(
self,
context: &Context,
shared_secret: &str,
mail_to_encrypt: MimePart<'static>,
compress: bool,
) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;
let mut raw_message = Vec::new();
let cursor = Cursor::new(&mut raw_message);
mail_to_encrypt.clone().write_part(cursor).ok();
let ctext =
pgp::symm_encrypt_message(raw_message, sign_key, shared_secret, compress).await?;
Ok(ctext)
}

View File

@@ -80,7 +80,7 @@ use crate::contact::ContactId;
use crate::context::Context;
use crate::download::MIN_DELETE_SERVER_AFTER;
use crate::events::EventType;
use crate::log::{LogExt, error, info, warn};
use crate::log::{LogExt, warn};
use crate::message::{Message, MessageState, MsgId, Viewtype};
use crate::mimeparser::SystemMessage;
use crate::stock_str;
@@ -88,9 +88,10 @@ use crate::tools::{SystemTime, duration_to_str, time};
use crate::{location, stats};
/// Ephemeral timer value.
#[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize, Deserialize)]
#[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize, Deserialize, Default)]
pub enum Timer {
/// Timer is disabled.
#[default]
Disabled,
/// Timer is enabled.
@@ -125,12 +126,6 @@ impl Timer {
}
}
impl Default for Timer {
fn default() -> Self {
Self::Disabled
}
}
impl fmt::Display for Timer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_u32())
@@ -246,10 +241,9 @@ pub(crate) async fn stock_ephemeral_timer_changed(
match timer {
Timer::Disabled => stock_str::msg_ephemeral_timer_disabled(context, from_id).await,
Timer::Enabled { duration } => match duration {
0..=59 => {
0..=60 => {
stock_str::msg_ephemeral_timer_enabled(context, &timer.to_string(), from_id).await
}
60 => stock_str::msg_ephemeral_timer_minute(context, from_id).await,
61..=3599 => {
stock_str::msg_ephemeral_timer_minutes(
context,

View File

@@ -38,7 +38,7 @@ async fn test_stock_ephemeral_messages() {
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 60 }, ContactId::SELF)
.await,
"You set message deletion timer to 1 minute."
"You set message deletion timer to 60 s."
);
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 90 }, ContactId::SELF)
@@ -142,7 +142,7 @@ async fn test_ephemeral_enable_disable() -> Result<()> {
let bob_received_message = bob.recv_msg(&sent).await;
assert_eq!(
bob_received_message.text,
"Message deletion timer is set to 1 minute by alice@example.org."
"Message deletion timer is set to 60 s by alice@example.org."
);
assert_eq!(
chat_bob.get_ephemeral_timer(bob).await?,
@@ -451,6 +451,8 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
let t = TestContext::new_alice().await;
const HOUR: i64 = 60 * 60;
let now = time();
let transport_id = 1;
let uidvalidity = 12345;
for (id, timestamp, ephemeral_timestamp) in &[
(900, now - 2 * HOUR, 0),
(1000, now - 23 * HOUR - MIN_DELETE_SERVER_AFTER, 0),
@@ -470,8 +472,8 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
.await?;
t.sql
.execute(
"INSERT INTO imap (rfc724_mid, folder, uid, target) VALUES (?,'INBOX',?, 'INBOX');",
(&message_id, id),
"INSERT INTO imap (transport_id, rfc724_mid, folder, uid, target, uidvalidity) VALUES (?, ?,'INBOX',?, 'INBOX', ?);",
(transport_id, &message_id, id, uidvalidity),
)
.await?;
}

View File

@@ -39,6 +39,8 @@ pub enum HeaderDef {
/// Mailing list ID defined in [RFC 2919](https://tools.ietf.org/html/rfc2919).
ListId,
ListPost,
/// Mailing list id, belonging to a broadcast channel created by Delta Chat
ChatListId,
/// List-Help header defined in [RFC 2369](https://datatracker.ietf.org/doc/html/rfc2369).
ListHelp,
@@ -63,7 +65,9 @@ pub enum HeaderDef {
ChatUserAvatar,
ChatVoiceMessage,
ChatGroupMemberRemoved,
ChatGroupMemberRemovedFpr,
ChatGroupMemberAdded,
ChatGroupMemberAddedFpr,
ChatContent,
/// Past members of the group.
@@ -94,6 +98,11 @@ pub enum HeaderDef {
/// This message obsoletes the text of the message defined here by rfc724_mid.
ChatEdit,
/// The secret shared amongst all recipients of this broadcast channel,
/// used to encrypt and decrypt messages.
/// This secret is sent to a new member in the member-addition message.
ChatBroadcastSecret,
/// [Autocrypt](https://autocrypt.org/) header.
Autocrypt,
AutocryptGossip,
@@ -129,6 +138,9 @@ pub enum HeaderDef {
/// Advertised gossip topic for one webxdc.
IrohGossipTopic,
/// See <https://www.rfc-editor.org/rfc/rfc9788.html#name-hp-outer-header-field>.
HpOuter,
#[cfg(test)]
TestHeader,
}

View File

@@ -169,27 +169,28 @@ impl HtmlMsgParser {
MimeMultipartType::Single => {
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
if mimetype == mime::TEXT_HTML {
if self.html.is_empty() {
if let Ok(decoded_data) = mail.get_body() {
self.html = decoded_data;
}
}
} else if mimetype == mime::TEXT_PLAIN && self.plain.is_none() {
if let Ok(decoded_data) = mail.get_body() {
self.plain = Some(PlainText {
text: decoded_data,
flowed: if let Some(format) = mail.ctype.params.get("format") {
format.as_str().eq_ignore_ascii_case("flowed")
} else {
false
},
delsp: if let Some(delsp) = mail.ctype.params.get("delsp") {
delsp.as_str().eq_ignore_ascii_case("yes")
} else {
false
},
});
if self.html.is_empty()
&& let Ok(decoded_data) = mail.get_body()
{
self.html = decoded_data;
}
} else if mimetype == mime::TEXT_PLAIN
&& self.plain.is_none()
&& let Ok(decoded_data) = mail.get_body()
{
self.plain = Some(PlainText {
text: decoded_data,
flowed: if let Some(format) = mail.ctype.params.get("format") {
format.as_str().eq_ignore_ascii_case("flowed")
} else {
false
},
delsp: if let Some(delsp) = mail.ctype.params.get("delsp") {
delsp.as_str().eq_ignore_ascii_case("yes")
} else {
false
},
});
}
Ok(())
}
@@ -213,31 +214,29 @@ impl HtmlMsgParser {
MimeMultipartType::Message => Ok(()),
MimeMultipartType::Single => {
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
if mimetype.type_() == mime::IMAGE {
if let Some(cid) = mail.headers.get_header_value(HeaderDef::ContentId) {
if let Ok(cid) = parse_message_id(&cid) {
if let Ok(replacement) = mimepart_to_data_url(mail) {
let re_string = format!(
"(<img[^>]*src[^>]*=[^>]*)(cid:{})([^>]*>)",
regex::escape(&cid)
);
match regex::Regex::new(&re_string) {
Ok(re) => {
self.html = re
.replace_all(
&self.html,
format!("${{1}}{replacement}${{3}}").as_str(),
)
.as_ref()
.to_string()
}
Err(e) => warn!(
context,
"Cannot create regex for cid: {} throws {}", re_string, e
),
}
}
if mimetype.type_() == mime::IMAGE
&& let Some(cid) = mail.headers.get_header_value(HeaderDef::ContentId)
&& let Ok(cid) = parse_message_id(&cid)
&& let Ok(replacement) = mimepart_to_data_url(mail)
{
let re_string = format!(
"(<img[^>]*src[^>]*=[^>]*)(cid:{})([^>]*>)",
regex::escape(&cid)
);
match regex::Regex::new(&re_string) {
Ok(re) => {
self.html = re
.replace_all(
&self.html,
format!("${{1}}{replacement}${{3}}").as_str(),
)
.as_ref()
.to_string()
}
Err(e) => warn!(
context,
"Cannot create regex for cid: {} throws {}", re_string, e
),
}
}
Ok(())

View File

@@ -24,7 +24,7 @@ use ratelimit::Ratelimit;
use url::Url;
use crate::calls::{create_fallback_ice_servers, create_ice_servers_from_metadata};
use crate::chat::{self, ChatId, ChatIdBlocked};
use crate::chat::{self, ChatId, ChatIdBlocked, add_device_msg};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::{self, Blocked, Chattype, ShowEmails};
@@ -32,10 +32,7 @@ use crate::contact::{Contact, ContactId, Modifier, Origin};
use crate::context::Context;
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
use crate::log::{LogExt, error, info, warn};
use crate::login_param::{
ConfiguredLoginParam, ConfiguredServerLoginParam, prioritize_server_login_params,
};
use crate::log::{LogExt, warn};
use crate::message::{self, Message, MessageState, MessengerMessage, MsgId};
use crate::mimeparser;
use crate::net::proxy::ProxyConfig;
@@ -48,6 +45,9 @@ use crate::receive_imf::{
use crate::scheduler::connectivity::ConnectivityStore;
use crate::stock_str;
use crate::tools::{self, create_id, duration_to_str, time};
use crate::transport::{
ConfiguredLoginParam, ConfiguredServerLoginParam, prioritize_server_login_params,
};
pub(crate) mod capabilities;
mod client;
@@ -71,6 +71,11 @@ const BODY_PARTIAL: &str = "(FLAGS RFC822.SIZE BODY.PEEK[HEADER])";
#[derive(Debug)]
pub(crate) struct Imap {
/// ID of the transport configuration in the `transports` table.
///
/// This ID is used to namespace records in the `imap` table.
transport_id: u32,
pub(crate) idle_interrupt_receiver: Receiver<()>,
/// Email address.
@@ -104,6 +109,12 @@ pub(crate) struct Imap {
/// immediately after logging in or returning an error in response to LOGIN command
/// due to internal server error.
ratelimit: Ratelimit,
/// IMAP UID resync request sender.
pub(crate) resync_request_sender: async_channel::Sender<()>,
/// IMAP UID resync request receiver.
pub(crate) resync_request_receiver: async_channel::Receiver<()>,
}
#[derive(Debug)]
@@ -243,18 +254,21 @@ impl<T: Iterator<Item = (i64, u32, String)>> Iterator for UidGrouper<T> {
impl Imap {
/// Creates new disconnected IMAP client using the specific login parameters.
///
/// `addr` is used to renew token if OAuth2 authentication is used.
pub fn new(
lp: Vec<ConfiguredServerLoginParam>,
password: String,
proxy_config: Option<ProxyConfig>,
addr: &str,
strict_tls: bool,
oauth2: bool,
pub async fn new(
context: &Context,
transport_id: u32,
param: ConfiguredLoginParam,
idle_interrupt_receiver: Receiver<()>,
) -> Self {
Imap {
) -> Result<Self> {
let lp = param.imap.clone();
let password = param.imap_password.clone();
let proxy_config = ProxyConfig::load(context).await?;
let addr = &param.addr;
let strict_tls = param.strict_tls(proxy_config.is_some());
let oauth2 = param.oauth2;
let (resync_request_sender, resync_request_receiver) = async_channel::bounded(1);
Ok(Imap {
transport_id,
idle_interrupt_receiver,
addr: addr.to_string(),
lp,
@@ -268,7 +282,9 @@ impl Imap {
conn_backoff_ms: 0,
// 1 connection per minute + a burst of 2.
ratelimit: Ratelimit::new(Duration::new(120, 0), 2.0),
}
resync_request_sender,
resync_request_receiver,
})
}
/// Creates new disconnected IMAP client using configured parameters.
@@ -276,20 +292,10 @@ impl Imap {
context: &Context,
idle_interrupt_receiver: Receiver<()>,
) -> Result<Self> {
let param = ConfiguredLoginParam::load(context)
let (transport_id, param) = ConfiguredLoginParam::load(context)
.await?
.context("Not configured")?;
let proxy_config = ProxyConfig::load(context).await?;
let strict_tls = param.strict_tls(proxy_config.is_some());
let imap = Self::new(
param.imap.clone(),
param.imap_password.clone(),
proxy_config,
&param.addr,
strict_tls,
param.oauth2,
idle_interrupt_receiver,
);
let imap = Self::new(context, transport_id, param, idle_interrupt_receiver).await?;
Ok(imap)
}
@@ -392,6 +398,7 @@ impl Imap {
match login_res {
Ok(mut session) => {
let capabilities = determine_capabilities(&mut session).await?;
let resync_request_sender = self.resync_request_sender.clone();
let session = if capabilities.can_compress {
info!(context, "Enabling IMAP compression.");
@@ -402,9 +409,19 @@ impl Imap {
})
.await
.context("Failed to enable IMAP compression")?;
Session::new(compressed_session, capabilities)
Session::new(
compressed_session,
capabilities,
resync_request_sender,
self.transport_id,
)
} else {
Session::new(session, capabilities)
Session::new(
session,
capabilities,
resync_request_sender,
self.transport_id,
)
};
// Store server ID in the context to display in account info.
@@ -583,8 +600,9 @@ impl Imap {
folder: &str,
folder_meaning: FolderMeaning,
) -> Result<(usize, bool)> {
let uid_validity = get_uidvalidity(context, folder).await?;
let old_uid_next = get_uid_next(context, folder).await?;
let transport_id = self.transport_id;
let uid_validity = get_uidvalidity(context, transport_id, folder).await?;
let old_uid_next = get_uid_next(context, transport_id, folder).await?;
info!(
context,
"fetch_new_msg_batch({folder}): UIDVALIDITY={uid_validity}, UIDNEXT={old_uid_next}."
@@ -652,12 +670,19 @@ impl Imap {
context
.sql
.execute(
"INSERT INTO imap (rfc724_mid, folder, uid, uidvalidity, target)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(folder, uid, uidvalidity)
"INSERT INTO imap (transport_id, rfc724_mid, folder, uid, uidvalidity, target)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(transport_id, folder, uid, uidvalidity)
DO UPDATE SET rfc724_mid=excluded.rfc724_mid,
target=excluded.target",
(&message_id, &folder, uid, uid_validity, target),
(
self.transport_id,
&message_id,
&folder,
uid,
uid_validity,
target,
),
)
.await?;
@@ -768,7 +793,7 @@ impl Imap {
prefetch_uid_next < mailbox_uid_next
};
if new_uid_next > old_uid_next {
set_uid_next(context, folder, new_uid_next).await?;
set_uid_next(context, self.transport_id, folder, new_uid_next).await?;
}
info!(context, "{} mails read from \"{}\".", read_cnt, folder);
@@ -848,6 +873,7 @@ impl Session {
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let transport_id = self.transport_id();
if folder_exists {
let mut list = self
.uid_fetch("1:*", RFC724MID_UID)
@@ -880,7 +906,7 @@ impl Session {
msgs.len(),
);
uid_validity = get_uidvalidity(context, folder).await?;
uid_validity = get_uidvalidity(context, transport_id, folder).await?;
} else {
warn!(context, "resync_folder_uids: No folder {folder}.");
uid_validity = 0;
@@ -895,12 +921,12 @@ impl Session {
// This may detect previously undetected moved
// messages, so we update server_folder too.
transaction.execute(
"INSERT INTO imap (rfc724_mid, folder, uid, uidvalidity, target)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(folder, uid, uidvalidity)
"INSERT INTO imap (transport_id, rfc724_mid, folder, uid, uidvalidity, target)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(transport_id, folder, uid, uidvalidity)
DO UPDATE SET rfc724_mid=excluded.rfc724_mid,
target=excluded.target",
(rfc724_mid, folder, uid, uid_validity, target),
(transport_id, rfc724_mid, folder, uid, uid_validity, target),
)?;
}
Ok(())
@@ -1222,11 +1248,12 @@ impl Session {
return Ok(());
}
let transport_id = self.transport_id();
let mut updated_chat_ids = BTreeSet::new();
let uid_validity = get_uidvalidity(context, folder)
let uid_validity = get_uidvalidity(context, transport_id, folder)
.await
.with_context(|| format!("failed to get UID validity for folder {folder}"))?;
let mut highest_modseq = get_modseq(context, folder)
let mut highest_modseq = get_modseq(context, transport_id, folder)
.await
.with_context(|| format!("failed to get MODSEQ for folder {folder}"))?;
let mut list = self
@@ -1249,15 +1276,14 @@ impl Session {
continue;
};
let is_seen = fetch.flags().any(|flag| flag == Flag::Seen);
if is_seen {
if let Some(chat_id) = mark_seen_by_uid(context, folder, uid_validity, uid)
if is_seen
&& let Some(chat_id) = mark_seen_by_uid(context, folder, uid_validity, uid)
.await
.with_context(|| {
format!("failed to update seen status for msg {folder}/{uid}")
})?
{
updated_chat_ids.insert(chat_id);
}
{
updated_chat_ids.insert(chat_id);
}
if let Some(modseq) = fetch.modseq {
@@ -1278,7 +1304,7 @@ impl Session {
self.new_mail = true;
}
set_modseq(context, folder, highest_modseq)
set_modseq(context, transport_id, folder, highest_modseq)
.await
.with_context(|| format!("failed to set MODSEQ for folder {folder}"))?;
if !updated_chat_ids.is_empty() {
@@ -1311,10 +1337,10 @@ impl Session {
while let Some(msg) = list.try_next().await? {
match get_fetch_headers(&msg) {
Ok(headers) => {
if let Some(from) = mimeparser::get_from(&headers) {
if context.is_self_addr(&from.addr).await? {
result.extend(mimeparser::get_recipients(&headers));
}
if let Some(from) = mimeparser::get_from(&headers)
&& context.is_self_addr(&from.addr).await?
{
result.extend(mimeparser::get_recipients(&headers));
}
}
Err(err) => {
@@ -1468,29 +1494,19 @@ impl Session {
context,
"Passing message UID {} to receive_imf().", request_uid
);
let res = receive_imf_inner(
context,
rfc724_mid,
body,
is_seen,
partial.map(|msg_size| (msg_size, None)),
)
.await;
let received_msg = if let Err(err) = res {
warn!(context, "receive_imf error: {:#}.", err);
if partial.is_some() {
return Err(err);
let res = receive_imf_inner(context, rfc724_mid, body, is_seen, partial).await;
let received_msg = match res {
Err(err) => {
warn!(context, "receive_imf error: {err:#}.");
let text = format!(
"❌ Failed to receive a message: {err:#}. Please report this bug to delta@merlinux.eu or https://support.delta.chat/."
);
let mut msg = Message::new_text(text);
add_device_msg(context, None, Some(&mut msg)).await?;
None
}
receive_imf_inner(
context,
rfc724_mid,
body,
is_seen,
Some((body.len().try_into()?, Some(format!("{err:#}")))),
)
.await?
} else {
res?
Ok(msg) => msg,
};
received_msgs_channel
.send((request_uid, received_msg))
@@ -1557,17 +1573,17 @@ impl Session {
.await?;
let mut got_turn_server = false;
for m in metadata {
if m.entry == "/shared/vendor/deltachat/turn" {
if let Some(value) = m.value {
match create_ice_servers_from_metadata(context, &value).await {
Ok((parsed_timestamp, parsed_ice_servers)) => {
old_metadata.ice_servers_expiration_timestamp = parsed_timestamp;
old_metadata.ice_servers = parsed_ice_servers;
got_turn_server = false;
}
Err(err) => {
warn!(context, "Failed to parse TURN server metadata: {err:#}.");
}
if m.entry == "/shared/vendor/deltachat/turn"
&& let Some(value) = m.value
{
match create_ice_servers_from_metadata(context, &value).await {
Ok((parsed_timestamp, parsed_ice_servers)) => {
old_metadata.ice_servers_expiration_timestamp = parsed_timestamp;
old_metadata.ice_servers = parsed_ice_servers;
got_turn_server = false;
}
Err(err) => {
warn!(context, "Failed to parse TURN server metadata: {err:#}.");
}
}
}
@@ -1852,11 +1868,13 @@ impl Imap {
info!(context, "Scanning folder: {:?}", folder);
// Update the delimiter iff there is a different one, but only once.
if let Some(d) = folder.delimiter() {
if delimiter_is_default && !d.is_empty() && delimiter != d {
delimiter = d.to_string();
delimiter_is_default = false;
}
if let Some(d) = folder.delimiter()
&& delimiter_is_default
&& !d.is_empty()
&& delimiter != d
{
delimiter = d.to_string();
delimiter_is_default = false;
}
let folder_meaning = get_folder_meaning_by_attrs(folder.attributes());
@@ -2097,12 +2115,10 @@ async fn needs_move_to_mvbox(
.get_header_value(HeaderDef::AutoSubmitted)
.filter(|val| val.eq_ignore_ascii_case("auto-generated"))
.is_some()
&& let Some(from) = mimeparser::get_from(headers)
&& context.is_self_addr(&from.addr).await?
{
if let Some(from) = mimeparser::get_from(headers) {
if context.is_self_addr(&from.addr).await? {
return Ok(true);
}
}
return Ok(true);
}
if !context.get_config_bool(Config::MvboxMove).await? {
return Ok(false);
@@ -2272,12 +2288,13 @@ pub(crate) async fn prefetch_should_download(
// We do not know the Message-ID or the Message-ID is missing (in this case, we create one in
// the further process).
if let Some(chat) = prefetch_get_chat(context, headers).await? {
if chat.typ == Chattype::Group && !chat.id.is_special() {
// This might be a group command, like removing a group member.
// We really need to fetch this to avoid inconsistent group state.
return Ok(true);
}
if let Some(chat) = prefetch_get_chat(context, headers).await?
&& chat.typ == Chattype::Group
&& !chat.id.is_special()
{
// This might be a group command, like removing a group member.
// We really need to fetch this to avoid inconsistent group state.
return Ok(true);
}
let maybe_ndn = if let Some(from) = headers.get_header_value(HeaderDef::From_) {
@@ -2417,13 +2434,18 @@ pub(crate) async fn markseen_on_imap_table(context: &Context, message_id: &str)
/// uid_next is the next unique identifier value from the last time we fetched a folder
/// See <https://tools.ietf.org/html/rfc3501#section-2.3.1.1>
/// This function is used to update our uid_next after fetching messages.
pub(crate) async fn set_uid_next(context: &Context, folder: &str, uid_next: u32) -> Result<()> {
pub(crate) async fn set_uid_next(
context: &Context,
transport_id: u32,
folder: &str,
uid_next: u32,
) -> Result<()> {
context
.sql
.execute(
"INSERT INTO imap_sync (folder, uid_next) VALUES (?,?)
ON CONFLICT(folder) DO UPDATE SET uid_next=excluded.uid_next",
(folder, uid_next),
"INSERT INTO imap_sync (transport_id, folder, uid_next) VALUES (?, ?,?)
ON CONFLICT(transport_id, folder) DO UPDATE SET uid_next=excluded.uid_next",
(transport_id, folder, uid_next),
)
.await?;
Ok(())
@@ -2434,57 +2456,69 @@ pub(crate) async fn set_uid_next(context: &Context, folder: &str, uid_next: u32)
/// This method returns the uid_next from the last time we fetched messages.
/// We can compare this to the current uid_next to find out whether there are new messages
/// and fetch from this value on to get all new messages.
async fn get_uid_next(context: &Context, folder: &str) -> Result<u32> {
async fn get_uid_next(context: &Context, transport_id: u32, folder: &str) -> Result<u32> {
Ok(context
.sql
.query_get_value("SELECT uid_next FROM imap_sync WHERE folder=?;", (folder,))
.query_get_value(
"SELECT uid_next FROM imap_sync WHERE transport_id=? AND folder=?",
(transport_id, folder),
)
.await?
.unwrap_or(0))
}
pub(crate) async fn set_uidvalidity(
context: &Context,
transport_id: u32,
folder: &str,
uidvalidity: u32,
) -> Result<()> {
context
.sql
.execute(
"INSERT INTO imap_sync (folder, uidvalidity) VALUES (?,?)
ON CONFLICT(folder) DO UPDATE SET uidvalidity=excluded.uidvalidity",
(folder, uidvalidity),
"INSERT INTO imap_sync (transport_id, folder, uidvalidity) VALUES (?,?,?)
ON CONFLICT(transport_id, folder) DO UPDATE SET uidvalidity=excluded.uidvalidity",
(transport_id, folder, uidvalidity),
)
.await?;
Ok(())
}
async fn get_uidvalidity(context: &Context, folder: &str) -> Result<u32> {
async fn get_uidvalidity(context: &Context, transport_id: u32, folder: &str) -> Result<u32> {
Ok(context
.sql
.query_get_value(
"SELECT uidvalidity FROM imap_sync WHERE folder=?;",
(folder,),
"SELECT uidvalidity FROM imap_sync WHERE transport_id=? AND folder=?",
(transport_id, folder),
)
.await?
.unwrap_or(0))
}
pub(crate) async fn set_modseq(context: &Context, folder: &str, modseq: u64) -> Result<()> {
pub(crate) async fn set_modseq(
context: &Context,
transport_id: u32,
folder: &str,
modseq: u64,
) -> Result<()> {
context
.sql
.execute(
"INSERT INTO imap_sync (folder, modseq) VALUES (?,?)
ON CONFLICT(folder) DO UPDATE SET modseq=excluded.modseq",
(folder, modseq),
"INSERT INTO imap_sync (transport_id, folder, modseq) VALUES (?,?,?)
ON CONFLICT(transport_id, folder) DO UPDATE SET modseq=excluded.modseq",
(transport_id, folder, modseq),
)
.await?;
Ok(())
}
async fn get_modseq(context: &Context, folder: &str) -> Result<u64> {
async fn get_modseq(context: &Context, transport_id: u32, folder: &str) -> Result<u64> {
Ok(context
.sql
.query_get_value("SELECT modseq FROM imap_sync WHERE folder=?;", (folder,))
.query_get_value(
"SELECT modseq FROM imap_sync WHERE transport_id=? AND folder=?",
(transport_id, folder),
)
.await?
.unwrap_or(0))
}
@@ -2501,21 +2535,6 @@ pub(crate) async fn get_imap_self_sent_search_command(context: &Context) -> Resu
Ok(search_command)
}
/// Deprecated, use get_uid_next() and get_uidvalidity()
pub async fn get_config_last_seen_uid(context: &Context, folder: &str) -> Result<(u32, u32)> {
let key = format!("imap.mailbox.{folder}");
if let Some(entry) = context.sql.get_raw_config(&key).await? {
// the entry has the format `imap.mailbox.<folder>=<uidvalidity>:<lastseenuid>`
let mut parts = entry.split(':');
Ok((
parts.next().unwrap_or_default().parse().unwrap_or(0),
parts.next().unwrap_or_default().parse().unwrap_or(0),
))
} else {
Ok((0, 0))
}
}
/// Whether to ignore fetching messages from a folder.
///
/// This caters for the [`Config::OnlyFetchMvbox`] setting which means mails from folders
@@ -2539,11 +2558,11 @@ fn build_sequence_sets(uids: &[u32]) -> Result<Vec<(Vec<u32>, String)>> {
let mut ranges: Vec<UidRange> = vec![];
for &current in uids {
if let Some(last) = ranges.last_mut() {
if last.end + 1 == current {
last.end = current;
continue;
}
if let Some(last) = ranges.last_mut()
&& last.end + 1 == current
{
last.end = current;
continue;
}
ranges.push(UidRange {

View File

@@ -8,14 +8,15 @@ use tokio::io::BufWriter;
use super::capabilities::Capabilities;
use crate::context::Context;
use crate::log::{LoggingStream, info, warn};
use crate::login_param::{ConnectionCandidate, ConnectionSecurity};
use crate::log::{LoggingStream, warn};
use crate::net::dns::{lookup_host_with_cache, update_connect_timestamp};
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionStream;
use crate::net::tls::wrap_tls;
use crate::net::{connect_tcp_inner, run_connection_attempts, update_connection_history};
use crate::tools::time;
use crate::transport::ConnectionCandidate;
use crate::transport::ConnectionSecurity;
#[derive(Debug)]
pub(crate) struct Client {

View File

@@ -8,7 +8,7 @@ use tokio::time::timeout;
use super::Imap;
use super::session::Session;
use crate::context::Context;
use crate::log::{info, warn};
use crate::log::warn;
use crate::net::TIMEOUT;
use crate::tools::{self, time_elapsed};

View File

@@ -11,17 +11,23 @@ fn test_get_folder_meaning_by_name() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_uid_next_validity() {
let t = TestContext::new_alice().await;
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0);
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 0);
assert_eq!(get_uid_next(&t.ctx, 1, "Inbox").await.unwrap(), 0);
assert_eq!(get_uidvalidity(&t.ctx, 1, "Inbox").await.unwrap(), 0);
set_uidvalidity(&t.ctx, "Inbox", 7).await.unwrap();
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 7);
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0);
set_uidvalidity(&t.ctx, 1, "Inbox", 7).await.unwrap();
assert_eq!(get_uidvalidity(&t.ctx, 1, "Inbox").await.unwrap(), 7);
assert_eq!(get_uid_next(&t.ctx, 1, "Inbox").await.unwrap(), 0);
set_uid_next(&t.ctx, "Inbox", 5).await.unwrap();
set_uidvalidity(&t.ctx, "Inbox", 6).await.unwrap();
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 5);
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 6);
// For another transport there is still no UIDVALIDITY set.
assert_eq!(get_uidvalidity(&t.ctx, 2, "Inbox").await.unwrap(), 0);
set_uid_next(&t.ctx, 1, "Inbox", 5).await.unwrap();
set_uidvalidity(&t.ctx, 1, "Inbox", 6).await.unwrap();
assert_eq!(get_uid_next(&t.ctx, 1, "Inbox").await.unwrap(), 5);
assert_eq!(get_uidvalidity(&t.ctx, 1, "Inbox").await.unwrap(), 6);
assert_eq!(get_uid_next(&t.ctx, 2, "Inbox").await.unwrap(), 0);
assert_eq!(get_uidvalidity(&t.ctx, 2, "Inbox").await.unwrap(), 0);
}
#[test]

View File

@@ -5,7 +5,7 @@ use anyhow::{Context as _, Result};
use super::{get_folder_meaning_by_attrs, get_folder_meaning_by_name};
use crate::config::Config;
use crate::imap::{Imap, session::Session};
use crate::log::{LogExt, info};
use crate::log::LogExt;
use crate::tools::{self, time_elapsed};
use crate::{context::Context, imap::FolderMeaning};

View File

@@ -5,7 +5,8 @@ use anyhow::Context as _;
use super::session::Session as ImapSession;
use super::{get_uid_next, get_uidvalidity, set_modseq, set_uid_next, set_uidvalidity};
use crate::context::Context;
use crate::log::{info, warn};
use crate::ensure_and_debug_assert;
use crate::log::warn;
type Result<T> = std::result::Result<T, Error>;
@@ -34,16 +35,16 @@ impl ImapSession {
/// because no EXPUNGE responses are sent, see
/// <https://tools.ietf.org/html/rfc3501#section-6.4.2>
pub(super) async fn maybe_close_folder(&mut self, context: &Context) -> anyhow::Result<()> {
if let Some(folder) = &self.selected_folder {
if self.selected_folder_needs_expunge {
info!(context, "Expunge messages in {folder:?}.");
if let Some(folder) = &self.selected_folder
&& self.selected_folder_needs_expunge
{
info!(context, "Expunge messages in {folder:?}.");
self.close().await.context("IMAP close/expunge failed")?;
info!(context, "Close/expunge succeeded.");
self.selected_folder = None;
self.selected_folder_needs_expunge = false;
self.new_mail = false;
}
self.close().await.context("IMAP close/expunge failed")?;
info!(context, "Close/expunge succeeded.");
self.selected_folder = None;
self.selected_folder_needs_expunge = false;
self.new_mail = false;
}
Ok(())
}
@@ -54,10 +55,10 @@ impl ImapSession {
async fn select_folder(&mut self, context: &Context, folder: &str) -> Result<NewlySelected> {
// if there is a new folder and the new folder is equal to the selected one, there's nothing to do.
// if there is _no_ new folder, we continue as we might want to expunge below.
if let Some(selected_folder) = &self.selected_folder {
if folder == selected_folder {
return Ok(NewlySelected::No);
}
if let Some(selected_folder) = &self.selected_folder
&& folder == selected_folder
{
return Ok(NewlySelected::No);
}
// deselect existing folder, if needed (it's also done implicitly by SELECT, however, without EXPUNGE then)
@@ -129,7 +130,7 @@ impl ImapSession {
context: &Context,
folder: &str,
create: bool,
) -> Result<bool> {
) -> anyhow::Result<bool> {
let newly_selected = if create {
self.select_or_create_folder(context, folder)
.await
@@ -146,15 +147,24 @@ impl ImapSession {
},
}
};
let transport_id = self.transport_id();
// Folders should not be selected when transport_id is not assigned yet
// because we cannot save UID validity then.
ensure_and_debug_assert!(
transport_id > 0,
"Cannot select folder when transport ID is unknown"
);
let mailbox = self
.selected_mailbox
.as_mut()
.with_context(|| format!("No mailbox selected, folder: {folder:?}"))?;
let old_uid_validity = get_uidvalidity(context, folder)
let old_uid_validity = get_uidvalidity(context, transport_id, folder)
.await
.with_context(|| format!("Failed to get old UID validity for folder {folder:?}"))?;
let old_uid_next = get_uid_next(context, folder)
let old_uid_next = get_uid_next(context, transport_id, folder)
.await
.with_context(|| format!("Failed to get old UID NEXT for folder {folder:?}"))?;
@@ -205,8 +215,8 @@ impl ImapSession {
context,
"The server illegally decreased the uid_next of folder {folder:?} from {old_uid_next} to {new_uid_next} without changing validity ({new_uid_validity}), resyncing UIDs...",
);
set_uid_next(context, folder, new_uid_next).await?;
context.schedule_resync().await?;
set_uid_next(context, transport_id, folder, new_uid_next).await?;
self.resync_request_sender.try_send(()).ok();
}
// If UIDNEXT changed, there are new emails.
@@ -224,35 +234,30 @@ impl ImapSession {
}
// UIDVALIDITY is modified, reset highest seen MODSEQ.
set_modseq(context, folder, 0).await?;
set_modseq(context, transport_id, folder, 0).await?;
// ============== uid_validity has changed or is being set the first time. ==============
let new_uid_next = new_uid_next.unwrap_or_default();
set_uid_next(context, folder, new_uid_next).await?;
set_uidvalidity(context, folder, new_uid_validity).await?;
set_uid_next(context, transport_id, folder, new_uid_next).await?;
set_uidvalidity(context, transport_id, folder, new_uid_validity).await?;
self.new_mail = true;
// Collect garbage entries in `imap` table.
context
.sql
.execute(
"DELETE FROM imap WHERE folder=? AND uidvalidity!=?",
(&folder, new_uid_validity),
"DELETE FROM imap WHERE transport_id=? AND folder=? AND uidvalidity!=?",
(transport_id, &folder, new_uid_validity),
)
.await?;
if old_uid_validity != 0 || old_uid_next != 0 {
context.schedule_resync().await?;
self.resync_request_sender.try_send(()).ok();
}
info!(
context,
"uid/validity change folder {}: new {}/{} previous {}/{}.",
folder,
new_uid_next,
new_uid_validity,
old_uid_next,
old_uid_validity,
"transport {transport_id}: UID validity for folder {folder} changed from {old_uid_validity}/{old_uid_next} to {new_uid_validity}/{new_uid_next}.",
);
Ok(true)
}

View File

@@ -30,6 +30,8 @@ const PREFETCH_FLAGS: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIE
#[derive(Debug)]
pub(crate) struct Session {
transport_id: u32,
pub(super) inner: ImapSession<Box<dyn SessionStream>>,
pub capabilities: Capabilities,
@@ -48,6 +50,8 @@ pub(crate) struct Session {
///
/// Should be false if no folder is currently selected.
pub new_mail: bool,
pub resync_request_sender: async_channel::Sender<()>,
}
impl Deref for Session {
@@ -68,8 +72,11 @@ impl Session {
pub(crate) fn new(
inner: ImapSession<Box<dyn SessionStream>>,
capabilities: Capabilities,
resync_request_sender: async_channel::Sender<()>,
transport_id: u32,
) -> Self {
Self {
transport_id,
inner,
capabilities,
selected_folder: None,
@@ -77,9 +84,15 @@ impl Session {
selected_folder_needs_expunge: false,
last_full_folder_scan: Mutex::new(None),
new_mail: false,
resync_request_sender,
}
}
/// Returns ID of the transport for which this session was created.
pub(crate) fn transport_id(&self) -> u32 {
self.transport_id
}
pub fn can_idle(&self) -> bool {
self.capabilities.can_idle
}

View File

@@ -20,7 +20,7 @@ use crate::context::Context;
use crate::e2ee;
use crate::events::EventType;
use crate::key::{self, DcKey, DcSecretKey, SignedPublicKey, SignedSecretKey};
use crate::log::{LogExt, error, info, warn};
use crate::log::{LogExt, warn};
use crate::pgp;
use crate::qr::DCBACKUP_VERSION;
use crate::sql;
@@ -377,7 +377,15 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
res = check_backup_version(context).await;
}
if res.is_ok() {
res = adjust_bcc_self(context).await;
// 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
@@ -751,7 +759,7 @@ async fn export_database(
.to_str()
.with_context(|| format!("path {} is not valid unicode", dest.display()))?;
adjust_bcc_self(context).await?;
context.set_config(Config::BccSelf, Some("1")).await?;
context
.sql
.set_raw_config_int("backup_time", timestamp)
@@ -785,18 +793,6 @@ async fn export_database(
.await
}
/// Sets `Config::BccSelf` (and `DeleteServerAfter` to "never" in effect) if needed so that new
/// messages are present on the server after a backup restoration or available for all devices in
/// multi-device case. NB: Calling this after a backup import isn't reliable as we can crash in
/// between, but this is a problem only for old backups, new backups already have `BccSelf` set if
/// necessary.
async fn adjust_bcc_self(context: &Context) -> Result<()> {
if context.is_chatmail().await? && !context.config_exists(Config::BccSelf).await? {
context.set_config(Config::BccSelf, Some("1")).await?;
}
Ok(())
}
async fn check_backup_version(context: &Context) -> Result<()> {
let version = (context.sql.get_raw_config_int("backup_version").await?).unwrap_or(2);
ensure!(
@@ -983,11 +979,10 @@ mod tests {
let context1 = &TestContext::new_alice().await;
// `bcc_self` is enabled by default for test contexts. Unset it.
context1.set_config(Config::BccSelf, None).await?;
// Check that the settings are displayed correctly.
assert_eq!(
context1.get_config(Config::BccSelf).await?,
Some("1".to_string())
);
assert_eq!(
context1.get_config(Config::DeleteServerAfter).await?,
Some("0".to_string())

View File

@@ -93,7 +93,7 @@ pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result<St
let private_key = load_self_secret_key(context).await?;
let ac_headers = Some(("Autocrypt-Prefer-Encrypt", "mutual"));
let private_key_asc = private_key.to_asc(ac_headers);
let encr = pgp::symm_encrypt(passphrase, private_key_asc.into_bytes())
let encr = pgp::symm_encrypt_autocrypt_setup(passphrase, private_key_asc.into_bytes())
.await?
.replace('\n', "\r\n");

View File

@@ -41,7 +41,7 @@ use tokio_util::sync::CancellationToken;
use crate::chat::add_device_msg;
use crate::context::Context;
use crate::imex::BlobDirContents;
use crate::log::{info, warn};
use crate::log::warn;
use crate::message::Message;
use crate::qr::Qr;
use crate::stock_str::backup_transfer_msg_body;
@@ -69,7 +69,7 @@ pub struct BackupProvider {
_endpoint: Endpoint,
/// iroh address.
node_addr: iroh::EndpointAddr,
node_addr: iroh::NodeAddr,
/// Authentication token that should be submitted
/// to retrieve the backup.
@@ -96,11 +96,12 @@ impl BackupProvider {
pub async fn prepare(context: &Context) -> Result<Self> {
let relay_mode = RelayMode::Disabled;
let endpoint = Endpoint::builder()
.tls_x509() // For compatibility with iroh <0.34.0
.alpns(vec![BACKUP_ALPN.to_vec()])
.relay_mode(relay_mode)
.bind()
.await?;
let node_addr = endpoint.addr();
let node_addr = endpoint.node_addr().await?;
// Acquire global "ongoing" mutex.
let cancel_token = context.alloc_ongoing().await?;
@@ -297,12 +298,16 @@ impl Future for BackupProvider {
pub async fn get_backup2(
context: &Context,
node_addr: iroh::EndpointAddr,
node_addr: iroh::NodeAddr,
auth_token: String,
) -> Result<()> {
let relay_mode = RelayMode::Disabled;
let endpoint = Endpoint::builder().relay_mode(relay_mode).bind().await?;
let endpoint = Endpoint::builder()
.tls_x509() // For compatibility with iroh <0.34.0
.relay_mode(relay_mode)
.bind()
.await?;
let conn = endpoint.connect(node_addr, BACKUP_ALPN).await?;
let (mut send_stream, mut recv_stream) = conn.open_bi().await?;

View File

@@ -0,0 +1,38 @@
//! Re-exports of `pub(crate)` functions that are needed for benchmarks.
#![allow(missing_docs)] // Not necessary to put a doc comment on the pub functions here
use anyhow::Result;
use deltachat_contact_tools::EmailAddress;
use crate::chat::ChatId;
use crate::context::Context;
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<()> {
key::store_self_keypair(context, keypair).await
}
pub async fn parse_and_get_text(context: &Context, imf_raw: &[u8]) -> Result<String> {
let mime_parser = MimeMessage::from_bytes(context, imf_raw, None).await?;
Ok(mime_parser.parts.into_iter().next().unwrap().msg)
}
pub async fn save_broadcast_secret(context: &Context, chat_id: ChatId, secret: &str) -> Result<()> {
crate::chat::save_broadcast_secret(context, chat_id, secret).await
}
pub fn create_dummy_keypair(addr: &str) -> Result<KeyPair> {
pgp::create_keypair(EmailAddress::new(addr)?)
}
pub fn create_broadcast_secret() -> String {
crate::tools::create_broadcast_secret()
}

View File

@@ -15,7 +15,7 @@ use tokio::runtime::Handle;
use crate::context::Context;
use crate::events::EventType;
use crate::log::{LogExt, info};
use crate::log::LogExt;
use crate::pgp::KeyPair;
use crate::tools::{self, time_elapsed};
@@ -159,13 +159,15 @@ pub(crate) async fn load_self_public_key(context: &Context) -> Result<SignedPubl
pub(crate) async fn load_self_public_keyring(context: &Context) -> Result<Vec<SignedPublicKey>> {
let keys = context
.sql
.query_map(
.query_map_vec(
r#"SELECT public_key
FROM keypairs
ORDER BY id=(SELECT value FROM config WHERE keyname='key_id') DESC"#,
(),
|row| row.get::<_, Vec<u8>>(0),
|keys| keys.collect::<Result<Vec<_>, _>>().map_err(Into::into),
|row| {
let public_key_bytes: Vec<u8> = row.get(0)?;
Ok(public_key_bytes)
},
)
.await?
.into_iter()
@@ -232,13 +234,15 @@ pub(crate) async fn load_self_secret_key(context: &Context) -> Result<SignedSecr
pub(crate) async fn load_self_secret_keyring(context: &Context) -> Result<Vec<SignedSecretKey>> {
let keys = context
.sql
.query_map(
.query_map_vec(
r#"SELECT private_key
FROM keypairs
ORDER BY id=(SELECT value FROM config WHERE keyname='key_id') DESC"#,
(),
|row| row.get::<_, Vec<u8>>(0),
|keys| keys.collect::<Result<Vec<_>, _>>().map_err(Into::into),
|row| {
let bytes: Vec<u8> = row.get(0)?;
Ok(bytes)
},
)
.await?
.into_iter()

View File

@@ -75,7 +75,10 @@ mod mimefactory;
pub mod mimeparser;
pub mod oauth2;
mod param;
#[cfg(not(feature = "internals"))]
mod pgp;
#[cfg(feature = "internals")]
pub mod pgp;
pub mod provider;
pub mod qr;
pub mod qr_code_generator;
@@ -86,9 +89,11 @@ pub mod securejoin;
mod simplify;
mod smtp;
pub mod stock_str;
pub mod storage_usage;
mod sync;
mod timesmearing;
mod token;
mod transport;
mod update_helper;
pub mod webxdc;
#[macro_use]
@@ -98,7 +103,7 @@ pub mod color;
pub mod html;
pub mod net;
pub mod plaintext;
mod push;
pub mod push;
mod stats;
pub use stats::SecurejoinSource;
pub use stats::SecurejoinUiPath;
@@ -112,6 +117,9 @@ pub mod accounts;
pub mod peer_channels;
pub mod reaction;
#[cfg(feature = "internals")]
pub mod internals_for_benches;
/// If set IMAP/incoming and SMTP/outgoing MIME messages will be printed.
pub const DCC_MIME_DEBUG: &str = "DCC_MIME_DEBUG";

View File

@@ -22,7 +22,7 @@ use crate::constants::DC_CHAT_ID_TRASH;
use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
use crate::log::{info, warn};
use crate::log::warn;
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::SystemMessage;
use crate::tools::{duration_to_str, time};
@@ -294,7 +294,7 @@ pub async fn send_locations_to_chat(
.unwrap_or_default();
} else if 0 == seconds && is_sending_locations_before {
let stock_str = stock_str::msg_location_disabled(context).await;
chat::add_info_msg(context, chat_id, &stock_str, now).await?;
chat::add_info_msg(context, chat_id, &stock_str).await?;
}
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);
@@ -348,7 +348,10 @@ pub async fn set(context: &Context, latitude: f64, longitude: f64, accuracy: f64
.query_map_vec(
"SELECT id FROM chats WHERE locations_send_until>?;",
(now,),
|row| row.get::<_, i32>(0),
|row| {
let id: i32 = row.get(0)?;
Ok(id)
},
)
.await?;
@@ -846,7 +849,7 @@ async fn maybe_send_locations(context: &Context) -> Result<Option<u64>> {
.context("failed to disable location streaming")?;
let stock_str = stock_str::msg_location_disabled(context).await;
chat::add_info_msg(context, chat_id, &stock_str, now).await?;
chat::add_info_msg(context, chat_id, &stock_str).await?;
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);
}

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