Compare commits

...
Author SHA1 Message Date
WofWca 7a7f040200 perf: JSON-RPC: faster eventLoop: request buffer
Before this commit we would not start another `getNextEvent()`
before we have received a response for the previous one.
This commit makes a pool of `getNextEvent()` requests
so that the server always has a request
that it can readily respond to.

Measurements

I have tested this on Delta Chat desktop.
This seems to provide a measurable speedup in handling event bursts.

I did `console.time()` when we start
a `BackendRemote.rpc.maybeNetwork()` request
(which we do every time the main window is focused),
and a `console.timeEnd()` when we receive the first
"IDLE entering wait-on-remote state" entry, inside of
[`ipcBackend.on('json-rpc-message'`](https://github.com/deltachat/deltachat-desktop/blob/3846aef67c1644d8ec8b57b229329855a378ee4e/packages/target-electron/runtime-electron/runtime.ts#L52).
For these measurements I also disabled request-response logging
with `config['log-debug'] = false`.

Each such `maybeNetwork()` resulted in ~1000 `getNextEvent()` responses.

With the original event loop (without a pool) the average time
based on 150 measurements was 1152.28 ms.
With the new event loop with a pool of 20
based on 150 measurements it was 774.58 ms.

That is 67.22% of the original average duration.

Related:
- https://github.com/deltachat/deltachat-desktop/issues/5282
2026-02-01 15:19:07 +04:00
missytake 50a73666fd api(jsonrpc): process events forever by default 2026-01-31 15:56:13 +01:00
iequidoo 61a8eff2ad fix: receive_imf: Look up key contact by intended recipient fingerprint (#7661)
For now, do this only for `OneOneChat` and `MailingListOrBroadcast`, this is enough to correctly
support messages from modern Delta Chat versions sending Intended Recipient Fingerprint subpackets
and single-recipient messages from modern versions of other MUAs.
2026-01-30 07:53:44 -03:00
iequidoo cbd379fdf0 feat: Trash messages with intended recipient fingerprints, but w/o our one included 2026-01-30 07:53:44 -03:00
iequidoo fe826f762e fix: add_or_lookup_key_contacts*(): Advance fingerprint_iter on invalid address 2026-01-30 07:53:44 -03:00
iequidoo 2019debe99 refactor: Rename lookup_key_contacts_by_address_list() to lookup_key_contacts_fallback_to_chat()
It only looks up contacts by address in the given chat, so `_fallback_to_chat` suffix is more
informative. It's obvious that such a lookup is done by address, there are no other reasonable
options.
2026-01-30 07:53:44 -03:00
iequidoo 6c4f4bfd19 test: Message in blocked chat arrives as InSeen
It's strange that this wasn't covered by any test.
2026-01-30 07:53:44 -03:00
iequidoo 44b0736216 test: Encrypted incoming message goes to encrypted 1:1 chat even if references messages in ad-hoc group
This is an important thing forgotten to be checked in 332527089.

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

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

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

---------

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

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

The additional reduction of the image-resolution was added in
https://github.com/chatmail/core/commit/b7864f232bbac4c585e75f1a3073d1175727e2f9.
It seems that back then only resolution-reduction was used to make the
file-size of images smaller, and the purpose was to skip an unnecessary
encoding-step.

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

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

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

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

---------

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

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

will also be more efficient, because desktop currently loads all fresh
messages to find out which chats to mark as noticed.
https://github.com/deltachat/deltachat-desktop/blob/76d32bfc9364d53e1ffea40fcf4a60ad9a8482ee/packages/frontend/src/components/AccountListSidebar/AccountItem.tsx#L334


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

---------

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

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

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

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

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

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

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

One potential problem with sending MDNs is that it may trigger ratelimits on some providers and
count as another recipient.
2026-01-17 20:54:35 -03:00
link2xt 659d21aa9d docs: fix formatting of indoc! link 2026-01-17 14:40:17 +00:00
link2xt 8f604e74ec fix: do not resolve ICE server hostnames during IMAP loop
Hostname resolution may timeout if DNS servers are not responding.
It is also not necessary to resolve fallback ICE server hostnames
if the user is not going to use calls.
2026-01-17 12:55:21 +00:00
Simon Laux e1ebf3e96d refactor: don't use concat! in sql statements (#7720) 2026-01-15 22:44:53 +00:00
Simon Laux 76171aea2e fix: hide incoming broadcasts in DC_GCL_FOR_FORWARDING (#7726)
you can't write to those chats, so you also can not forward to them.

Closes #7702
2026-01-15 22:26:05 +00:00
Hocuri 96b8d1720e fix: Use only lowercase letters for stats id (#7700)
If the user enables statistics-sending in the advanced settings, they
will be asked whether they also want to take part in a survey. We use a
short ID to then link the survey result to the sent statistics.

However, the survey website didn't like our base64 ids, especially the
fact that the id could contain a `-`. This PR makes it so that the id
only contains lowecase letters.
2026-01-15 18:51:04 +01:00
68 changed files with 1620 additions and 1155 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.92.0
RUST_VERSION: 1.93.0
# Minimum Supported Rust Version
MSRV: 1.88.0
+76
View File
@@ -1,5 +1,80 @@
# Changelog
## [2.39.0] - 2026-01-23
### CI
- Update Rust to 1.93.0.
### Documentation
- RELEASE.md: Push preparation commit to the main branch before tagging.
- RELEASE.md: Add section about dealing with failed releases.
### Fixes
- Forward message with file ([#7755](https://github.com/chatmail/core/pull/7755)).
- Do not additionally reduce the resolution of images that fit into the resolution-limit and are larger than the file-size-limit ([#7760](https://github.com/chatmail/core/pull/7760)).
### Miscellaneous Tasks
- Merge v2.38.0 into main branch.
- Cleanup deprecated functions/defines ([#7763](https://github.com/chatmail/core/pull/7763)).
## [2.38.0] - 2026-01-22
### API-Changes
- [**breaking**] Jsonrpc: remove `contacts` from `FullChat`. To migrate load contacts on demand via `get_contacts_by_ids` using `FullChat.contactIds` ([#7282](https://github.com/chatmail/core/pull/7282)).
- jsonrpc: Add run_until parameter for bots ([#7688](https://github.com/chatmail/core/pull/7688)).
- rust, jsonrpc: Add `get_message_read_receipt_count` method ([#7732](https://github.com/chatmail/core/pull/7732)).
- rust and jsonrpc: Marknoticed_all_chats method to mark all chats as notices, including muted ones. ([#7709](https://github.com/chatmail/core/pull/7709)).
- Public re-export of Connectivity ([#7737](https://github.com/chatmail/core/pull/7737)).
### Documentation
- Fix chat types.
- Set_config_from_qr() configures context for "DCACCOUNT:" and "DCLOGIN:" QRs ([#7450](https://github.com/chatmail/core/pull/7450)).
- Fix formatting of `indoc!` link.
### Features / Changes
- Pre-messages / next version of download on demand ([#7371](https://github.com/chatmail/core/pull/7371)).
- Connectivity view: move quota up and combine with IMAP state. ([#7653](https://github.com/chatmail/core/pull/7653)).
- Execute sync message before checking for primary transport update.
- Disable partial search by contact address.
- Don't put text into post-message ([#7714](https://github.com/chatmail/core/pull/7714)).
- Don't scale up Origin of multiple and broadcast recipients when sending a message.
- pgp: Use preferred hash algorithm for signing instead of hardcoded SHA256.
- In teamprofiles, don't mark chat as read on outgoing message ([#7717](https://github.com/chatmail/core/pull/7717)).
- Send and apply MDNs to self ([#7005](https://github.com/chatmail/core/pull/7005))
### Fixes
- Do not show contact address in message info ([#7695](https://github.com/chatmail/core/pull/7695)).
- Take transport_id into account when marking messages with \Seen flags.
- Send bcc-self messages to all own relays ([#7656](https://github.com/chatmail/core/pull/7656)).
- Only emit TransportsModified if transports are really modified.
- Logging errors in deltachat-rpc-server during startup ([#7707](https://github.com/chatmail/core/pull/7707)).
- Use only lowercase letters for stats id ([#7700](https://github.com/chatmail/core/pull/7700)).
- Hide incoming broadcasts in `DC_GCL_FOR_FORWARDING` ([#7726](https://github.com/chatmail/core/pull/7726)).
- Do not resolve ICE server hostnames during IMAP loop.
- More reliable parsing of `dclogin:` links with ip address as host ([#7734](https://github.com/chatmail/core/pull/7734)).
- Don't remember old channel members in the database ([#7716](https://github.com/chatmail/core/pull/7716)).
- Make it possible to leave and immediately delete a chat ([#7744](https://github.com/chatmail/core/pull/7744)).
- Emit MsgsChanged instead of MsgsNoticed on self-MDN if chat still has fresh messages.
- Prevent possible infinite loop with invalid `smtp` row ([#7746](https://github.com/chatmail/core/pull/7746)).
- Sync broadcast subscribers list ([#7578](https://github.com/chatmail/core/pull/7578))
### Refactor
- Don't use `concat!` in sql statements ([#7720](https://github.com/chatmail/core/pull/7720)).
### Tests
- Port test_dont_move_sync_msgs to JSON-RPC ([#7676](https://github.com/chatmail/core/pull/7676)).
- rpc-client: Replace remaining print()s with `logging` ([#6082](https://github.com/chatmail/core/pull/6082)).
## [2.37.0] - 2026-01-08
### API-Changes
@@ -7543,3 +7618,4 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.35.0]: https://github.com/chatmail/core/compare/v2.34.0..v2.35.0
[2.36.0]: https://github.com/chatmail/core/compare/v2.35.0..v2.36.0
[2.37.0]: https://github.com/chatmail/core/compare/v2.36.0..v2.37.0
[2.38.0]: https://github.com/chatmail/core/compare/v2.37.0..v2.38.0
Generated
+5 -5
View File
@@ -1303,7 +1303,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.37.0"
version = "2.39.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1413,7 +1413,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.37.0"
version = "2.39.0"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1434,7 +1434,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "2.37.0"
version = "2.39.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1450,7 +1450,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.37.0"
version = "2.39.0"
dependencies = [
"anyhow",
"deltachat",
@@ -1479,7 +1479,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.37.0"
version = "2.39.0"
dependencies = [
"anyhow",
"deltachat",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.37.0"
version = "2.39.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.88"
+13 -4
View File
@@ -1,4 +1,4 @@
# Releasing a new version of DeltaChat core
# Releasing a new version of chatmail core
For example, to release version 1.116.0 of the core, do the following steps.
@@ -14,8 +14,17 @@ For example, to release version 1.116.0 of the core, do the following steps.
5. Commit the changes as `chore(release): prepare for 1.116.0`.
Optionally, use a separate branch like `prep-1.116.0` for this commit and open a PR for review.
6. Tag the release: `git tag --annotate v1.116.0`.
6. Push the commit to the `main` branch.
7. Push the release tag: `git push origin v1.116.0`.
7. Once the commit is on the `main` branch and passed CI, tag the release: `git tag --annotate v1.116.0`.
8. Create a GitHub release: `gh release create v1.116.0 --notes ''`.
8. Push the release tag: `git push origin v1.116.0`.
9. Create a GitHub release: `gh release create v1.116.0 --notes ''`.
## Dealing with failed releases
Once you make a GitHub release,
CI will try to build and publish [PyPI](https://pypi.org/) and [npm](https://www.npmjs.com/) packages.
If this fails for some reason, do not modify the failed tag, do not delete it and do not force-push to the `main` branch.
Fix the build process and tag a new release instead.
+1 -1
View File
@@ -21,7 +21,7 @@ text TEXT DEFAULT '' NOT NULL -- message text
```
Do not use macros like [`concat!`](https://doc.rust-lang.org/std/macro.concat.html)
or [`indoc!](https://docs.rs/indoc).
or [`indoc!`](https://docs.rs/indoc).
Do not escape newlines like this:
```
sql.execute(
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "2.37.0"
version = "2.39.0"
description = "Deltachat FFI"
edition = "2018"
readme = "README.md"
+3 -257
View File
@@ -894,7 +894,7 @@ int dc_preconfigure_keypair (dc_context_t* context, const cha
* the pseudo-chat DC_CHAT_ID_ARCHIVED_LINK is added if there are _any_ archived
* chats
* - the flag DC_GCL_FOR_FORWARDING sorts "Saved messages" to the top of the chatlist
* and hides the "Device chat" and contact requests.
* and hides the "Device chat", contact requests and incoming broadcasts.
* typically used on forwarding, may be combined with DC_GCL_NO_SPECIALS
* to also hide the archive link.
* - if the flag DC_GCL_NO_SPECIALS is set, archive link is not added
@@ -1563,7 +1563,7 @@ dc_array_t* dc_wait_next_msgs (dc_context_t* context);
* Mark all messages in a chat as _noticed_.
* _Noticed_ messages are no longer _fresh_ and do not count as being unseen
* but are still waiting for being marked as "seen" using dc_markseen_msgs()
* (IMAP/MDNs is not done for noticed messages).
* (read receipts aren't sent for noticed messages).
*
* Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
* See also dc_markseen_msgs().
@@ -2220,10 +2220,6 @@ uint32_t dc_lookup_contact_id_by_addr (dc_context_t* context, const char*
uint32_t dc_create_contact (dc_context_t* context, const char* name, const char* addr);
// Deprecated 2025-05-20, setting this flag is a no-op.
#define DC_GCL_DEPRECATED_VERIFIED_ONLY 0x01
#define DC_GCL_ADD_SELF 0x02
#define DC_GCL_ADDRESS 0x04
@@ -2296,17 +2292,6 @@ dc_array_t* dc_import_vcard (dc_context_t* context, const char*
dc_array_t* dc_get_contacts (dc_context_t* context, uint32_t flags, const char* query);
/**
* Get the number of blocked contacts.
*
* @deprecated Deprecated 2021-02-22, use dc_array_get_cnt() on dc_get_blocked_contacts() instead.
* @memberof dc_context_t
* @param context The context object.
* @return The number of blocked contacts.
*/
int dc_get_blocked_cnt (dc_context_t* context);
/**
* Get blocked contacts.
*
@@ -2579,7 +2564,6 @@ void dc_stop_ongoing_process (dc_context_t* context);
#define DC_QR_FPR_MISMATCH 220 // id=contact
#define DC_QR_FPR_WITHOUT_ADDR 230 // test1=formatted fingerprint
#define DC_QR_ACCOUNT 250 // text1=domain
#define DC_QR_BACKUP 251 // deprecated
#define DC_QR_BACKUP2 252
#define DC_QR_BACKUP_TOO_NEW 255
#define DC_QR_PROXY 271 // text1=address (e.g. "127.0.0.1:9050")
@@ -4675,7 +4659,6 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
#define DC_INFO_LOCATION_ONLY 9
#define DC_INFO_EPHEMERAL_TIMER_CHANGED 10
#define DC_INFO_PROTECTION_ENABLED 11
#define DC_INFO_PROTECTION_DISABLED 12 // deprecated 2025-07
#define DC_INFO_INVALID_UNENCRYPTED_MAIL 13
#define DC_INFO_WEBXDC_INFO_MESSAGE 32
#define DC_INFO_CHAT_E2EE 50
@@ -7022,61 +7005,16 @@ void dc_event_unref(dc_event_t* event);
/// Used in summaries.
#define DC_STR_FILE 12
/// "Group name changed from %1$s to %2$s."
///
/// Used in status messages for group name changes.
/// - %1$s will be replaced by the old group name
/// - %2$s will be replaced by the new group name
///
/// @deprecated 2022-09-10
#define DC_STR_MSGGRPNAME 15
/// "Group image changed."
///
/// Used in status messages for group images changes.
///
/// @deprecated 2022-09-10
#define DC_STR_MSGGRPIMGCHANGED 16
/// "Member %1$s added."
///
/// Used in status messages for added members.
/// - %1$s will be replaced by the name of the added member
///
/// @deprecated 2022-09-10
#define DC_STR_MSGADDMEMBER 17
/// "Member %1$s removed."
///
/// Used in status messages for removed members.
/// - %1$s will be replaced by the name of the removed member
///
/// @deprecated 2022-09-10
#define DC_STR_MSGDELMEMBER 18
/// "Group left."
///
/// Used in status messages.
///
/// @deprecated 2022-09-10
#define DC_STR_MSGGROUPLEFT 19
/// "GIF"
///
/// Used in summaries.
#define DC_STR_GIF 23
/// @deprecated 2025-07, this string is no longer needed.
#define DC_STR_ENCRYPTEDMSG 24
/// "End-to-end encryption available."
///
/// Used to build the string returned by dc_get_contact_encrinfo().
/// @deprecated 2026-01-23
#define DC_STR_E2E_AVAILABLE 25
/// @deprecated Deprecated 2021-02-07, this string is no longer needed.
#define DC_STR_ENCR_TRANSP 27
/// "No encryption."
///
/// Used to build the string returned by dc_get_contact_encrinfo().
@@ -7087,90 +7025,23 @@ void dc_event_unref(dc_event_t* event);
/// Used to build the string returned by dc_get_contact_encrinfo().
#define DC_STR_FINGERPRINTS 30
/// "Message opened"
///
/// Used in subjects of outgoing read receipts.
///
/// @deprecated Deprecated 2024-07-26
#define DC_STR_READRCPT 31
/// "The message '%1$s' you sent was displayed on the screen of the recipient."
///
/// Used as message text of outgoing read receipts.
/// - %1$s will be replaced by the subject of the displayed message
///
/// @deprecated Deprecated 2024-06-23
#define DC_STR_READRCPT_MAILBODY 32
/// @deprecated Deprecated, this string is no longer needed.
#define DC_STR_MSGGRPIMGDELETED 33
/// "End-to-end encryption preferred."
///
/// Used to build the string returned by dc_get_contact_encrinfo().
/// @deprecated 2025-06-05
#define DC_STR_E2E_PREFERRED 34
/// "%1$s verified"
///
/// Used in status messages.
/// - %1$s will be replaced by the name of the verified contact
#define DC_STR_CONTACT_VERIFIED 35
/// "Cannot establish guaranteed end-to-end encryption with %1$s."
///
/// Used in status messages.
/// - %1$s will be replaced by the name of the contact that cannot be verified
/// @deprecated 2025-06-05
#define DC_STR_CONTACT_NOT_VERIFIED 36
/// "Changed setup for %1$s."
///
/// Used in status messages.
/// - %1$s will be replaced by the name of the contact with the changed setup
/// @deprecated 2025-06-05
#define DC_STR_CONTACT_SETUP_CHANGED 37
/// "Archived chats"
///
/// Used as the name for the corresponding chatlist entry.
#define DC_STR_ARCHIVEDCHATS 40
/// "Autocrypt Setup Message"
///
/// @deprecated 2025-04
#define DC_STR_AC_SETUP_MSG_SUBJECT 42
/// "This is the Autocrypt Setup Message, open it in a compatible client to use your setup"
///
/// @deprecated 2025-04
#define DC_STR_AC_SETUP_MSG_BODY 43
/// "Cannot login as %1$s."
///
/// Used in error strings.
/// - %1$s will be replaced by the failing login name
#define DC_STR_CANNOT_LOGIN 60
/// "%1$s by %2$s"
///
/// Used to concretize actions,
/// - %1$s will be replaced by an action
/// as #DC_STR_MSGADDMEMBER or #DC_STR_MSGGRPIMGCHANGED (full-stop removed, if any)
/// - %2$s will be replaced by the name of the user taking that action
///
/// @deprecated 2022-09-10
#define DC_STR_MSGACTIONBYUSER 62
/// "%1$s by me"
///
/// Used to concretize actions.
/// - %1$s will be replaced by an action
/// as #DC_STR_MSGADDMEMBER or #DC_STR_MSGGRPIMGCHANGED (full-stop removed, if any)
///
/// @deprecated 2022-09-10
#define DC_STR_MSGACTIONBYME 63
/// "Location streaming enabled."
///
/// Used in status messages.
@@ -7211,13 +7082,6 @@ void dc_event_unref(dc_event_t* event);
/// Used as message text for the message added to the device chat after successful login.
#define DC_STR_WELCOME_MESSAGE 71
/// "Unknown sender for this chat. See 'info' for more details."
///
/// Use as message text if assigning the message to a chat is not totally correct.
///
/// @deprecated 2025-08-18
#define DC_STR_UNKNOWN_SENDER_FOR_CHAT 72
/// "Message from %1$s"
///
/// Used in subjects of outgoing messages in one-to-one chats.
@@ -7231,53 +7095,6 @@ void dc_event_unref(dc_event_t* event);
/// - %1$s will be replaced by the name of the contact the message cannot be sent to
#define DC_STR_FAILED_SENDING_TO 74
/// "Message deletion timer is disabled."
///
/// Used in status messages.
///
/// @deprecated 2022-09-10
#define DC_STR_EPHEMERAL_DISABLED 75
/// "Message deletion timer is set to %1$s s."
///
/// Used in status messages when the other constants
/// (#DC_STR_EPHEMERAL_MINUTE, #DC_STR_EPHEMERAL_HOUR and so on) do not match the timer.
/// - %1$s will be replaced by the number of seconds the timer is set to
///
/// @deprecated 2022-09-10
#define DC_STR_EPHEMERAL_SECONDS 76
/// "Message deletion timer is set to 1 minute."
///
/// Used in status messages.
///
/// @deprecated 2022-09-10
#define DC_STR_EPHEMERAL_MINUTE 77
/// "Message deletion timer is set to 1 hour."
///
/// Used in status messages.
///
/// @deprecated 2022-09-10
#define DC_STR_EPHEMERAL_HOUR 78
/// "Message deletion timer is set to 1 day."
///
/// Used in status messages.
///
/// @deprecated 2022-09-10
#define DC_STR_EPHEMERAL_DAY 79
/// "Message deletion timer is set to 1 week."
///
/// Used in status messages.
///
/// @deprecated 2022-09-10
#define DC_STR_EPHEMERAL_WEEK 80
/// @deprecated Deprecated 2021-01-30, DC_STR_EPHEMERAL_WEEKS is used instead.
#define DC_STR_EPHEMERAL_FOUR_WEEKS 81
/// "Error: %1$s"
///
/// Used in error strings.
@@ -7311,42 +7128,6 @@ void dc_event_unref(dc_event_t* event);
/// Used as device message text.
#define DC_STR_SELF_DELETED_MSG_BODY 91
/// "Message deletion timer is set to %1$s minutes."
///
/// Used in status messages.
///
/// `%1$s` will be replaced by the number of minutes (always >1) the timer is set to.
///
/// @deprecated Replaced by DC_STR_MSG_YOU_EPHEMERAL_TIMER_MINUTES and DC_STR_MSG_EPHEMERAL_TIMER_MINUTES_BY.
#define DC_STR_EPHEMERAL_MINUTES 93
/// "Message deletion timer is set to %1$s hours."
///
/// Used in status messages.
///
/// `%1$s` will be replaced by the number of hours (always >1) the timer is set to.
///
/// @deprecated Replaced by DC_STR_MSG_YOU_EPHEMERAL_TIMER_HOURS and DC_STR_MSG_EPHEMERAL_TIMER_HOURS_BY.
#define DC_STR_EPHEMERAL_HOURS 94
/// "Message deletion timer is set to %1$s days."
///
/// Used in status messages.
///
/// `%1$s` will be replaced by the number of days (always >1) the timer is set to.
///
/// @deprecated Replaced by DC_STR_MSG_YOU_EPHEMERAL_TIMER_DAYS and DC_STR_MSG_EPHEMERAL_TIMER_DAYS_BY.
#define DC_STR_EPHEMERAL_DAYS 95
/// "Message deletion timer is set to %1$s weeks."
///
/// Used in status messages.
///
/// `%1$s` will be replaced by the number of weeks (always >1) the timer is set to.
///
/// @deprecated Replaced by DC_STR_MSG_YOU_EPHEMERAL_TIMER_WEEKS and DC_STR_MSG_EPHEMERAL_TIMER_WEEKS_BY.
#define DC_STR_EPHEMERAL_WEEKS 96
/// "Forwarded"
///
/// Used in message summary text for notifications and chatlist.
@@ -7359,9 +7140,6 @@ void dc_event_unref(dc_event_t* event);
/// `%1$s` will be replaced by the percentage used
#define DC_STR_QUOTA_EXCEEDING_MSG_BODY 98
/// @deprecated Deprecated 2025-11-12, this string is no longer needed.
#define DC_STR_PARTIAL_DOWNLOAD_MSG_BODY 99
/// "Multi Device Synchronization"
///
/// Used in subjects of outgoing sync messages.
@@ -7387,10 +7165,6 @@ void dc_event_unref(dc_event_t* event);
/// Used as a headline in the connectivity view.
#define DC_STR_OUTGOING_MESSAGES 104
/// @deprecated Deprecated 2022-04-16, this string is no longer needed.
#define DC_STR_ONE_MOMENT 106
/// "Connected"
///
/// Used as status in the connectivity view.
@@ -7481,12 +7255,6 @@ void dc_event_unref(dc_event_t* event);
/// Used as status in the connectivity view.
#define DC_STR_NOT_CONNECTED 121
/// "%1$s changed their address from %2$s to %3$s"
///
/// Used as an info message to chats with contacts that changed their address.
/// @deprecated 2025-06-05
#define DC_STR_AEAP_ADDR_CHANGED 122
/// "You changed group name from \"%1$s\" to \"%2$s\"."
///
/// `%1$s` will be replaced by the old group name.
@@ -7601,17 +7369,6 @@ void dc_event_unref(dc_event_t* event);
/// Used in status messages.
#define DC_STR_EPHEMERAL_TIMER_SECONDS_BY_OTHER 141
/// "You set message deletion timer to 1 minute."
///
/// @deprecated 2025-11-14, this string is no longer needed
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_YOU 142
/// "Message deletion timer is set to 1 minute by %1$s."
///
/// `%1$s` will be replaced by name of the contact.
/// @deprecated 2025-11-14, this string is no longer needed
#define DC_STR_EPHEMERAL_TIMER_1_MINUTE_BY_OTHER 143
/// "You set message deletion timer to 1 hour."
///
/// Used in status messages.
@@ -7781,17 +7538,6 @@ void dc_event_unref(dc_event_t* event);
/// Used as info message.
#define DC_STR_SECUREJOIN_WAIT 190
/// "Could not yet establish guaranteed end-to-end encryption, but you may already send a message."
///
/// @deprecated 2025-03
#define DC_STR_SECUREJOIN_WAIT_TIMEOUT 191
/// "The contact must be online to proceed. This process will continue automatically in background."
///
/// Used as info message.
/// @deprecated 2025-06-05
#define DC_STR_SECUREJOIN_TAKES_LONGER 192
/// "❤️ Seems you're enjoying Delta Chat!"… (donation request device message)
#define DC_STR_DONATION_REQUEST 193
-16
View File
@@ -2260,22 +2260,6 @@ pub unsafe extern "C" fn dc_get_contacts(
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_get_blocked_cnt(context: *mut dc_context_t) -> libc::c_int {
if context.is_null() {
eprintln!("ignoring careless call to dc_get_blocked_cnt()");
return 0;
}
let ctx = &*context;
block_on(async move {
Contact::get_all_blocked(ctx)
.await
.unwrap_or_log_default(ctx, "failed to get blocked count")
.len() as libc::c_int
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_get_blocked_contacts(
context: *mut dc_context_t,
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "2.37.0"
version = "2.39.0"
description = "DeltaChat JSON-RPC API"
edition = "2021"
license = "MPL-2.0"
+31 -5
View File
@@ -11,8 +11,8 @@ use deltachat::blob::BlobObject;
use deltachat::calls::ice_servers;
use deltachat::chat::{
self, add_contact_to_chat, forward_msgs, forward_msgs_2ctx, get_chat_media, get_chat_msgs,
get_chat_msgs_ex, marknoticed_chat, remove_contact_from_chat, Chat, ChatId, ChatItem,
MessageListOptions,
get_chat_msgs_ex, marknoticed_all_chats, marknoticed_chat, remove_contact_from_chat, Chat,
ChatId, ChatItem, MessageListOptions,
};
use deltachat::chatlist::Chatlist;
use deltachat::config::{get_all_ui_config_keys, Config};
@@ -23,8 +23,8 @@ use deltachat::ephemeral::Timer;
use deltachat::imex;
use deltachat::location;
use deltachat::message::{
self, delete_msgs_ex, get_existing_msg_ids, get_msg_read_receipts, markseen_msgs, Message,
MessageState, MsgId, Viewtype,
self, delete_msgs_ex, get_existing_msg_ids, get_msg_read_receipt_count, get_msg_read_receipts,
markseen_msgs, Message, MessageState, MsgId, Viewtype,
};
use deltachat::peer_channels::{
leave_webxdc_realtime, send_webxdc_realtime_advertisement, send_webxdc_realtime_data,
@@ -1164,10 +1164,24 @@ impl CommandApi {
Ok(None)
}
/// Mark all messages in all chats as _noticed_.
/// Skips messages from blocked contacts, but does not skip messages in muted chats.
///
/// _Noticed_ messages are no longer _fresh_ and do not count as being unseen
/// but are still waiting for being marked as "seen" using markseen_msgs()
/// (read receipts aren't sent for noticed messages).
///
/// Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
/// See also markseen_msgs().
pub async fn marknoticed_all_chats(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
marknoticed_all_chats(&ctx).await
}
/// Mark all messages in a chat as _noticed_.
/// _Noticed_ messages are no longer _fresh_ and do not count as being unseen
/// but are still waiting for being marked as "seen" using markseen_msgs()
/// (IMAP/MDNs is not done for noticed messages).
/// (read receipts aren't sent for noticed messages).
///
/// Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
/// See also markseen_msgs().
@@ -1434,6 +1448,18 @@ impl CommandApi {
MessageInfo::from_msg_id(&ctx, MsgId::new(message_id)).await
}
/// Returns count of read receipts on message.
///
/// This view count is meant as a feedback measure for the channel owner only.
async fn get_message_read_receipt_count(
&self,
account_id: u32,
message_id: u32,
) -> Result<usize> {
let ctx = self.get_context(account_id).await?;
get_msg_read_receipt_count(&ctx, MsgId::new(message_id)).await
}
/// Returns contacts that sent read receipts and the time of reading.
async fn get_message_read_receipts(
&self,
+1 -1
View File
@@ -54,5 +54,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.37.0"
"version": "2.39.0"
}
+34 -3
View File
@@ -40,20 +40,51 @@ export class BaseDeltaChat<
* and emitting the respective events on this class.
*/
startEventLoop: boolean,
options?: {
/**
* @see {@linkcode BaseDeltaChat.eventLoop}.
*
* Has no effect if {@linkcode startEventLoop} === false.
*/
eventLoopRequestPoolSize?: number;
},
) {
super();
this.rpc = new RawClient(this.transport);
if (startEventLoop) {
this.eventTask = this.eventLoop();
this.eventTask = this.eventLoop({
eventLoopRequestPoolSize: options?.eventLoopRequestPoolSize,
});
}
}
/**
* @see the constructor's `startEventLoop`
*/
async eventLoop(): Promise<void> {
async eventLoop(options?: {
/**
* How many {@linkcode RawClient.getNextEvent} to constantly keep open.
* Having a value > 1 improves performance
* when dealing with bursts of events.
*
* Must be >= 1.
*
* @default 20
*/
eventLoopRequestPoolSize?: number;
}): Promise<void> {
const promises: ReturnType<typeof this.rpc.getNextEvent>[] = [];
for (let i = 0; i < (options?.eventLoopRequestPoolSize ?? 20); i++) {
promises.push(this.rpc.getNextEvent());
}
const bufferLength = promises.length;
let currInd = 0;
while (true) {
const event = await this.rpc.getNextEvent();
const event = await promises[currInd];
promises[currInd] = this.rpc.getNextEvent();
currInd = (currInd + 1) % bufferLength;
//@ts-ignore
this.emit(event.event.kind, event.contextId, event.event);
this.emit("ALL", event.contextId, event.event);
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "2.37.0"
version = "2.39.0"
license = "MPL-2.0"
edition = "2021"
repository = "https://github.com/chatmail/core"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.37.0"
version = "2.39.0"
license = "MPL-2.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
@@ -104,7 +104,7 @@ class Client:
def _process_events(
self,
until_func: Callable[[AttrDict], bool],
until_func: Callable[[AttrDict], bool] = _forever,
until_event: EventType = False,
) -> AttrDict:
"""Process events until the given callable evaluates to True,
@@ -44,6 +44,14 @@ class Message:
read_receipts = self._rpc.get_message_read_receipts(self.account.id, self.id)
return [AttrDict(read_receipt) for read_receipt in read_receipts]
def get_read_receipt_count(self) -> int:
"""
Returns count of read receipts on message.
This view count is meant as a feedback measure for the channel owner only.
"""
return self._rpc.get_message_read_receipt_count(self.account.id, self.id)
def get_reactions(self) -> Optional[AttrDict]:
"""Get message reactions."""
reactions = self._rpc.get_message_reactions(self.account.id, self.id)
@@ -22,7 +22,7 @@ from .rpc import Rpc
E2EE_INFO_MSGS = 1
"""
The number of info messages added to new e2ee chats.
Currently this is "End-to-end encryption available".
Currently this is "Messages are end-to-end encrypted."
"""
+17 -28
View File
@@ -8,8 +8,10 @@ from imap_tools import AND, U
from deltachat_rpc_client import Contact, EventType, Message
def test_move_works(acfactory):
def test_move_works(acfactory, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("mvbox_move", "1")
ac2.bring_online()
@@ -34,6 +36,8 @@ def test_move_avoids_loop(acfactory, direct_imap):
We do not want to move this message from `INBOX.DeltaChat` to `DeltaChat` again.
"""
ac1, ac2 = acfactory.get_online_accounts(2)
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("mvbox_move", "1")
ac2.set_config("delete_server_after", "0")
ac2.bring_online()
@@ -97,6 +101,8 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
addr, password = acfactory.get_credentials()
ac2 = acfactory.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password})
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("mvbox_move", "1")
assert ac2.is_configured()
@@ -130,30 +136,6 @@ def test_reactions_for_a_reordering_move(acfactory, direct_imap):
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
def test_delete_deltachat_folder(acfactory, direct_imap):
"""Test that DeltaChat folder is recreated if user deletes it manually."""
ac1 = acfactory.new_configured_account()
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.conn.folder.delete("DeltaChat")
assert "DeltaChat" not in ac1_direct_imap.list_folders()
# Wait until new folder is created and UIDVALIDITY is updated.
while True:
event = ac1.wait_for_event()
if event.kind == EventType.INFO and "transport 1: UID validity for folder DeltaChat changed from " in event.msg:
break
ac2 = acfactory.get_online_account()
ac2.create_chat(ac1).send_text("hello")
msg = ac1.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
assert "DeltaChat" in ac1_direct_imap.list_folders()
def test_dont_show_emails(acfactory, direct_imap, log):
"""Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them.
So: If it's outgoing AND there is no Received header, then ignore the email.
@@ -294,10 +276,12 @@ def test_dont_show_emails(acfactory, direct_imap, log):
assert len(msg.chat.get_messages()) == 2
def test_move_works_on_self_sent(acfactory):
def test_move_works_on_self_sent(acfactory, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
# Enable movebox and wait until it is created.
# Create and enable movebox.
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder("DeltaChat")
ac1.set_config("mvbox_move", "1")
ac1.set_config("bcc_self", "1")
ac1.bring_online()
@@ -314,6 +298,8 @@ def test_move_works_on_self_sent(acfactory):
def test_moved_markseen(acfactory, direct_imap):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("mvbox_move", "1")
ac2.set_config("delete_server_after", "0")
ac2.set_config("sync_msgs", "0") # Do not send a sync message when accepting a contact request.
@@ -356,6 +342,8 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
for ac in ac1, ac2:
ac.set_config("delete_server_after", "0")
if mvbox_move:
ac_direct_imap = direct_imap(ac)
ac_direct_imap.create_folder("DeltaChat")
ac.set_config("mvbox_move", "1")
ac.bring_online()
@@ -393,6 +381,8 @@ def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
def test_mvbox_and_trash(acfactory, direct_imap, log):
log.section("ac1: start with mvbox")
ac1 = acfactory.get_online_account()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder("DeltaChat")
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
@@ -400,7 +390,6 @@ def test_mvbox_and_trash(acfactory, direct_imap, log):
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()
@@ -209,7 +209,7 @@ def test_transport_synchronization(acfactory, log) -> None:
def test_transport_sync_new_as_primary(acfactory, log) -> None:
"""Test synchronization of new transport as primary between devices."""
ac1 = acfactory.get_online_account()
ac1, bob = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone()
ac1_clone.bring_online()
@@ -229,6 +229,15 @@ def test_transport_sync_new_as_primary(acfactory, log) -> None:
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
assert ac1_clone.get_config("configured_addr") == transport2["addr"]
log.section("ac1_clone receives a message via the new primary transport")
ac1_chat = ac1.create_chat(bob)
ac1_chat.send_text("Hello!")
bob_chat_id = bob.wait_for_incoming_msg_event().chat_id
bob_chat = bob.get_chat_by_id(bob_chat_id)
bob_chat.accept()
bob_chat.send_text("hello back")
assert ac1_clone.wait_for_incoming_msg().get_snapshot().text == "hello back"
def test_recognize_self_address(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
+83 -7
View File
@@ -5,6 +5,7 @@ import logging
import os
import socket
import subprocess
import time
from unittest.mock import MagicMock
import pytest
@@ -387,22 +388,29 @@ def test_dont_move_sync_msgs(acfactory, direct_imap):
ac1.set_config("bcc_self", "1")
ac1.set_config("fix_is_chatmail", "1")
ac1.add_or_update_transport({"addr": addr, "password": password})
ac1.bring_online()
ac1.start_io()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.select_folder("Inbox")
# Sync messages may also be sent during configuration.
inbox_msg_cnt = len(ac1_direct_imap.get_all_messages())
ac1.wait_for_event(EventType.MSG_DELIVERED)
ac1_direct_imap.select_folder("Inbox")
while True:
if len(ac1_direct_imap.get_all_messages()) == 1:
break
time.sleep(1)
ac1.set_config("displayname", "Alice")
ac1.wait_for_event(EventType.MSG_DELIVERED)
ac1.set_config("displayname", "Bob")
ac1.wait_for_event(EventType.MSG_DELIVERED)
ac1_direct_imap.select_folder("Inbox")
assert len(ac1_direct_imap.get_all_messages()) == inbox_msg_cnt + 2
ac1_direct_imap.select_folder("DeltaChat")
assert len(ac1_direct_imap.get_all_messages()) == 0
# Message may not be delivered to IMAP immediately
# after sending over SMTP,
# retry until they are delivered to IMAP.
while True:
if len(ac1_direct_imap.get_all_messages()) == 3:
break
time.sleep(1)
def test_reaction_seen_on_another_dev(acfactory) -> None:
@@ -911,6 +919,47 @@ def test_markseen_contact_request(acfactory):
assert message2.get_snapshot().state == MessageState.IN_SEEN
@pytest.mark.parametrize("team_profile", [True, False])
def test_no_markseen_in_team_profile(team_profile, acfactory):
"""
Test that seen status is synchronized iff `team_profile` isn't set.
"""
alice, bob = acfactory.get_online_accounts(2)
if team_profile:
bob.set_config("team_profile", "1")
# Bob sets up a second device.
bob2 = bob.clone()
bob2.start_io()
alice_chat_bob = alice.create_chat(bob)
bob_chat_alice = bob.create_chat(alice)
bob2.create_chat(alice)
alice_chat_bob.send_text("Hello Bob!")
message = bob.wait_for_incoming_msg()
message2 = bob2.wait_for_incoming_msg()
assert message2.get_snapshot().state == MessageState.IN_FRESH
message.mark_seen()
# Send a message and wait until it arrives
# in order to wait until Bob2 gets the markseen message.
# This also tests that outgoing messages
# don't mark preceeding messages as seen in team profiles.
bob_chat_alice.send_text("Outgoing message")
while True:
outgoing = bob2.wait_for_msg(EventType.MSGS_CHANGED)
if outgoing.id != 0:
break
assert outgoing.get_snapshot().text == "Outgoing message"
if team_profile:
assert message2.get_snapshot().state == MessageState.IN_FRESH
else:
assert message2.get_snapshot().state == MessageState.IN_SEEN
def test_read_receipt(acfactory):
"""
Test sending a read receipt and ensure it is attributed to the correct contact.
@@ -930,6 +979,9 @@ def test_read_receipt(acfactory):
assert len(read_receipts) == 1
assert read_receipts[0].contact_id == alice_contact_bob.id
read_receipt_cnt = read_msg.get_read_receipt_count()
assert read_receipt_cnt == 1
def test_get_http_response(acfactory):
alice = acfactory.new_configured_account()
@@ -1133,6 +1185,30 @@ def test_leave_broadcast(acfactory, all_devices_online):
check_account(bob2, bob2.create_contact(alice), inviter_side=False)
def test_leave_and_delete_group(acfactory, log):
alice, bob = acfactory.get_online_accounts(2)
log.section("Alice creates a group")
alice_chat = alice.create_group("Group")
alice_chat.add_contact(bob)
assert len(alice_chat.get_contacts()) == 2 # Alice and Bob
alice_chat.send_text("hello")
log.section("Bob sees the group, and leaves and deletes it")
msg = bob.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
msg.chat.accept()
msg.chat.leave()
# Bob deletes the chat. This must not prevent the leave message from being sent.
msg.chat.delete()
log.section("Alice receives the delete message")
# After Bob left, only Alice will be left in the group:
while len(alice_chat.get_contacts()) != 1:
alice.wait_for_event(EventType.CHAT_MODIFIED)
def test_immediate_autodelete(acfactory, direct_imap, log):
ac1, ac2 = acfactory.get_online_accounts(2)
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "2.37.0"
version = "2.39.0"
description = "DeltaChat JSON-RPC server"
edition = "2021"
readme = "README.md"
@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "2.37.0"
"version": "2.39.0"
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "2.37.0"
version = "2.39.0"
license = "MPL-2.0"
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
readme = "README.rst"
+3 -24
View File
@@ -220,21 +220,6 @@ def test_webxdc_huge_update(acfactory, data, lp):
assert update["payload"] == payload
def test_enable_mvbox_move(acfactory, lp):
(ac1,) = acfactory.get_online_accounts(1)
lp.sec("ac2: start without mvbox thread")
ac2 = acfactory.new_online_configuring_account(mvbox_move=False)
acfactory.bring_accounts_online()
lp.sec("ac2: configuring mvbox")
ac2.set_config("mvbox_move", "1")
lp.sec("ac1: send message and wait for ac2 to receive it")
acfactory.get_accepted_chat(ac1, ac2).send_text("message1")
assert ac2._evtracker.wait_next_incoming_message().text == "message1"
def test_forward_messages(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat = ac1.create_chat(ac2)
@@ -350,7 +335,7 @@ def test_long_group_name(acfactory, lp):
def test_send_self_message(acfactory, lp):
ac1 = acfactory.new_online_configuring_account(mvbox_move=True, bcc_self=True)
ac1 = acfactory.new_online_configuring_account(bcc_self=True)
acfactory.bring_accounts_online()
lp.sec("ac1: create self chat")
chat = ac1.get_self_contact().create_chat()
@@ -509,7 +494,7 @@ def test_reply_privately(acfactory):
def test_mdn_asymmetric(acfactory, lp):
ac1 = acfactory.new_online_configuring_account(mvbox_move=True)
ac1 = acfactory.new_online_configuring_account()
ac2 = acfactory.new_online_configuring_account()
acfactory.bring_accounts_online()
@@ -538,20 +523,14 @@ def test_mdn_asymmetric(acfactory, lp):
ac2.mark_seen_messages([msg])
lp.sec("ac1: waiting for incoming activity")
# MDN should be moved even though MDNs are already disabled
ac1._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED")
assert len(chat.get_messages()) == 1 + E2EE_INFO_MSGS
# Wait for the message to be marked as seen on IMAP.
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder DeltaChat as seen.")
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder INBOX as seen.")
# MDN is received even though MDNs are already disabled
assert msg_out.is_out_mdn_received()
ac1.direct_imap.select_config_folder("mvbox")
assert len(list(ac1.direct_imap.conn.fetch(AND(seen=True)))) == 1
def test_send_receive_encrypt(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
-3
View File
@@ -258,9 +258,6 @@ class TestOfflineChat:
with pytest.raises(ValueError):
ac1.set_stock_translation(dc.const.DC_STR_FILE, "xyz %1$s")
ac1._evtracker.get_matching("DC_EVENT_WARNING")
with pytest.raises(ValueError):
ac1.set_stock_translation(dc.const.DC_STR_CONTACT_NOT_VERIFIED, "xyz %2$s")
ac1._evtracker.get_matching("DC_EVENT_WARNING")
with pytest.raises(ValueError):
ac1.set_stock_translation(500, "xyz %1$s")
ac1._evtracker.get_matching("DC_EVENT_WARNING")
+1 -1
View File
@@ -1 +1 @@
2026-01-08
2026-01-23
+1 -1
View File
@@ -7,7 +7,7 @@ set -euo pipefail
#
# Avoid using rustup here as it depends on reading /proc/self/exe and
# has problems running under QEMU.
RUST_VERSION=1.92.0
RUST_VERSION=1.93.0
ARCH="$(uname -m)"
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu
+2 -2
View File
@@ -6,11 +6,11 @@ set -euo pipefail
export TZ=UTC
# Provider database revision.
REV=d041136c19a48b493823b46d472f12b9ee94ae80
REV=b21726d0c895ae43251579707229792e1fb4d837
CORE_ROOT="$PWD"
TMP="$(mktemp -d)"
git clone --filter=blob:none https://github.com/deltachat/provider-db.git "$TMP"
git clone --filter=blob:none https://github.com/chatmail/provider-db.git "$TMP"
cd "$TMP"
git checkout "$REV"
DATE=$(git show -s --format=%cs)
+24 -24
View File
@@ -1,6 +1,6 @@
//! # Blob directory management.
use core::cmp::max;
use std::cmp::max;
use std::io::{Cursor, Seek};
use std::iter::FusedIterator;
use std::mem;
@@ -256,7 +256,7 @@ impl<'a> BlobObject<'a> {
/// Recode image to avatar size.
pub async fn recode_to_avatar_size(&mut self, context: &Context) -> Result<()> {
let (img_wh, max_bytes) =
let (max_wh, max_bytes) =
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
.unwrap_or_default()
{
@@ -273,7 +273,7 @@ impl<'a> BlobObject<'a> {
let is_avatar = true;
self.check_or_recode_to_size(
context, None, // The name of an avatar doesn't matter
viewtype, img_wh, max_bytes, is_avatar,
viewtype, max_wh, max_bytes, is_avatar,
)?;
Ok(())
@@ -294,7 +294,7 @@ impl<'a> BlobObject<'a> {
name: Option<String>,
viewtype: &mut Viewtype,
) -> Result<String> {
let (img_wh, max_bytes) =
let (max_wh, max_bytes) =
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
.unwrap_or_default()
{
@@ -305,13 +305,15 @@ impl<'a> BlobObject<'a> {
MediaQuality::Worse => (constants::WORSE_IMAGE_SIZE, constants::WORSE_IMAGE_BYTES),
};
let is_avatar = false;
self.check_or_recode_to_size(context, name, viewtype, img_wh, max_bytes, is_avatar)
self.check_or_recode_to_size(context, name, viewtype, max_wh, max_bytes, is_avatar)
}
/// Checks or recodes the image so that it fits into limits on width/height and byte size.
/// Checks or recodes the image so that it fits into limits on width/height and/or byte size.
///
/// If `!is_avatar`, then if `max_bytes` is exceeded, reduces the image to `img_wh` and proceeds
/// with the result without rechecking.
/// If `!is_avatar`, then if `max_bytes` is exceeded, reduces the image to `max_wh` and proceeds
/// with the result (even if `max_bytes` is still exceeded).
///
/// If `is_avatar`, the resolution will be reduced in a loop until the image fits `max_bytes`.
///
/// This modifies the blob object in-place.
///
@@ -324,7 +326,7 @@ impl<'a> BlobObject<'a> {
context: &Context,
name: Option<String>,
viewtype: &mut Viewtype,
mut img_wh: u32,
max_wh: u32,
max_bytes: usize,
is_avatar: bool,
) -> Result<String> {
@@ -386,7 +388,14 @@ impl<'a> BlobObject<'a> {
_ => img,
};
let exceeds_wh = img.width() > img_wh || img.height() > img_wh;
// max_wh is the maximum image width and height, i.e. the resolution-limit.
// target_wh target-resolution for resizing the image.
let exceeds_wh = img.width() > max_wh || img.height() > max_wh;
let mut target_wh = if exceeds_wh {
max_wh
} else {
max(img.width(), img.height())
};
let exceeds_max_bytes = nr_bytes > max_bytes as u64;
let jpeg_quality = 75;
@@ -425,15 +434,6 @@ impl<'a> BlobObject<'a> {
});
if do_scale {
if !exceeds_wh {
img_wh = max(img.width(), img.height());
// PNGs and WebPs may be huge because of animation, which is lost by the `image`
// crate when recoding, so don't scale them down.
if matches!(fmt, ImageFormat::Jpeg) || !encoded.is_empty() {
img_wh = img_wh * 2 / 3;
}
}
loop {
if mem::take(&mut add_white_bg) {
self::add_white_bg(&mut img);
@@ -448,9 +448,9 @@ impl<'a> BlobObject<'a> {
// usually has less pixels by cropping, UI that needs to wait anyways,
// and also benefits from slightly better (5%) encoding of Triangle-filtered images.
let new_img = if is_avatar {
img.resize(img_wh, img_wh, image::imageops::FilterType::Triangle)
img.resize(target_wh, target_wh, image::imageops::FilterType::Triangle)
} else {
img.thumbnail(img_wh, img_wh)
img.thumbnail(target_wh, target_wh)
};
if encoded_img_exceeds_bytes(
@@ -461,19 +461,19 @@ impl<'a> BlobObject<'a> {
&mut encoded,
)? && is_avatar
{
if img_wh < 20 {
if target_wh < 20 {
return Err(format_err!(
"Failed to scale image to below {max_bytes}B.",
));
}
img_wh = img_wh * 2 / 3;
target_wh = target_wh * 2 / 3;
} else {
info!(
context,
"Final scaled-down image size: {}B ({}px).",
encoded.len(),
img_wh
target_wh
);
break;
}
+53
View File
@@ -798,3 +798,56 @@ async fn test_create_and_deduplicate_from_bytes() -> Result<()> {
Ok(())
}
/// Tests that an image that already fits into the width limit,
/// but not the bytes limit,
/// is compressed without changing the resolution.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_without_downscaling() -> Result<()> {
let t = &TestContext::new().await;
let image = include_bytes!("../../test-data/image/screenshot120x120.jpg");
const { assert!(120 < constants::WORSE_AVATAR_SIZE) };
for is_avatar in [true, false] {
let mut blob =
BlobObject::create_and_deduplicate_from_bytes(t, image, "image.jpg").unwrap();
let image_path = blob.to_abs_path();
check_image_size(&image_path, 120, 120);
assert!(
fs::metadata(&image_path).await.unwrap().len() > constants::WORSE_AVATAR_BYTES as u64
);
// Repeat the check, because a second call to `check_or_recode_to_size()`
// is not supposed to change anything:
let mut imgs = vec![];
for _ in 0..2 {
let mut viewtype = Viewtype::Image;
let new_name = blob.check_or_recode_to_size(
t,
Some("image.jpg".to_string()),
&mut viewtype,
constants::WORSE_AVATAR_SIZE,
constants::WORSE_AVATAR_BYTES,
is_avatar,
)?;
let image_path = blob.to_abs_path();
assert_eq!(new_name, "image.jpg"); // The name shall not have changed
assert_eq!(viewtype, Viewtype::Image); // The viewtype shall not have changed
let img = check_image_size(&image_path, 120, 120); // The resolution shall not have changed
imgs.push(img);
let new_image_bytes = fs::metadata(&image_path).await.unwrap().len();
assert!(
new_image_bytes < constants::WORSE_AVATAR_BYTES as u64,
"The new image size, {new_image_bytes}, should be lower than {}, is_avatar={is_avatar}",
constants::WORSE_AVATAR_BYTES
);
}
assert_eq!(imgs[0], imgs[1]);
}
Ok(())
}
+106 -62
View File
@@ -617,33 +617,7 @@ struct IceServer {
pub credential: Option<String>,
}
/// Creates JSON with ICE servers.
async fn create_ice_servers(
context: &Context,
hostname: &str,
port: u16,
username: &str,
password: &str,
) -> Result<String> {
// Do not use cache because there is no TLS.
let load_cache = false;
let urls: Vec<String> = lookup_host_with_cache(context, hostname, port, "", load_cache)
.await?
.into_iter()
.map(|addr| format!("turn:{addr}"))
.collect();
let ice_server = IceServer {
urls,
username: Some(username.to_string()),
credential: Some(password.to_string()),
};
let json = serde_json::to_string(&[ice_server])?;
Ok(json)
}
/// Creates JSON with ICE servers from a line received over IMAP METADATA.
/// Creates ICE servers from a line received over IMAP METADATA.
///
/// IMAP METADATA returns a line such as
/// `example.com:3478:1758650868:8Dqkyyu11MVESBqjbIylmB06rv8=`
@@ -653,20 +627,107 @@ async fn create_ice_servers(
/// while `8Dqkyyu11MVESBqjbIylmB06rv8=`
/// is the password.
pub(crate) async fn create_ice_servers_from_metadata(
context: &Context,
metadata: &str,
) -> Result<(i64, String)> {
) -> Result<(i64, Vec<UnresolvedIceServer>)> {
let (hostname, rest) = metadata.split_once(':').context("Missing hostname")?;
let (port, rest) = rest.split_once(':').context("Missing port")?;
let port = u16::from_str(port).context("Failed to parse the port")?;
let (ts, password) = rest.split_once(':').context("Missing timestamp")?;
let expiration_timestamp = i64::from_str(ts).context("Failed to parse the timestamp")?;
let ice_servers = create_ice_servers(context, hostname, port, ts, password).await?;
let ice_servers = vec![UnresolvedIceServer::Turn {
hostname: hostname.to_string(),
port,
username: ts.to_string(),
credential: password.to_string(),
}];
Ok((expiration_timestamp, ice_servers))
}
/// STUN or TURN server with unresolved DNS name.
#[derive(Debug, Clone)]
pub(crate) enum UnresolvedIceServer {
/// STUN server.
Stun { hostname: String, port: u16 },
/// TURN server with the username and password.
Turn {
hostname: String,
port: u16,
username: String,
credential: String,
},
}
/// Resolves domain names of ICE servers.
///
/// On failure to resolve, logs the error
/// and skips the server, but does not fail.
pub(crate) async fn resolve_ice_servers(
context: &Context,
unresolved_ice_servers: Vec<UnresolvedIceServer>,
) -> Result<String> {
let mut result: Vec<IceServer> = Vec::new();
// Do not use cache because there is no TLS.
let load_cache = false;
for unresolved_ice_server in unresolved_ice_servers {
match unresolved_ice_server {
UnresolvedIceServer::Stun { hostname, port } => {
match lookup_host_with_cache(context, &hostname, port, "", load_cache).await {
Ok(addrs) => {
let urls: Vec<String> = addrs
.into_iter()
.map(|addr| format!("stun:{addr}"))
.collect();
let stun_server = IceServer {
urls,
username: None,
credential: None,
};
result.push(stun_server);
}
Err(err) => {
warn!(
context,
"Failed to resolve STUN {hostname}:{port}: {err:#}."
);
}
}
}
UnresolvedIceServer::Turn {
hostname,
port,
username,
credential,
} => match lookup_host_with_cache(context, &hostname, port, "", load_cache).await {
Ok(addrs) => {
let urls: Vec<String> = addrs
.into_iter()
.map(|addr| format!("turn:{addr}"))
.collect();
let turn_server = IceServer {
urls,
username: Some(username),
credential: Some(credential),
};
result.push(turn_server);
}
Err(err) => {
warn!(
context,
"Failed to resolve TURN {hostname}:{port}: {err:#}."
);
}
},
}
}
let json = serde_json::to_string(&result)?;
Ok(json)
}
/// Creates JSON with ICE servers when no TURN servers are known.
pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<String> {
pub(crate) fn create_fallback_ice_servers() -> Vec<UnresolvedIceServer> {
// Do not use public STUN server from https://stunprotocol.org/.
// It changes the hostname every year
// (e.g. stunserver2025.stunprotocol.org
@@ -674,36 +735,18 @@ pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<Str
// because of bandwidth costs:
// <https://github.com/jselbie/stunserver/issues/50>
let hostname = "nine.testrun.org";
// Do not use cache because there is no TLS.
let load_cache = false;
let urls: Vec<String> = lookup_host_with_cache(context, hostname, STUN_PORT, "", load_cache)
.await?
.into_iter()
.map(|addr| format!("stun:{addr}"))
.collect();
let stun_server = IceServer {
urls,
username: None,
credential: None,
};
let hostname = "turn.delta.chat";
// Do not use cache because there is no TLS.
let load_cache = false;
let urls: Vec<String> = lookup_host_with_cache(context, hostname, STUN_PORT, "", load_cache)
.await?
.into_iter()
.map(|addr| format!("turn:{addr}"))
.collect();
let turn_server = IceServer {
urls,
username: Some("public".to_string()),
credential: Some("o4tR7yG4rG2slhXqRUf9zgmHz".to_string()),
};
let json = serde_json::to_string(&[stun_server, turn_server])?;
Ok(json)
vec![
UnresolvedIceServer::Stun {
hostname: "nine.testrun.org".to_string(),
port: STUN_PORT,
},
UnresolvedIceServer::Turn {
hostname: "turn.delta.chat".to_string(),
port: STUN_PORT,
username: "public".to_string(),
credential: "o4tR7yG4rG2slhXqRUf9zgmHz".to_string(),
},
]
}
/// Returns JSON with ICE servers.
@@ -717,7 +760,8 @@ pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<Str
/// <https://github.com/deltachat/deltachat-desktop/issues/5447>.
pub async fn ice_servers(context: &Context) -> Result<String> {
if let Some(ref metadata) = *context.metadata.read().await {
Ok(metadata.ice_servers.clone())
let ice_servers = resolve_ice_servers(context, metadata.ice_servers.clone()).await?;
Ok(ice_servers)
} else {
Ok("[]".to_string())
}
+56 -49
View File
@@ -19,6 +19,7 @@ use strum_macros::EnumIter;
use crate::blob::BlobObject;
use crate::chatlist::Chatlist;
use crate::chatlist_events;
use crate::color::str_to_color;
use crate::config::Config;
use crate::constants::{
@@ -42,7 +43,7 @@ use crate::mimefactory::{MimeFactory, RenderedEmail};
use crate::mimeparser::SystemMessage;
use crate::param::{Param, Params};
use crate::receive_imf::ReceivedMsg;
use crate::smtp::send_msg_to_smtp;
use crate::smtp::{self, send_msg_to_smtp};
use crate::stock_str;
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::{
@@ -51,7 +52,6 @@ use crate::tools::{
gm2local_offset, normalize_text, smeared_time, time, truncate_msg_text,
};
use crate::webxdc::StatusUpdateSerial;
use crate::{chatlist_events, imap};
pub(crate) const PARAM_BROADCAST_SECRET: Param = Param::Arg3;
@@ -635,10 +635,6 @@ impl ChatId {
"UPDATE imap SET target=? WHERE rfc724_mid IN (SELECT pre_rfc724_mid FROM msgs WHERE chat_id=? AND pre_rfc724_mid!='')",
(&delete_msgs_target, self,),
)?;
transaction.execute(
"DELETE FROM smtp WHERE msg_id IN (SELECT id FROM msgs WHERE chat_id=?)",
(self,),
)?;
transaction.execute(
"DELETE FROM msgs_mdns WHERE msg_id IN (SELECT id FROM msgs WHERE chat_id=?)",
(self,),
@@ -1156,7 +1152,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
return Ok(stock_str::encr_none(context).await);
}
let mut ret = stock_str::e2e_available(context).await + "\n";
let mut ret = stock_str::messages_e2e_encrypted(context).await + "\n";
for &contact_id in get_chat_contacts(context, self)
.await?
@@ -2135,10 +2131,11 @@ pub(crate) async fn sync(context: &Context, id: SyncId, action: SyncAction) -> R
}
/// Whether the chat is pinned or archived.
#[derive(Debug, Copy, Eq, PartialEq, Clone, Serialize, Deserialize, EnumIter)]
#[derive(Debug, Copy, Eq, PartialEq, Clone, Serialize, Deserialize, EnumIter, Default)]
#[repr(i8)]
pub enum ChatVisibility {
/// Chat is neither archived nor pinned.
#[default]
Normal = 0,
/// Chat is archived.
@@ -2838,32 +2835,11 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
let from = context.get_primary_self_addr().await?;
let lowercase_from = from.to_lowercase();
// Send BCC to self if it is enabled.
//
// Previous versions of Delta Chat did not send BCC self
// if DeleteServerAfter was set to immediately delete messages
// from the server. This is not the case anymore
// because BCC-self messages are also used to detect
// that message was sent if SMTP server is slow to respond
// and connection is frequently lost
// before receiving status line. NB: This is not a problem for chatmail servers, so `BccSelf`
// disabled by default is fine.
//
// `from` must be the last addr, see `receive_imf_inner()` why.
recipients.retain(|x| x.to_lowercase() != lowercase_from);
if (context.get_config_bool(Config::BccSelf).await?
|| msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage)
&& (context.get_config_delete_server_after().await? != Some(0) || !recipients.is_empty())
if context.get_config_bool(Config::BccSelf).await?
|| msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage
{
// Avoid sending unencrypted messages to all transports, chatmail relays won't accept
// them. Normally the user should have a non-chatmail primary transport to send unencrypted
// messages.
if needs_encryption {
for addr in context.get_secondary_self_addrs().await? {
recipients.push(addr);
}
}
recipients.push(from);
smtp::add_self_recipients(context, &mut recipients, needs_encryption).await?;
}
// Default Webxdc integrations are hidden messages and must not be sent out
@@ -3230,6 +3206,36 @@ pub async fn get_chat_msgs_ex(
Ok(items)
}
/// Marks all unread messages in all chats as noticed.
/// Ignores messages from blocked contacts, but does not ignore messages in muted chats.
pub async fn marknoticed_all_chats(context: &Context) -> Result<()> {
// The sql statement here is similar to the one in get_fresh_msgs
let list = context
.sql
.query_map_vec(
"SELECT DISTINCT(c.id)
FROM msgs m
INNER JOIN chats c
ON m.chat_id=c.id
WHERE m.state=?
AND m.hidden=0
AND m.chat_id>9
AND c.blocked=0;",
(MessageState::InFresh,),
|row| {
let msg_id: ChatId = row.get(0)?;
Ok(msg_id)
},
)
.await?;
for chat_id in list {
marknoticed_chat(context, chat_id).await?;
}
Ok(())
}
/// Marks all messages in the chat as noticed.
/// If the given chat-id is the archive-link, marks all messages in all archived chats as noticed.
pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()> {
@@ -3291,7 +3297,7 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()>
let hidden_messages = context
.sql
.query_map_vec(
"SELECT id, rfc724_mid FROM msgs
"SELECT id FROM msgs
WHERE state=?
AND hidden=1
AND chat_id=?
@@ -3299,16 +3305,11 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()>
(MessageState::InFresh, chat_id), // No need to check for InNoticed messages, because reactions are never InNoticed
|row| {
let msg_id: MsgId = row.get(0)?;
let rfc724_mid: String = row.get(1)?;
Ok((msg_id, rfc724_mid))
Ok(msg_id)
},
)
.await?;
for (msg_id, rfc724_mid) in &hidden_messages {
message::update_msg_state(context, *msg_id, MessageState::InSeen).await?;
imap::markseen_on_imap_table(context, rfc724_mid).await?;
}
message::markseen_msgs(context, hidden_messages).await?;
if noticed_msgs_count == 0 {
return Ok(());
}
@@ -3330,6 +3331,10 @@ pub(crate) async fn mark_old_messages_as_noticed(
context: &Context,
mut msgs: Vec<ReceivedMsg>,
) -> Result<()> {
if context.get_config_bool(Config::TeamProfile).await? {
return Ok(());
}
msgs.retain(|m| m.state.is_outgoing());
if msgs.is_empty() {
return Ok(());
@@ -4112,7 +4117,7 @@ pub async fn remove_contact_from_chat(
} else {
let mut sync = Nosync;
if chat.is_promoted() {
if chat.is_promoted() && chat.typ != Chattype::OutBroadcast {
remove_from_chat_contacts_table(context, chat_id, contact_id).await?;
} else {
remove_from_chat_contacts_table_without_trace(context, chat_id, contact_id).await?;
@@ -4369,13 +4374,17 @@ pub async fn forward_msgs_2ctx(
if msg.get_viewtype() == Viewtype::Call {
msg.viewtype = Viewtype::Text;
}
if msg.download_state != DownloadState::Done {
msg.text += &msg.additional_text;
}
msg.text += &msg.additional_text;
let param = &mut param;
msg.param.steal(param, Param::File);
// When forwarding between different accounts, blob files must be physically copied
// because each account has its own blob directory.
if let Some(src_path) = param.get_file_path(ctx_src)? {
let new_blob = BlobObject::create_and_deduplicate(ctx_dst, &src_path, &src_path)
.context("Failed to copy blob file to destination account")?;
msg.param.set(Param::File, new_blob.as_name());
}
msg.param.steal(param, Param::Filename);
msg.param.steal(param, Param::Width);
msg.param.steal(param, Param::Height);
@@ -4453,9 +4462,7 @@ pub(crate) async fn save_copy_in_self_talk(
msg.param.remove(Param::PostMessageFileBytes);
msg.param.remove(Param::PostMessageViewtype);
if msg.download_state != DownloadState::Done {
msg.text += &msg.additional_text;
}
msg.text += &msg.additional_text;
if !msg.original_msg_id.is_unset() {
bail!("message already saved.");
+192 -5
View File
@@ -1,6 +1,7 @@
use std::sync::Arc;
use super::*;
use crate::Event;
use crate::chatlist::get_archived_cnt;
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS};
use crate::ephemeral::Timer;
@@ -1240,6 +1241,96 @@ async fn test_unarchive_if_muted() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_marknoticed_all_chats() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
tcm.section("alice: create chats & promote them by sending a message");
let alice_chat_normal = alice
.create_group_with_members("Chat (normal)", &[alice, bob])
.await;
send_text_msg(alice, alice_chat_normal, "Hi".to_string()).await?;
let alice_chat_muted = alice
.create_group_with_members("Chat (muted)", &[alice, bob])
.await;
send_text_msg(alice, alice_chat_muted, "Hi".to_string()).await?;
set_muted(&alice.ctx, alice_chat_muted, MuteDuration::Forever).await?;
let alice_chat_archived_and_muted = alice
.create_group_with_members("Chat (archived and muted)", &[alice, bob])
.await;
send_text_msg(alice, alice_chat_archived_and_muted, "Hi".to_string()).await?;
set_muted(
&alice.ctx,
alice_chat_archived_and_muted,
MuteDuration::Forever,
)
.await?;
alice_chat_archived_and_muted
.set_visibility(&alice.ctx, ChatVisibility::Archived)
.await?;
tcm.section("bob: receive messages, accept all chats and send a reply to each messsage");
while let Some(sent_msg) = alice.pop_sent_msg_opt(Duration::default()).await {
let bob_message = bob.recv_msg(&sent_msg).await;
let bob_chat_id = bob_message.chat_id;
bob_chat_id.accept(bob).await?;
send_text_msg(bob, bob_chat_id, "reply".to_string()).await?;
}
tcm.section("alice: receive replies from bob");
while let Some(sent_msg) = bob.pop_sent_msg_opt(Duration::default()).await {
alice.recv_msg(&sent_msg).await;
}
// ensure chats have unread messages
assert_eq!(alice_chat_normal.get_fresh_msg_cnt(alice).await?, 1);
assert_eq!(alice_chat_muted.get_fresh_msg_cnt(alice).await?, 1);
assert_eq!(
alice_chat_archived_and_muted
.get_fresh_msg_cnt(alice)
.await?,
1
);
tcm.section("alice: mark as read");
alice.evtracker.clear_events();
marknoticed_all_chats(alice).await?;
tcm.section("alice: check that chats are no longer unread and that chatlist update events were received");
assert_eq!(alice_chat_normal.get_fresh_msg_cnt(alice).await?, 0);
assert_eq!(alice_chat_muted.get_fresh_msg_cnt(alice).await?, 0);
assert_eq!(
alice_chat_archived_and_muted
.get_fresh_msg_cnt(alice)
.await?,
0
);
let emitted_events = alice.evtracker.take_events();
for event in &[
EventType::ChatlistItemChanged {
chat_id: Some(alice_chat_normal),
},
EventType::ChatlistItemChanged {
chat_id: Some(alice_chat_muted),
},
EventType::ChatlistItemChanged {
chat_id: Some(alice_chat_archived_and_muted),
},
EventType::ChatlistItemChanged {
chat_id: Some(DC_CHAT_ID_ARCHIVED_LINK),
},
] {
assert!(emitted_events.iter().any(|Event { typ, .. }| typ == event));
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_archive_fresh_msgs() -> Result<()> {
let t = TestContext::new_alice().await;
@@ -3344,6 +3435,11 @@ async fn test_remove_member_from_broadcast() -> Result<()> {
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
remove_contact_from_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
// Alice must not remember old members,
// because we would like to remember the minimum information possible
let past_contacts = get_past_chat_contacts(alice, alice_chat_id).await?;
assert_eq!(past_contacts.len(), 0);
let remove_msg = alice.pop_sent_msg().await;
let rcvd = bob.recv_msg(&remove_msg).await;
assert_eq!(rcvd.text, "Member Me removed by alice@example.org.");
@@ -3720,13 +3816,13 @@ async fn test_chat_get_encryption_info() -> Result<()> {
let chat_id = create_group(alice, "Group").await?;
assert_eq!(
chat_id.get_encryption_info(alice).await?,
"End-to-end encryption available"
"Messages are end-to-end encrypted."
);
add_contact_to_chat(alice, chat_id, contact_bob).await?;
assert_eq!(
chat_id.get_encryption_info(alice).await?,
"End-to-end encryption available\n\
"Messages are end-to-end encrypted.\n\
\n\
bob@example.net\n\
CCCB 5AA9 F6E1 141C 9431\n\
@@ -3736,7 +3832,7 @@ async fn test_chat_get_encryption_info() -> Result<()> {
add_contact_to_chat(alice, chat_id, contact_fiona).await?;
assert_eq!(
chat_id.get_encryption_info(alice).await?,
"End-to-end encryption available\n\
"Messages are end-to-end encrypted.\n\
\n\
fiona@example.net\n\
C8BA 50BF 4AC1 2FAF 38D7\n\
@@ -3750,13 +3846,13 @@ async fn test_chat_get_encryption_info() -> Result<()> {
let email_chat = alice.create_email_chat(bob).await;
assert_eq!(
email_chat.id.get_encryption_info(alice).await?,
"No encryption"
"No encryption."
);
alice.sql.execute("DELETE FROM public_keys", ()).await?;
assert_eq!(
chat_id.get_encryption_info(alice).await?,
"End-to-end encryption available\n\
"Messages are end-to-end encrypted.\n\
\n\
fiona@example.net\n\
(key missing)\n\
@@ -5395,6 +5491,97 @@ async fn test_forward_msgs_2ctx() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_forward_msgs_2ctx_with_file() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// First, establish a chat between Alice and Bob to have the chat IDs
let alice_chat = alice.create_chat(bob).await;
let alice_initial = alice.send_text(alice_chat.id, "hi").await;
let bob_alice_msg = bob.recv_msg(&alice_initial).await;
let bob_chat_id = bob_alice_msg.chat_id;
bob_chat_id.accept(bob).await?;
// Alice sends a message with an attached file to her self-chat
let alice_self_chat = alice.get_self_chat().await;
let file_bytes = b"test file content";
let file = alice.get_blobdir().join("test.txt");
tokio::fs::write(&file, file_bytes).await?;
let mut msg = Message::new(Viewtype::File);
msg.set_file_and_deduplicate(alice, &file, Some("test.txt"), Some("text/plain"))?;
msg.set_text("Here's a file".to_string());
alice.send_msg(alice_self_chat.id, &mut msg).await;
let alice_self_msg = alice.get_last_msg().await;
// Verify the file exists in Alice's blobdir
assert_eq!(alice_self_msg.viewtype, Viewtype::File);
let alice_original_file_path = alice_self_msg.get_file(alice).unwrap();
let alice_original_content = tokio::fs::read(&alice_original_file_path).await?;
assert_eq!(alice_original_content, file_bytes);
// Alice forwards the message to Bob using forward_msgs_2ctx
forward_msgs_2ctx(alice, &[alice_self_msg.id], bob, bob_chat_id).await?;
// Bob should have the forwarded message with the file in his database
let bob_msg = bob.get_last_msg().await;
assert_eq!(bob_msg.viewtype, Viewtype::File);
assert!(bob_msg.is_forwarded());
assert_eq!(bob_msg.text, "Here's a file");
assert_eq!(bob_msg.from_id, ContactId::SELF);
// Verify Bob has the file in his blobdir with correct content
let bob_file_path = bob_msg.get_file(bob).unwrap();
let bob_file_content = tokio::fs::read(&bob_file_path).await?;
assert_eq!(bob_file_content, file_bytes);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_forward_msgs_2ctx_missing_blob() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
let alice_initial = alice.send_text(alice_chat.id, "hi").await;
let bob_alice_msg = bob.recv_msg(&alice_initial).await;
let bob_chat_id = bob_alice_msg.chat_id;
bob_chat_id.accept(bob).await?;
// Alice sends a file to her self-chat
let alice_self_chat = alice.get_self_chat().await;
let file_bytes = b"test content";
let file = alice.get_blobdir().join("test.txt");
tokio::fs::write(&file, file_bytes).await?;
let mut msg = Message::new(Viewtype::File);
msg.set_file_and_deduplicate(alice, &file, Some("test.txt"), Some("text/plain"))?;
msg.set_text("File message".to_string());
alice.send_msg(alice_self_chat.id, &mut msg).await;
let alice_self_msg = alice.get_last_msg().await;
// Delete the blob file from Alice's blobdir to simulate a missing file
let alice_file_path = alice_self_msg.get_file(alice).unwrap();
tokio::fs::remove_file(&alice_file_path).await?;
// Alice tries to forward the message - this should fail with an error
let result = forward_msgs_2ctx(alice, &[alice_self_msg.id], bob, bob_chat_id).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to copy blob file")
);
Ok(())
}
/// Tests that in multi-device setup
/// second device learns the key of a contact
/// via Autocrypt-Gossip in 1:1 chats.
+39 -3
View File
@@ -76,7 +76,7 @@ impl Chatlist {
/// the pseudo-chat DC_CHAT_ID_ARCHIVED_LINK is added if there are *any* archived
/// chats
/// - the flag DC_GCL_FOR_FORWARDING sorts "Saved messages" to the top of the chatlist
/// and hides the device-chat and contact requests
/// and hides the device-chat, contact requests and incoming broadcasts.
/// typically used on forwarding, may be combined with DC_GCL_NO_SPECIALS
/// - if the flag DC_GCL_NO_SPECIALS is set, archive link is not added
/// to the list (may be used eg. for selecting chats on forwarding, the flag is
@@ -224,8 +224,9 @@ impl Chatlist {
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
&& param.get(Param::ListPost).is_none_or_empty()
if typ == Chattype::InBroadcast
|| (typ == Chattype::Mailinglist
&& param.get(Param::ListPost).is_none_or_empty())
{
None
} else {
@@ -597,6 +598,41 @@ mod tests {
assert_eq!(chats.len(), 1);
}
/// Test that DC_CHAT_TYPE_IN_BROADCAST are hidden
/// and DC_CHAT_TYPE_OUT_BROADCAST are shown in chatlist for forwarding.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_visiblity_on_forward() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_broadcast_a_id = create_broadcast(alice, "Channel Alice".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_broadcast_a_id))
.await
.unwrap();
let bob_broadcast_a_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
let bob_broadcast_b_id = create_broadcast(bob, "Channel Bob".to_string()).await?;
let chats = Chatlist::try_load(bob, DC_GCL_FOR_FORWARDING, None, None)
.await
.unwrap();
assert!(
!chats
.iter()
.any(|(chat_id, _)| chat_id == &bob_broadcast_a_id),
"alice broadcast is not shown in bobs forwarding chatlist"
);
assert!(
chats
.iter()
.any(|(chat_id, _)| chat_id == &bob_broadcast_b_id),
"bobs own broadcast is shown in his forwarding chatlist"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_search_special_chat_names() {
let t = TestContext::new_alice().await;
-5
View File
@@ -175,11 +175,6 @@ pub enum Config {
#[strum(props(default = "0"))] // also change MediaQuality.default() on changes
MediaQuality,
/// If set to "1", then existing messages are considered to be already fetched.
/// This flag is reset after successful configuration.
#[strum(props(default = "1"))]
FetchedExistingMsgs,
/// Timer in seconds after which the message is deleted from the
/// server.
///
+1 -3
View File
@@ -23,7 +23,7 @@ use percent_encoding::utf8_percent_encode;
use server_params::{ServerParams, expand_param_vector};
use tokio::task;
use crate::config::{self, Config};
use crate::config::Config;
use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
use crate::context::Context;
use crate::imap::Imap;
@@ -631,8 +631,6 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
progress!(ctx, 920);
ctx.set_config_internal(Config::FetchedExistingMsgs, config::from_bool(false))
.await?;
ctx.scheduler.interrupt_inbox().await;
progress!(ctx, 940);
+3 -15
View File
@@ -1299,18 +1299,6 @@ WHERE addr=?
Ok(())
}
/// Returns number of blocked contacts.
pub async fn get_blocked_cnt(context: &Context) -> Result<usize> {
let count = context
.sql
.count(
"SELECT COUNT(*) FROM contacts WHERE id>? AND blocked!=0",
(ContactId::LAST_SPECIAL,),
)
.await?;
Ok(count)
}
/// Get blocked contacts.
pub async fn get_all_blocked(context: &Context) -> Result<Vec<ContactId>> {
Contact::update_blocked_mailinglist_contacts(context)
@@ -1354,13 +1342,13 @@ WHERE addr=?
let fingerprint_other = fingerprint_other.to_string();
let stock_message = if contact.public_key(context).await?.is_some() {
stock_str::e2e_available(context).await
stock_str::messages_e2e_encrypted(context).await
} else {
stock_str::encr_none(context).await
};
let finger_prints = stock_str::finger_prints(context).await;
let mut ret = format!("{stock_message}.\n{finger_prints}:");
let mut ret = format!("{stock_message}\n{finger_prints}:");
let fingerprint_self = load_self_public_key(context)
.await?
@@ -1466,7 +1454,7 @@ WHERE addr=?
/// Returns true if the contact is a key-contact.
/// Otherwise it is an addresss-contact.
pub fn is_key_contact(&self) -> bool {
self.fingerprint.is_some()
self.fingerprint.is_some() || self.id == ContactId::SELF
}
/// Returns OpenPGP fingerprint of a contact.
+2 -2
View File
@@ -823,7 +823,7 @@ async fn test_contact_get_encrinfo() -> Result<()> {
let address_contact_bob_id = alice.add_or_lookup_address_contact_id(bob).await;
let encrinfo = Contact::get_encrinfo(alice, address_contact_bob_id).await?;
assert_eq!(encrinfo, "No encryption");
assert_eq!(encrinfo, "No encryption.");
let contact = Contact::get_by_id(alice, address_contact_bob_id).await?;
assert!(!contact.e2ee_avail(alice).await?);
@@ -832,7 +832,7 @@ async fn test_contact_get_encrinfo() -> Result<()> {
let encrinfo = Contact::get_encrinfo(alice, contact_bob_id).await?;
assert_eq!(
encrinfo,
"End-to-end encryption available.
"Messages are end-to-end encrypted.
Fingerprints:
Me (alice@example.org):
+15 -27
View File
@@ -36,6 +36,8 @@ use crate::tools::{self, duration_to_str, time, time_elapsed};
use crate::transport::ConfiguredLoginParam;
use crate::{chatlist_events, stats};
pub use crate::scheduler::connectivity::Connectivity;
/// Builder for the [`Context`].
///
/// Many arguments to the [`Context`] are kind of optional and only needed to handle
@@ -944,12 +946,6 @@ impl Context {
}
res.insert("secondary_addrs", secondary_addrs);
res.insert(
"fetched_existing_msgs",
self.get_config_bool(Config::FetchedExistingMsgs)
.await?
.to_string(),
);
res.insert(
"show_emails",
self.get_config_int(Config::ShowEmails).await?.to_string(),
@@ -1120,21 +1116,19 @@ impl Context {
let list = self
.sql
.query_map_vec(
concat!(
"SELECT m.id",
" FROM msgs m",
" LEFT JOIN contacts ct",
" ON m.from_id=ct.id",
" LEFT JOIN chats c",
" ON m.chat_id=c.id",
" WHERE m.state=?",
" AND m.hidden=0",
" AND m.chat_id>9",
" AND ct.blocked=0",
" AND c.blocked=0",
" AND NOT(c.muted_until=-1 OR c.muted_until>?)",
" ORDER BY m.timestamp DESC,m.id DESC;"
),
"SELECT m.id
FROM msgs m
LEFT JOIN contacts ct
ON m.from_id=ct.id
LEFT JOIN chats c
ON m.chat_id=c.id
WHERE m.state=?
AND m.hidden=0
AND m.chat_id>9
AND ct.blocked=0
AND c.blocked=0
AND NOT(c.muted_until=-1 OR c.muted_until>?)
ORDER BY m.timestamp DESC,m.id DESC",
(MessageState::InFresh, time()),
|row| {
let msg_id: MsgId = row.get(0)?;
@@ -1286,12 +1280,6 @@ impl Context {
Ok(list)
}
/// Returns true if given folder name is the name of the inbox.
pub async fn is_inbox(&self, folder_name: &str) -> Result<bool> {
let inbox = self.get_config(Config::ConfiguredInboxFolder).await?;
Ok(inbox.as_deref() == Some(folder_name))
}
/// Returns true if given folder name is the name of the "DeltaChat" folder.
pub async fn is_mvbox(&self, folder_name: &str) -> Result<bool> {
let mvbox = self.get_config(Config::ConfiguredMvboxFolder).await?;
+1 -4
View File
@@ -189,10 +189,7 @@ impl Session {
bail!("Attempt to fetch UID 0");
}
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
ensure!(folder_exists, "No folder {folder}");
// we are connected, and the folder is selected
+25 -195
View File
@@ -16,19 +16,20 @@ use std::{
use anyhow::{Context as _, Result, bail, ensure, format_err};
use async_channel::{self, Receiver, Sender};
use async_imap::types::{Fetch, Flag, Name, NameAttribute, UnsolicitedResponse};
use deltachat_contact_tools::ContactAddress;
use futures::{FutureExt as _, TryStreamExt};
use futures_lite::FutureExt;
use num_traits::FromPrimitive;
use ratelimit::Ratelimit;
use url::Url;
use crate::calls::{create_fallback_ice_servers, create_ice_servers_from_metadata};
use crate::calls::{
UnresolvedIceServer, create_fallback_ice_servers, create_ice_servers_from_metadata,
};
use crate::chat::{self, ChatId, ChatIdBlocked, add_device_msg};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::{self, Blocked, DC_VERSION_STR, ShowEmails};
use crate::contact::{Contact, ContactId, Modifier, Origin};
use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
@@ -57,7 +58,6 @@ pub mod select_folder;
pub(crate) mod session;
use client::{Client, determine_capabilities};
use mailparse::SingleInfo;
use session::Session;
pub(crate) const GENERATED_PREFIX: &str = "GEN_";
@@ -134,16 +134,15 @@ pub(crate) struct ServerMetadata {
pub iroh_relay: Option<Url>,
/// JSON with ICE servers for WebRTC calls
/// and the expiration timestamp.
///
/// If JSON is about to expire, new TURN credentials
/// should be fetched from the server
/// to be ready for WebRTC calls.
pub ice_servers: String,
/// ICE servers for WebRTC calls.
pub ice_servers: Vec<UnresolvedIceServer>,
/// Timestamp when ICE servers are considered
/// expired and should be updated.
///
/// If ICE servers are about to expire, new TURN credentials
/// should be fetched from the server
/// to be ready for WebRTC calls.
pub ice_servers_expiration_timestamp: i64,
}
@@ -500,13 +499,7 @@ impl Imap {
.get_raw_config_int(constants::DC_FOLDERS_CONFIGURED_KEY)
.await?;
if folders_configured.unwrap_or_default() < constants::DC_FOLDERS_CONFIGURED_VERSION {
let is_chatmail = match context.get_config_bool(Config::FixIsChatmail).await? {
false => session.is_chatmail(),
true => context.get_config_bool(Config::IsChatmail).await?,
};
let create_mvbox = !is_chatmail || context.get_config_bool(Config::MvboxMove).await?;
self.configure_folders(context, &mut session, create_mvbox)
.await?;
self.configure_folders(context, &mut session).await?;
}
Ok(session)
@@ -564,9 +557,8 @@ impl Imap {
return Ok(false);
}
let create = false;
let folder_exists = session
.select_with_uidvalidity(context, folder, create)
.select_with_uidvalidity(context, folder)
.await
.with_context(|| format!("Failed to select folder {folder:?}"))?;
if !folder_exists {
@@ -836,27 +828,6 @@ impl Imap {
Ok((read_cnt, fetch_more))
}
/// Read the recipients from old emails sent by the user and add them as contacts.
/// This way, we can already offer them some email addresses they can write to.
///
/// Then, Fetch the last messages DC_FETCH_EXISTING_MSGS_COUNT emails from the server
/// and show them in the chat list.
pub(crate) async fn fetch_existing_msgs(
&mut self,
context: &Context,
session: &mut Session,
) -> Result<()> {
add_all_recipients_as_contacts(context, session, Config::ConfiguredMvboxFolder)
.await
.context("failed to get recipients from the movebox")?;
add_all_recipients_as_contacts(context, session, Config::ConfiguredInboxFolder)
.await
.context("failed to get recipients from the inbox")?;
info!(context, "Done fetching existing messages.");
Ok(())
}
}
impl Session {
@@ -895,10 +866,7 @@ impl Session {
// Collect pairs of UID and Message-ID.
let mut msgs = BTreeMap::new();
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
let transport_id = self.transport_id();
if folder_exists {
let mut list = self
@@ -1104,10 +1072,7 @@ impl Session {
// MOVE/DELETE operations. This does not result in multiple SELECT commands
// being sent because `select_folder()` does nothing if the folder is already
// selected.
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
ensure!(folder_exists, "No folder {folder}");
// Empty target folder name means messages should be deleted.
@@ -1139,7 +1104,6 @@ impl Session {
/// Stores pending `\Seen` flags for messages in `imap_markseen` table.
pub(crate) async fn store_seen_flags_on_imap(&mut self, context: &Context) -> Result<()> {
if context.get_config_bool(Config::TeamProfile).await? {
info!(context, "Team profile, skipping seen flag synchronization.");
return Ok(());
}
@@ -1163,8 +1127,7 @@ impl Session {
.await?;
for (folder, rowid_set, uid_set) in UidGrouper::from(rows) {
let create = false;
let folder_exists = match self.select_with_uidvalidity(context, &folder, create).await {
let folder_exists = match self.select_with_uidvalidity(context, &folder).await {
Err(err) => {
warn!(
context,
@@ -1215,13 +1178,11 @@ impl Session {
}
if context.get_config_bool(Config::TeamProfile).await? {
info!(context, "Team profile, skipping seen flag synchronization.");
return Ok(());
}
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.select_with_uidvalidity(context, folder)
.await
.context("Failed to select folder")?;
if !folder_exists {
@@ -1313,41 +1274,6 @@ impl Session {
Ok(())
}
/// Gets the from, to and bcc addresses from all existing outgoing emails.
pub async fn get_all_recipients(&mut self, context: &Context) -> Result<Vec<SingleInfo>> {
let mut uids: Vec<_> = self
.uid_search(get_imap_self_sent_search_command(context).await?)
.await?
.into_iter()
.collect();
uids.sort_unstable();
let mut result = Vec::new();
for (_, uid_set) in build_sequence_sets(&uids)? {
let mut list = self
.uid_fetch(uid_set, "(UID BODY.PEEK[HEADER.FIELDS (FROM TO CC BCC)])")
.await
.context("IMAP Could not fetch")?;
while let Some(msg) = list.try_next().await? {
match get_fetch_headers(&msg) {
Ok(headers) => {
if let Some(from) = mimeparser::get_from(&headers)
&& context.is_self_addr(&from.addr).await?
{
result.extend(mimeparser::get_recipients(&headers));
}
}
Err(err) => {
warn!(context, "{}", err);
continue;
}
};
}
}
Ok(result)
}
/// Fetches a list of messages by server UID.
///
/// Sends pairs of UID and info about each downloaded message to the provided channel.
@@ -1552,7 +1478,7 @@ impl Session {
if m.entry == "/shared/vendor/deltachat/turn"
&& let Some(value) = m.value
{
match create_ice_servers_from_metadata(context, &value).await {
match create_ice_servers_from_metadata(&value).await {
Ok((parsed_timestamp, parsed_ice_servers)) => {
old_metadata.ice_servers_expiration_timestamp = parsed_timestamp;
old_metadata.ice_servers = parsed_ice_servers;
@@ -1569,7 +1495,7 @@ impl Session {
info!(context, "Will use fallback ICE servers.");
// Set expiration timestamp 7 days in the future so we don't request it again.
old_metadata.ice_servers_expiration_timestamp = time() + 3600 * 24 * 7;
old_metadata.ice_servers = create_fallback_ice_servers(context).await?;
old_metadata.ice_servers = create_fallback_ice_servers();
}
return Ok(());
}
@@ -1616,7 +1542,7 @@ impl Session {
}
"/shared/vendor/deltachat/turn" => {
if let Some(value) = m.value {
match create_ice_servers_from_metadata(context, &value).await {
match create_ice_servers_from_metadata(&value).await {
Ok((parsed_timestamp, parsed_ice_servers)) => {
ice_servers_expiration_timestamp = parsed_timestamp;
ice_servers = Some(parsed_ice_servers);
@@ -1635,7 +1561,7 @@ impl Session {
} else {
// Set expiration timestamp 7 days in the future so we don't request it again.
ice_servers_expiration_timestamp = time() + 3600 * 24 * 7;
create_fallback_ice_servers(context).await?
create_fallback_ice_servers()
};
*lock = Some(ServerMetadata {
@@ -1767,17 +1693,16 @@ impl Session {
/// Attempts to configure mvbox.
///
/// Tries to find any folder examining `folders` in the order they go. If none is found, tries
/// to create any folder in the same order. This method does not use LIST command to ensure that
/// Tries to find any folder examining `folders` in the order they go.
/// This method does not use LIST command to ensure that
/// configuration works even if mailbox lookup is forbidden via Access Control List (see
/// <https://datatracker.ietf.org/doc/html/rfc4314>).
///
/// Returns first found or created folder name.
/// Returns first found folder name.
async fn configure_mvbox<'a>(
&mut self,
context: &Context,
folders: &[&'a str],
create_mvbox: bool,
) -> Result<Option<&'a str>> {
// Close currently selected folder if needed.
// We are going to select folders using low-level EXAMINE operations below.
@@ -1794,34 +1719,12 @@ impl Session {
self.close().await?;
// Before moving emails to the mvbox we need to remember its UIDVALIDITY, otherwise
// emails moved before that wouldn't be fetched but considered "old" instead.
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
ensure!(folder_exists, "No MVBOX folder {:?}??", &folder);
return Ok(Some(folder));
}
}
if !create_mvbox {
return Ok(None);
}
// Some servers require namespace-style folder names like "INBOX.DeltaChat", so we try all
// the variants here.
for folder in folders {
match self
.select_with_uidvalidity(context, folder, create_mvbox)
.await
{
Ok(_) => {
info!(context, "MVBOX-folder {} created.", folder);
return Ok(Some(folder));
}
Err(err) => {
warn!(context, "Cannot create MVBOX-folder {:?}: {}", folder, err);
}
}
}
Ok(None)
}
}
@@ -1831,7 +1734,6 @@ impl Imap {
&mut self,
context: &Context,
session: &mut Session,
create_mvbox: bool,
) -> Result<()> {
let mut folders = session
.list(Some(""), Some("*"))
@@ -1872,7 +1774,7 @@ impl Imap {
let fallback_folder = format!("INBOX{delimiter}DeltaChat");
let mvbox_folder = session
.configure_mvbox(context, &["DeltaChat", &fallback_folder], create_mvbox)
.configure_mvbox(context, &["DeltaChat", &fallback_folder])
.await
.context("failed to configure mvbox")?;
@@ -2468,18 +2370,6 @@ async fn get_modseq(context: &Context, transport_id: u32, folder: &str) -> Resul
.unwrap_or(0))
}
/// Compute the imap search expression for all self-sent mails (for all self addresses)
pub(crate) async fn get_imap_self_sent_search_command(context: &Context) -> Result<String> {
// See https://www.rfc-editor.org/rfc/rfc3501#section-6.4.4 for syntax of SEARCH and OR
let mut search_command = format!("FROM \"{}\"", context.get_primary_self_addr().await?);
for item in context.get_secondary_self_addrs().await? {
search_command = format!("OR ({search_command}) (FROM \"{item}\")");
}
Ok(search_command)
}
/// Whether to ignore fetching messages from a folder.
///
/// This caters for the [`Config::OnlyFetchMvbox`] setting which means mails from folders
@@ -2552,66 +2442,6 @@ impl std::fmt::Display for UidRange {
}
}
}
async fn add_all_recipients_as_contacts(
context: &Context,
session: &mut Session,
folder: Config,
) -> Result<()> {
let mailbox = if let Some(m) = context.get_config(folder).await? {
m
} else {
info!(
context,
"Folder {} is not configured, skipping fetching contacts from it.", folder
);
return Ok(());
};
let create = false;
let folder_exists = session
.select_with_uidvalidity(context, &mailbox, create)
.await
.with_context(|| format!("could not select {mailbox}"))?;
if !folder_exists {
return Ok(());
}
let recipients = session
.get_all_recipients(context)
.await
.context("could not get recipients")?;
let mut any_modified = false;
for recipient in recipients {
let recipient_addr = match ContactAddress::new(&recipient.addr) {
Err(err) => {
warn!(
context,
"Could not add contact for recipient with address {:?}: {:#}",
recipient.addr,
err
);
continue;
}
Ok(recipient_addr) => recipient_addr,
};
let (_, modified) = Contact::add_or_lookup(
context,
&recipient.display_name.unwrap_or_default(),
&recipient_addr,
Origin::OutgoingTo,
)
.await?;
if modified != Modifier::None {
any_modified = true;
}
}
if any_modified {
context.emit_event(EventType::ContactsChanged(None));
}
Ok(())
}
#[cfg(test)]
mod imap_tests;
+1 -3
View File
@@ -27,9 +27,7 @@ impl Session {
idle_interrupt_receiver: Receiver<()>,
folder: &str,
) -> Result<Self> {
let create = true;
self.select_with_uidvalidity(context, folder, create)
.await?;
self.select_with_uidvalidity(context, folder).await?;
if self.drain_unsolicited_responses(context)? {
self.new_mail = true;
+1 -26
View File
@@ -1,6 +1,6 @@
use super::*;
use crate::contact::Contact;
use crate::test_utils::TestContext;
use crate::transport::add_pseudo_transport;
#[test]
fn test_get_folder_meaning_by_name() {
@@ -264,31 +264,6 @@ async fn test_target_folder_setupmsg() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_imap_search_command() -> Result<()> {
let t = TestContext::new_alice().await;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,
r#"FROM "alice@example.org""#
);
add_pseudo_transport(&t, "alice@another.com").await?;
t.ctx.set_primary_self_addr("alice@another.com").await?;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,
r#"OR (FROM "alice@another.com") (FROM "alice@example.org")"#
);
add_pseudo_transport(&t, "alice@third.com").await?;
t.ctx.set_primary_self_addr("alice@third.com").await?;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,
r#"OR (OR (FROM "alice@third.com") (FROM "alice@another.com")) (FROM "alice@example.org")"#
);
Ok(())
}
#[test]
fn test_uid_grouper() {
// Input: sequence of (rowid: i64, uid: u32, target: String)
+9 -43
View File
@@ -89,33 +89,6 @@ impl ImapSession {
}
}
/// Selects a folder. Tries to create it once and select again if the folder does not exist.
pub(super) async fn select_or_create_folder(
&mut self,
context: &Context,
folder: &str,
) -> anyhow::Result<NewlySelected> {
match self.select_folder(context, folder).await {
Ok(newly_selected) => Ok(newly_selected),
Err(err) => match err {
Error::NoFolder(..) => {
info!(context, "Failed to select folder {folder:?} because it does not exist, trying to create it.");
let create_res = self.create(folder).await;
if let Err(ref err) = create_res {
info!(context, "Couldn't select folder, then create() failed: {err:#}.");
// Need to recheck, could have been created in parallel.
}
let select_res = self.select_folder(context, folder).await.with_context(|| format!("failed to select newely created folder {folder}"));
if select_res.is_err() {
create_res?;
}
select_res
}
_ => Err(err).with_context(|| format!("failed to select folder {folder} with error other than NO, not trying to create it")),
},
}
}
/// Selects a folder optionally creating it and takes care of UIDVALIDITY changes. Returns false
/// iff `folder` doesn't exist.
///
@@ -129,23 +102,16 @@ impl ImapSession {
&mut self,
context: &Context,
folder: &str,
create: bool,
) -> anyhow::Result<bool> {
let newly_selected = if create {
self.select_or_create_folder(context, folder)
.await
.with_context(|| format!("Failed to select or create folder {folder:?}"))?
} else {
match self.select_folder(context, folder).await {
Ok(newly_selected) => newly_selected,
Err(err) => match err {
Error::NoFolder(..) => return Ok(false),
_ => {
return Err(err)
.with_context(|| format!("Failed to select folder {folder:?}"))?;
}
},
}
let newly_selected = match self.select_folder(context, folder).await {
Ok(newly_selected) => newly_selected,
Err(err) => match err {
Error::NoFolder(..) => return Ok(false),
_ => {
return Err(err)
.with_context(|| format!("Failed to select folder {folder:?}"))?;
}
},
};
let transport_id = self.transport_id();
+7 -17
View File
@@ -156,24 +156,14 @@ pub(crate) async fn load_self_public_key(context: &Context) -> Result<SignedPubl
}
/// Returns our own public keyring.
///
/// No keys are generated and at most one key is returned.
pub(crate) async fn load_self_public_keyring(context: &Context) -> Result<Vec<SignedPublicKey>> {
let keys = context
.sql
.query_map_vec(
r#"SELECT public_key
FROM keypairs
ORDER BY id=(SELECT value FROM config WHERE keyname='key_id') DESC"#,
(),
|row| {
let public_key_bytes: Vec<u8> = row.get(0)?;
Ok(public_key_bytes)
},
)
.await?
.into_iter()
.filter_map(|bytes| SignedPublicKey::from_slice(&bytes).log_err(context).ok())
.collect();
Ok(keys)
if let Some(public_key) = load_self_public_key_opt(context).await? {
Ok(vec![public_key])
} else {
Ok(vec![])
}
}
/// Returns own public key fingerprint in (not human-readable) hex representation.
+71 -47
View File
@@ -87,12 +87,10 @@ impl MsgId {
let result = context
.sql
.query_row_optional(
concat!(
"SELECT m.state, mdns.msg_id",
" FROM msgs m LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id",
" WHERE id=?",
" LIMIT 1",
),
"SELECT m.state, mdns.msg_id
FROM msgs m LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id
WHERE id=?
LIMIT 1",
(self,),
|row| {
let state: MessageState = row.get(0)?;
@@ -454,6 +452,7 @@ pub struct Message {
pub(crate) is_dc_message: MessengerMessage,
pub(crate) original_msg_id: MsgId,
pub(crate) mime_modified: bool,
pub(crate) chat_visibility: ChatVisibility,
pub(crate) chat_blocked: Blocked,
pub(crate) location_id: u32,
pub(crate) error: Option<String>,
@@ -501,40 +500,39 @@ impl Message {
let mut msg = context
.sql
.query_row_optional(
concat!(
"SELECT",
" m.id AS id,",
" rfc724_mid AS rfc724mid,",
" pre_rfc724_mid AS pre_rfc724mid,",
" m.mime_in_reply_to AS mime_in_reply_to,",
" m.chat_id AS chat_id,",
" m.from_id AS from_id,",
" m.to_id AS to_id,",
" m.timestamp AS timestamp,",
" m.timestamp_sent AS timestamp_sent,",
" m.timestamp_rcvd AS timestamp_rcvd,",
" m.ephemeral_timer AS ephemeral_timer,",
" m.ephemeral_timestamp AS ephemeral_timestamp,",
" m.type AS type,",
" m.state AS state,",
" mdns.msg_id AS mdn_msg_id,",
" m.download_state AS download_state,",
" m.error AS error,",
" m.msgrmsg AS msgrmsg,",
" m.starred AS original_msg_id,",
" m.mime_modified AS mime_modified,",
" m.txt AS txt,",
" m.subject AS subject,",
" m.param AS param,",
" m.hidden AS hidden,",
" m.location_id AS location,",
" c.blocked AS blocked",
" FROM msgs m",
" LEFT JOIN chats c ON c.id=m.chat_id",
" LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id",
" WHERE m.id=? AND chat_id!=3",
" LIMIT 1",
),
"SELECT
m.id AS id,
rfc724_mid AS rfc724mid,
pre_rfc724_mid AS pre_rfc724mid,
m.mime_in_reply_to AS mime_in_reply_to,
m.chat_id AS chat_id,
m.from_id AS from_id,
m.to_id AS to_id,
m.timestamp AS timestamp,
m.timestamp_sent AS timestamp_sent,
m.timestamp_rcvd AS timestamp_rcvd,
m.ephemeral_timer AS ephemeral_timer,
m.ephemeral_timestamp AS ephemeral_timestamp,
m.type AS type,
m.state AS state,
mdns.msg_id AS mdn_msg_id,
m.download_state AS download_state,
m.error AS error,
m.msgrmsg AS msgrmsg,
m.starred AS original_msg_id,
m.mime_modified AS mime_modified,
m.txt AS txt,
m.subject AS subject,
m.param AS param,
m.hidden AS hidden,
m.location_id AS location,
c.archived AS visibility,
c.blocked AS blocked
FROM msgs m
LEFT JOIN chats c ON c.id=m.chat_id
LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id
WHERE m.id=? AND chat_id!=3
LIMIT 1",
(id,),
|row| {
let state: MessageState = row.get("state")?;
@@ -587,6 +585,7 @@ impl Message {
param: row.get::<_, String>("param")?.parse().unwrap_or_default(),
hidden: row.get("hidden")?,
location_id: row.get("location")?,
chat_visibility: row.get::<_, Option<_>>("visibility")?.unwrap_or_default(),
chat_blocked: row
.get::<_, Option<Blocked>>("blocked")?
.unwrap_or_default(),
@@ -1507,6 +1506,16 @@ pub async fn get_msg_read_receipts(
.await
}
/// Returns count of read receipts on message.
///
/// This view count is meant as a feedback measure for the channel owner only.
pub async fn get_msg_read_receipt_count(context: &Context, msg_id: MsgId) -> Result<usize> {
context
.sql
.count("SELECT COUNT(*) FROM msgs_mdns WHERE msg_id=?", (msg_id,))
.await
}
pub(crate) fn guess_msgtype_from_suffix(msg: &Message) -> Option<(Viewtype, &'static str)> {
msg.param
.get(Param::Filename)
@@ -1841,6 +1850,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
m.param AS param,
m.from_id AS from_id,
m.rfc724_mid AS rfc724_mid,
m.hidden AS hidden,
c.archived AS archived,
c.blocked AS blocked
FROM msgs m LEFT JOIN chats c ON c.id=m.chat_id
@@ -1852,6 +1862,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
let param: Params = row.get::<_, String>("param")?.parse().unwrap_or_default();
let from_id: ContactId = row.get("from_id")?;
let rfc724_mid: String = row.get("rfc724_mid")?;
let hidden: bool = row.get("hidden")?;
let visibility: ChatVisibility = row.get("archived")?;
let blocked: Option<Blocked> = row.get("blocked")?;
let ephemeral_timer: EphemeralTimer = row.get("ephemeral_timer")?;
@@ -1863,6 +1874,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
param,
from_id,
rfc724_mid,
hidden,
visibility,
blocked.unwrap_or_default(),
),
@@ -1895,6 +1907,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
curr_param,
curr_from_id,
curr_rfc724_mid,
curr_hidden,
curr_visibility,
curr_blocked,
),
@@ -1907,8 +1920,9 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
markseen_on_imap_table(context, &curr_rfc724_mid).await?;
// Read receipts for system messages are never sent. These messages have no place to
// display received read receipt anyway. And since their text is locally generated,
// Read receipts for system messages are never sent to contacts.
// These messages have no place to display received read receipt
// anyway. And since their text is locally generated,
// quoting them is dangerous as it may contain contact names. E.g., for original message
// "Group left by me", a read receipt will quote "Group left by <name>", and the name can
// be a display name stored in address book rather than the name sent in the From field by
@@ -1916,25 +1930,35 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
//
// We also don't send read receipts for contact requests.
// Read receipts will not be sent even after accepting the chat.
if curr_blocked == Blocked::Not
let to_id = if curr_blocked == Blocked::Not
&& curr_param.get_bool(Param::WantsMdn).unwrap_or_default()
&& curr_param.get_cmd() == SystemMessage::Unknown
&& context.should_send_mdns().await?
{
Some(curr_from_id)
} else if context.get_config_bool(Config::BccSelf).await? {
Some(ContactId::SELF)
} else {
None
};
if let Some(to_id) = to_id {
context
.sql
.execute(
"INSERT INTO smtp_mdns (msg_id, from_id, rfc724_mid) VALUES(?, ?, ?)",
(id, curr_from_id, curr_rfc724_mid),
(id, to_id, curr_rfc724_mid),
)
.await
.context("failed to insert into smtp_mdns")?;
context.scheduler.interrupt_smtp().await;
}
updated_chat_ids.insert(curr_chat_id);
if !curr_hidden {
updated_chat_ids.insert(curr_chat_id);
}
}
archived_chats_maybe_noticed |=
curr_state == MessageState::InFresh && curr_visibility == ChatVisibility::Archived;
archived_chats_maybe_noticed |= curr_state == MessageState::InFresh
&& !curr_hidden
&& curr_visibility == ChatVisibility::Archived;
}
for updated_chat_id in updated_chat_ids {
+35 -9
View File
@@ -106,6 +106,7 @@ pub struct MimeFactory {
/// addresses and OpenPGP keys
/// to use for encryption.
///
/// If `Some`, encrypt to self also.
/// `None` if the message is not encrypted.
encryption_pubkeys: Option<Vec<(String, SignedPublicKey)>>,
@@ -234,7 +235,6 @@ impl MimeFactory {
encryption_pubkeys = if msg.param.get_bool(Param::ForcePlaintext).unwrap_or(false) {
None
} else {
// Encrypt, but only to self.
Some(Vec::new())
};
} else if chat.is_mailing_list() {
@@ -247,6 +247,37 @@ impl MimeFactory {
// Do not encrypt messages to mailing lists.
encryption_pubkeys = None;
} else if let Some(fp) = must_have_only_one_recipient(&msg, &chat) {
let fp = fp?;
// In a broadcast channel, only send member-added/removed messages
// to the affected member
let (authname, addr) = context
.sql
.query_row(
"SELECT authname, addr FROM contacts WHERE fingerprint=?",
(fp,),
|row| {
let authname: String = row.get(0)?;
let addr: String = row.get(1)?;
Ok((authname, addr))
},
)
.await?;
let public_key_bytes: Vec<_> = context
.sql
.query_get_value(
"SELECT public_key FROM public_keys WHERE fingerprint=?",
(fp,),
)
.await?
.context("Can't send member addition/removal: missing key")?;
recipients.push(addr.clone());
to.push((authname, addr.clone()));
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
encryption_pubkeys = Some(vec![(addr, public_key)]);
} else {
let email_to_remove = if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup {
msg.param.get(Param::Arg)
@@ -306,13 +337,6 @@ impl MimeFactory {
for row in rows {
let (authname, addr, fingerprint, id, add_timestamp, remove_timestamp, public_key_bytes_opt) = row?;
// In a broadcast channel, only send member-added/removed messages
// to the affected member:
if let Some(fp) = must_have_only_one_recipient(&msg, &chat)
&& fp? != fingerprint {
continue;
}
let public_key_opt = if let Some(public_key_bytes) = &public_key_bytes_opt {
Some(SignedPublicKey::from_slice(public_key_bytes)?)
} else {
@@ -539,7 +563,9 @@ impl MimeFactory {
let timestamp = create_smeared_timestamp(context);
let addr = contact.get_addr().to_string();
let encryption_pubkeys = if contact.is_key_contact() {
let encryption_pubkeys = if from_id == ContactId::SELF {
Some(Vec::new())
} else if contact.is_key_contact() {
if let Some(key) = contact.public_key(context).await? {
Some(vec![(addr.clone(), key)])
} else {
+26 -25
View File
@@ -89,11 +89,12 @@ pub(crate) struct MimeMessage {
pub decrypting_failed: bool,
/// Valid signature fingerprint if a message is an
/// Autocrypt encrypted and signed message.
/// Autocrypt encrypted and signed message and corresponding intended recipient fingerprints
/// (<https://www.rfc-editor.org/rfc/rfc9580.html#name-intended-recipient-fingerpr>) if any.
///
/// If a message is not encrypted or the signature is not valid,
/// this is `None`.
pub signature: Option<Fingerprint>,
pub signature: Option<(Fingerprint, HashSet<Fingerprint>)>,
/// The addresses for which there was a gossip header
/// and their respective gossiped keys.
@@ -529,12 +530,16 @@ impl MimeMessage {
let mut signatures = if let Some(ref decrypted_msg) = decrypted_msg {
crate::pgp::valid_signature_fingerprints(decrypted_msg, &public_keyring)
} else {
HashSet::new()
HashMap::new()
};
let mail = mail.as_ref().map(|mail| {
let (content, signatures_detached) = validate_detached_signature(mail, &public_keyring)
.unwrap_or((mail, Default::default()));
let signatures_detached = signatures_detached
.into_iter()
.map(|fp| (fp, Vec::new()))
.collect::<HashMap<_, _>>();
signatures.extend(signatures_detached);
content
});
@@ -640,6 +645,10 @@ impl MimeMessage {
};
}
let signature = signatures
.into_iter()
.last()
.map(|(fp, recipient_fps)| (fp, recipient_fps.into_iter().collect::<HashSet<_>>()));
let mut parser = MimeMessage {
parts: Vec::new(),
headers,
@@ -655,7 +664,7 @@ impl MimeMessage {
decrypting_failed: mail.is_err(),
// only non-empty if it was a valid autocrypt message
signature: signatures.into_iter().last(),
signature,
autocrypt_fingerprint,
gossiped_keys,
is_forwarded: false,
@@ -2159,9 +2168,9 @@ pub(crate) struct Report {
///
/// It MUST be present if the original message has a Message-ID according to RFC 8098.
/// In case we can't find it (shouldn't happen), this is None.
original_message_id: Option<String>,
pub original_message_id: Option<String>,
/// Additional-Message-IDs
additional_message_ids: Vec<String>,
pub additional_message_ids: Vec<String>,
}
/// Delivery Status Notification (RFC 3464, RFC 6533)
@@ -2468,31 +2477,23 @@ async fn handle_mdn(
timestamp_sent: i64,
) -> Result<()> {
if from_id == ContactId::SELF {
warn!(
context,
"Ignoring MDN sent to self, this is a bug on the sender device."
);
// This is not an error on our side,
// we successfully ignored an invalid MDN and return `Ok`.
// MDNs to self are handled in receive_imf_inner().
return Ok(());
}
let Some((msg_id, chat_id, has_mdns, is_dup)) = context
.sql
.query_row_optional(
concat!(
"SELECT",
" m.id AS msg_id,",
" c.id AS chat_id,",
" mdns.contact_id AS mdn_contact",
" FROM msgs m ",
" LEFT JOIN chats c ON m.chat_id=c.id",
" LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id",
" WHERE rfc724_mid=? AND from_id=1",
" ORDER BY msg_id DESC, mdn_contact=? DESC",
" LIMIT 1",
),
"SELECT
m.id AS msg_id,
c.id AS chat_id,
mdns.contact_id AS mdn_contact
FROM msgs m
LEFT JOIN chats c ON m.chat_id=c.id
LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id
WHERE rfc724_mid=? AND from_id=1
ORDER BY msg_id DESC, mdn_contact=? DESC
LIMIT 1",
(&rfc724_mid, from_id),
|row| {
let msg_id: MsgId = row.get("msg_id")?;
+36
View File
@@ -1,11 +1,13 @@
use mailparse::ParsedMail;
use std::mem;
use std::time::Duration;
use super::*;
use crate::{
chat,
chatlist::Chatlist,
constants::{self, Blocked, DC_DESIRED_TEXT_LEN, DC_ELLIPSIS},
key,
message::{MessageState, MessengerMessage},
receive_imf::receive_imf,
test_utils::{TestContext, TestContextManager},
@@ -1420,6 +1422,40 @@ async fn test_extra_imf_headers() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_intended_recipient_fingerprint() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let t_fp = key::load_self_public_key(t).await?.dc_fingerprint();
t.set_config_bool(Config::BccSelf, false).await.unwrap();
let members = [tcm.bob().await, tcm.fiona().await];
let chat_id = chat::create_group(t, "").await?;
chat::send_text_msg(t, chat_id, "hi!".to_string()).await?;
assert!(t.pop_sent_msg_opt(Duration::ZERO).await.is_none());
for (i, member) in members.iter().enumerate() {
let contact = t.add_or_lookup_contact(member).await;
chat::add_contact_to_chat(t, chat_id, contact.id).await?;
let sent_msg = t.pop_sent_msg().await;
let (fp, recipient_fps) = t.parse_msg(&sent_msg).await.signature.unwrap();
assert_eq!(fp, t_fp);
// `mimefactory` encrypts to self unconditionally.
assert_eq!(recipient_fps.len(), 1 + i + 1);
assert!(recipient_fps.contains(&t_fp));
assert!(recipient_fps.contains(&contact.fingerprint().unwrap()));
}
t.set_config_bool(Config::BccSelf, true).await.unwrap();
chat::send_text_msg(t, chat_id, "hi!".to_string()).await?;
let sent_msg = t.pop_sent_msg().await;
let (fp, recipient_fps) = t.parse_msg(&sent_msg).await.signature.unwrap();
assert_eq!(fp, t_fp);
assert_eq!(recipient_fps.len(), 1 + members.len());
assert!(recipient_fps.contains(&t_fp));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_long_in_reply_to() -> Result<()> {
let t = TestContext::new_alice().await;
+84 -20
View File
@@ -1,6 +1,6 @@
//! OpenPGP helper module using [rPGP facilities](https://github.com/rpgp/rpgp).
use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::{BufRead, Cursor};
use anyhow::{Context as _, Result, bail};
@@ -10,14 +10,17 @@ use pgp::armor::BlockType;
use pgp::composed::{
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, KeyType as PgpKeyType,
Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey,
SignedSecretKey, SubkeyParamsBuilder, TheRing,
SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig, TheRing,
};
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
use pgp::crypto::ecc_curve::ECCCurve;
use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
use pgp::types::{CompressionAlgorithm, KeyDetails, Password, PublicKeyTrait, StringToKey};
use pgp::types::{
CompressionAlgorithm, KeyDetails, KeyVersion, Password, PublicKeyTrait, SecretKeyTrait as _,
StringToKey,
};
use rand_old::{Rng as _, thread_rng};
use tokio::runtime::Handle;
@@ -31,9 +34,6 @@ pub(crate) const HEADER_SETUPCODE: &str = "passphrase-begin";
/// Preferred symmetric encryption algorithm.
const SYMMETRIC_KEY_ALGORITHM: SymmetricKeyAlgorithm = SymmetricKeyAlgorithm::AES128;
/// Preferred cryptographic hash.
const HASH_ALGORITHM: HashAlgorithm = HashAlgorithm::Sha256;
/// Split data from PGP Armored Data as defined in <https://tools.ietf.org/html/rfc4880#section-6.2>.
///
/// Returns (type, headers, base64 encoded body).
@@ -191,6 +191,36 @@ pub async fn pk_encrypt(
let pkeys = public_keys_for_encryption
.iter()
.filter_map(select_pk_for_encryption);
let subpkts = {
let mut hashed = Vec::with_capacity(1 + public_keys_for_encryption.len() + 1);
hashed.push(Subpacket::critical(SubpacketData::SignatureCreationTime(
chrono::Utc::now().trunc_subsecs(0),
))?);
// Test "elena" uses old Delta Chat.
let skip = private_key_for_signing.dc_fingerprint().hex()
== "B86586B6DEF437D674BFAFC02A6B2EBC633B9E82";
for key in &public_keys_for_encryption {
if skip {
break;
}
let data = SubpacketData::IntendedRecipientFingerprint(key.fingerprint());
let subpkt = match private_key_for_signing.version() < KeyVersion::V6 {
true => Subpacket::regular(data)?,
false => Subpacket::critical(data)?,
};
hashed.push(subpkt);
}
hashed.push(Subpacket::regular(SubpacketData::IssuerFingerprint(
private_key_for_signing.fingerprint(),
))?);
let mut unhashed = vec![];
if private_key_for_signing.version() <= KeyVersion::V4 {
unhashed.push(Subpacket::regular(SubpacketData::Issuer(
private_key_for_signing.key_id(),
))?);
}
SubpacketConfig::UserDefined { hashed, unhashed }
};
let msg = MessageBuilder::from_bytes("", plain);
let encoded_msg = match seipd_version {
@@ -205,7 +235,13 @@ pub async fn pk_encrypt(
}
}
msg.sign(&*private_key_for_signing, Password::empty(), HASH_ALGORITHM);
let hash_algorithm = private_key_for_signing.hash_alg();
msg.sign_with_subpackets(
&*private_key_for_signing,
Password::empty(),
hash_algorithm,
subpkts,
);
if compress {
msg.compression(CompressionAlgorithm::ZLIB);
}
@@ -228,7 +264,13 @@ pub async fn pk_encrypt(
}
}
msg.sign(&*private_key_for_signing, Password::empty(), HASH_ALGORITHM);
let hash_algorithm = private_key_for_signing.hash_alg();
msg.sign_with_subpackets(
&*private_key_for_signing,
Password::empty(),
hash_algorithm,
subpkts,
);
if compress {
msg.compression(CompressionAlgorithm::ZLIB);
}
@@ -263,9 +305,14 @@ pub fn pk_calc_signature(
chrono::Utc::now().trunc_subsecs(0),
))?,
];
config.unhashed_subpackets = vec![Subpacket::regular(SubpacketData::Issuer(
private_key_for_signing.key_id(),
))?];
config.unhashed_subpackets = vec![];
if private_key_for_signing.version() <= KeyVersion::V4 {
config
.unhashed_subpackets
.push(Subpacket::regular(SubpacketData::Issuer(
private_key_for_signing.key_id(),
))?);
}
let signature = config.sign(
&private_key_for_signing.primary_key,
@@ -369,19 +416,28 @@ fn check_symmetric_encryption(msg: &Message<'_>) -> std::result::Result<(), &'st
/// Returns fingerprints
/// of all keys from the `public_keys_for_validation` keyring that
/// have valid signatures there.
/// have valid signatures in `msg` and corresponding intended recipient fingerprints
/// (<https://www.rfc-editor.org/rfc/rfc9580.html#name-intended-recipient-fingerpr>) if any.
///
/// If the message is wrongly signed, HashSet will be empty.
/// If the message is wrongly signed, returns an empty map.
pub fn valid_signature_fingerprints(
msg: &pgp::composed::Message,
public_keys_for_validation: &[SignedPublicKey],
) -> HashSet<Fingerprint> {
let mut ret_signature_fingerprints: HashSet<Fingerprint> = Default::default();
) -> HashMap<Fingerprint, Vec<Fingerprint>> {
let mut ret_signature_fingerprints = HashMap::new();
if msg.is_signed() {
for pkey in public_keys_for_validation {
if msg.verify(&pkey.primary_key).is_ok() {
if let Ok(signature) = msg.verify(&pkey.primary_key) {
let fp = pkey.dc_fingerprint();
ret_signature_fingerprints.insert(fp);
let mut recipient_fps = Vec::new();
if let Some(cfg) = signature.config() {
for subpkt in &cfg.hashed_subpackets {
if let SubpacketData::IntendedRecipientFingerprint(fp) = &subpkt.data {
recipient_fps.push(fp.clone().into());
}
}
}
ret_signature_fingerprints.insert(fp, recipient_fps);
}
}
}
@@ -453,7 +509,8 @@ pub async fn symm_encrypt_message(
);
msg.encrypt_with_password(&mut rng, s2k, &shared_secret)?;
msg.sign(&*private_key_for_signing, Password::empty(), HASH_ALGORITHM);
let hash_algorithm = private_key_for_signing.hash_alg();
msg.sign(&*private_key_for_signing, Password::empty(), hash_algorithm);
if compress {
msg.compression(CompressionAlgorithm::ZLIB);
}
@@ -495,13 +552,14 @@ mod tests {
use pgp::composed::Esk;
use pgp::packet::PublicKeyEncryptedSessionKey;
#[expect(clippy::type_complexity)]
fn pk_decrypt_and_validate<'a>(
ctext: &'a [u8],
private_keys_for_decryption: &'a [SignedSecretKey],
public_keys_for_validation: &[SignedPublicKey],
) -> Result<(
pgp::composed::Message<'static>,
HashSet<Fingerprint>,
HashMap<Fingerprint, Vec<Fingerprint>>,
Vec<u8>,
)> {
let mut msg = decrypt(ctext.to_vec(), private_keys_for_decryption, &[])?;
@@ -609,7 +667,7 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decrypt_singed() {
async fn test_decrypt_signed() {
// Check decrypting as Alice
let decrypt_keyring = vec![KEYS.alice_secret.clone()];
let sig_check_keyring = vec![KEYS.alice_public.clone()];
@@ -621,6 +679,9 @@ mod tests {
.unwrap();
assert_eq!(content, CLEARTEXT);
assert_eq!(valid_signatures.len(), 1);
for recipient_fps in valid_signatures.values() {
assert_eq!(recipient_fps.len(), 2);
}
// Check decrypting as Bob
let decrypt_keyring = vec![KEYS.bob_secret.clone()];
@@ -633,6 +694,9 @@ mod tests {
.unwrap();
assert_eq!(content, CLEARTEXT);
assert_eq!(valid_signatures.len(), 1);
for recipient_fps in valid_signatures.values() {
assert_eq!(recipient_fps.len(), 2);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+12 -34
View File
@@ -505,16 +505,10 @@ static P_FIVE_CHAT: Provider = Provider {
overview_page: "https://providers.delta.chat/five-chat",
server: &[],
opt: ProviderOptions::new(),
config_defaults: Some(&[
ConfigDefault {
key: Config::BccSelf,
value: "1",
},
ConfigDefault {
key: Config::MvboxMove,
value: "0",
},
]),
config_defaults: Some(&[ConfigDefault {
key: Config::BccSelf,
value: "1",
}]),
oauth2_authorizer: None,
};
@@ -564,7 +558,7 @@ static P_FREENET_DE: Provider = Provider {
static P_GMAIL: Provider = Provider {
id: "gmail",
status: Status::Preparation,
before_login_hint: "For Gmail accounts, you need to have \"2-Step Verification\" enabled and create an app-password.",
before_login_hint: "For Gmail accounts, you need to have \"2-Step Verification\" enabled and create an app-password. Gmail limits how many messages you can send per day.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/gmail",
server: &[
@@ -1080,10 +1074,6 @@ static P_NAUTA_CU: Provider = Provider {
key: Config::DeleteServerAfter,
value: "1",
},
ConfigDefault {
key: Config::MvboxMove,
value: "0",
},
ConfigDefault {
key: Config::MediaQuality,
value: "1",
@@ -1172,10 +1162,7 @@ static P_NINE_TESTRUN_ORG: Provider = Provider {
},
],
opt: ProviderOptions::new(),
config_defaults: Some(&[ConfigDefault {
key: Config::MvboxMove,
value: "0",
}]),
config_defaults: None,
oauth2_authorizer: None,
};
@@ -1616,16 +1603,10 @@ static P_TESTRUN: Provider = Provider {
},
],
opt: ProviderOptions::new(),
config_defaults: Some(&[
ConfigDefault {
key: Config::BccSelf,
value: "1",
},
ConfigDefault {
key: Config::MvboxMove,
value: "0",
},
]),
config_defaults: Some(&[ConfigDefault {
key: Config::BccSelf,
value: "1",
}]),
oauth2_authorizer: None,
};
@@ -1966,10 +1947,7 @@ static P_YGGMAIL: Provider = Provider {
},
],
opt: ProviderOptions::new(),
config_defaults: Some(&[ConfigDefault {
key: Config::MvboxMove,
value: "0",
}]),
config_defaults: None,
oauth2_authorizer: None,
};
@@ -2647,4 +2625,4 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
});
pub static _PROVIDER_UPDATED: LazyLock<chrono::NaiveDate> =
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2025, 9, 10).unwrap());
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2026, 1, 27).unwrap());
+31 -5
View File
@@ -59,14 +59,12 @@ pub enum LoginOptions {
/// scheme: `dclogin://user@host/?p=password&v=1[&options]`
/// read more about the scheme at <https://github.com/deltachat/interface/blob/master/uri-schemes.md#DCLOGIN>
pub(super) fn decode_login(qr: &str) -> Result<Qr> {
let url = url::Url::parse(qr).with_context(|| format!("Malformed url: {qr:?}"))?;
let qr = qr.replacen("://", ":", 1);
let url_without_scheme = qr
let url = url::Url::parse(&qr).with_context(|| format!("Malformed url: {qr:?}"))?;
let payload = qr
.get(DCLOGIN_SCHEME.len()..)
.context("invalid DCLOGIN payload E1")?;
let payload = url_without_scheme
.strip_prefix("//")
.unwrap_or(url_without_scheme);
let addr = payload
.split(['?', '/'])
@@ -365,4 +363,32 @@ mod test {
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_dclogin_ipv4() -> anyhow::Result<()> {
let result = decode_login("dclogin://test@[127.0.0.1]?p=1234&v=1")?;
if let Qr::Login { address, options } = result {
assert_eq!(address, "test@[127.0.0.1]".to_owned());
assert_eq!(options, login_options_just_pw!("1234".to_owned()));
} else {
unreachable!("wrong type");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_dclogin_ipv6() -> anyhow::Result<()> {
let result =
decode_login("dclogin://test@[2001:0db8:85a3:0000:0000:8a2e:0370:7334]?p=1234&v=1")?;
if let Qr::Login { address, options } = result {
assert_eq!(
address,
"test@[2001:0db8:85a3:0000:0000:8a2e:0370:7334]".to_owned()
);
assert_eq!(options, login_options_just_pw!("1234".to_owned()));
} else {
unreachable!("wrong type");
}
Ok(())
}
}
+12 -2
View File
@@ -747,13 +747,23 @@ Content-Disposition: reaction\n\
alice_reaction_msg.id.get_state(&alice).await?,
MessageState::InSeen
);
// Reactions don't request MDNs.
// Reactions don't request MDNs, but an MDN to self is sent.
assert_eq!(
alice
.sql
.count("SELECT COUNT(*) FROM smtp_mdns", ())
.await?,
0
1
);
assert_eq!(
alice
.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE from_id=?",
(ContactId::SELF,)
)
.await?,
1
);
// Alice reacts to own message.
+214 -78
View File
@@ -1,5 +1,6 @@
//! Internet Message Format reception pipeline.
use std::cmp;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::iter;
use std::sync::LazyLock;
@@ -14,7 +15,7 @@ use mailparse::SingleInfo;
use num_traits::FromPrimitive;
use regex::Regex;
use crate::chat::{self, Chat, ChatId, ChatIdBlocked, save_broadcast_secret};
use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ChatVisibility, save_broadcast_secret};
use crate::config::Config;
use crate::constants::{self, Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX, ShowEmails};
use crate::contact::{self, Contact, ContactId, Origin, mark_contact_id_as_verified};
@@ -26,7 +27,9 @@ use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
use crate::imap::{GENERATED_PREFIX, markseen_on_imap_table};
use crate::key::{DcKey, Fingerprint};
use crate::key::{self_fingerprint, self_fingerprint_opt};
use crate::key::{
load_self_public_key, load_self_public_key_opt, self_fingerprint, self_fingerprint_opt,
};
use crate::log::{LogExt as _, warn};
use crate::message::{
self, Message, MessageState, MessengerMessage, MsgId, Viewtype, rfc724_mid_exists,
@@ -195,7 +198,7 @@ async fn insert_tombstone(context: &Context, rfc724_mid: &str) -> Result<MsgId>
async fn get_to_and_past_contact_ids(
context: &Context,
mime_parser: &MimeMessage,
chat_assignment: &ChatAssignment,
chat_assignment: &mut ChatAssignment,
parent_message: &Option<Message>,
incoming_origin: Origin,
) -> Result<(Vec<Option<ContactId>>, Vec<Option<ContactId>>)> {
@@ -281,7 +284,7 @@ async fn get_to_and_past_contact_ids(
.await?;
if let Some(chat_id) = chat_id {
past_ids = lookup_key_contacts_by_address_list(
past_ids = lookup_key_contacts_fallback_to_chat(
context,
&mime_parser.past_members,
past_member_fingerprints,
@@ -314,7 +317,7 @@ async fn get_to_and_past_contact_ids(
Origin::Hidden,
)
.await?;
past_ids = lookup_key_contacts_by_address_list(
past_ids = lookup_key_contacts_fallback_to_chat(
context,
&mime_parser.past_members,
past_member_fingerprints,
@@ -384,54 +387,58 @@ async fn get_to_and_past_contact_ids(
// mapped it to a key contact.
// This is an encrypted 1:1 chat.
to_ids = pgp_to_ids
} else if let Some(chat_id) = chat_id {
to_ids = match mime_parser.was_encrypted() {
true => {
lookup_key_contacts_by_address_list(
context,
&mime_parser.recipients,
to_member_fingerprints,
Some(chat_id),
)
.await?
}
false => {
add_or_lookup_contacts_by_address_list(
context,
&mime_parser.recipients,
if !mime_parser.incoming {
Origin::OutgoingTo
} else if incoming_origin.is_known() {
Origin::IncomingTo
} else {
Origin::IncomingUnknownTo
},
)
.await?
}
}
} else {
let ids = match mime_parser.was_encrypted() {
true => {
lookup_key_contacts_by_address_list(
let ids = if mime_parser.was_encrypted() {
let mut recipient_fps = mime_parser
.signature
.as_ref()
.map(|(_, recipient_fps)| recipient_fps.iter().cloned().collect::<Vec<_>>())
.unwrap_or_default();
// If there are extra recipient fingerprints, it may be a non-chat "implicit
// Bcc" message. Fall back to in-chat lookup if so.
if !recipient_fps.is_empty() && recipient_fps.len() <= 2 {
let self_fp = load_self_public_key(context).await?.dc_fingerprint();
recipient_fps.retain(|fp| *fp != self_fp);
if recipient_fps.is_empty() {
vec![Some(ContactId::SELF)]
} else {
add_or_lookup_key_contacts(
context,
&mime_parser.recipients,
&mime_parser.gossiped_keys,
&recipient_fps,
Origin::Hidden,
)
.await?
}
} else {
lookup_key_contacts_fallback_to_chat(
context,
&mime_parser.recipients,
to_member_fingerprints,
None,
chat_id,
)
.await?
}
false => vec![],
} else {
vec![]
};
if mime_parser.was_encrypted() && !ids.contains(&None)
// Prefer creating PGP chats if there are any key-contacts. At least this prevents
// from replying unencrypted.
// from replying unencrypted. Otherwise downgrade to a non-replyable ad-hoc group.
|| ids
.iter()
.any(|&c| c.is_some() && c != Some(ContactId::SELF))
{
to_ids = ids;
} else {
if mime_parser.was_encrypted() {
warn!(
context,
"No key-contact looked up. Downgrading to AdHocGroup."
);
*chat_assignment = ChatAssignment::AdHocGroup;
}
to_ids = add_or_lookup_contacts_by_address_list(
context,
&mime_parser.recipients,
@@ -487,6 +494,18 @@ pub(crate) async fn receive_imf_inner(
);
}
let trash = || async {
let msg_ids = vec![insert_tombstone(context, rfc724_mid).await?];
Ok(Some(ReceivedMsg {
chat_id: DC_CHAT_ID_TRASH,
state: MessageState::Undefined,
hidden: false,
sort_timestamp: 0,
msg_ids,
needs_delete_job: false,
}))
};
let mut mime_parser = match MimeMessage::from_bytes(context, imf_raw).await {
Err(err) => {
warn!(context, "receive_imf: can't parse MIME: {err:#}.");
@@ -494,17 +513,7 @@ pub(crate) async fn receive_imf_inner(
// We don't have an rfc724_mid, there's no point in adding a trash entry
return Ok(None);
}
let msg_ids = vec![insert_tombstone(context, rfc724_mid).await?];
return Ok(Some(ReceivedMsg {
chat_id: DC_CHAT_ID_TRASH,
state: MessageState::Undefined,
hidden: false,
sort_timestamp: 0,
msg_ids,
needs_delete_job: false,
}));
return trash().await;
}
Ok(mime_parser) => mime_parser,
};
@@ -512,6 +521,18 @@ pub(crate) async fn receive_imf_inner(
let rfc724_mid_orig = &mime_parser
.get_rfc724_mid()
.unwrap_or(rfc724_mid.to_string());
if let Some((_, recipient_fps)) = &mime_parser.signature
&& !recipient_fps.is_empty()
&& let Some(self_pubkey) = load_self_public_key_opt(context).await?
&& !recipient_fps.contains(&self_pubkey.dc_fingerprint())
{
warn!(
context,
"Message {rfc724_mid_orig:?} is not intended for us (TRASH)."
);
return trash().await;
}
info!(
context,
"Receiving message {rfc724_mid_orig:?}, seen={seen}...",
@@ -595,7 +616,7 @@ pub(crate) async fn receive_imf_inner(
// For example, GitHub sends messages from `notifications@github.com`,
// but uses display name of the user whose action generated the notification
// as the display name.
let fingerprint = mime_parser.signature.as_ref();
let fingerprint = mime_parser.signature.as_ref().map(|(fp, _)| fp);
let (from_id, _from_id_blocked, incoming_origin) = match from_field_to_contact_id(
context,
&mime_parser.from,
@@ -633,14 +654,12 @@ pub(crate) async fn receive_imf_inner(
.await?
.filter(|p| Some(p.id) != replace_msg_id);
let chat_assignment =
let mut chat_assignment =
decide_chat_assignment(context, &mime_parser, &parent_message, rfc724_mid, from_id).await?;
info!(context, "Chat assignment is {chat_assignment:?}.");
let (to_ids, past_ids) = get_to_and_past_contact_ids(
context,
&mime_parser,
&chat_assignment,
&mut chat_assignment,
&parent_message,
incoming_origin,
)
@@ -960,6 +979,74 @@ UPDATE config SET value=? WHERE keyname='configured_addr' AND value!=?1
// This is a Delta Chat MDN. Mark as read.
markseen_on_imap_table(context, rfc724_mid_orig).await?;
}
if !mime_parser.incoming && !context.get_config_bool(Config::TeamProfile).await? {
let mut updated_chats = BTreeMap::new();
let mut archived_chats_maybe_noticed = false;
for report in &mime_parser.mdn_reports {
for msg_rfc724_mid in report
.original_message_id
.iter()
.chain(&report.additional_message_ids)
{
let Some(msg_id) = rfc724_mid_exists(context, msg_rfc724_mid).await? else {
continue;
};
let Some(msg) = Message::load_from_db_optional(context, msg_id).await? else {
continue;
};
if msg.state < MessageState::InFresh || msg.state >= MessageState::InSeen {
continue;
}
if !mime_parser.was_encrypted() && msg.get_showpadlock() {
warn!(context, "MDN: Not encrypted. Ignoring.");
continue;
}
message::update_msg_state(context, msg_id, MessageState::InSeen).await?;
if let Err(e) = msg_id.start_ephemeral_timer(context).await {
error!(context, "start_ephemeral_timer for {msg_id}: {e:#}.");
}
if !mime_parser.has_chat_version() {
continue;
}
archived_chats_maybe_noticed |= msg.state < MessageState::InNoticed
&& msg.chat_visibility == ChatVisibility::Archived;
updated_chats
.entry(msg.chat_id)
.and_modify(|ts| *ts = cmp::max(*ts, msg.timestamp_sort))
.or_insert(msg.timestamp_sort);
}
}
for (chat_id, timestamp_sort) in updated_chats {
context
.sql
.execute(
"
UPDATE msgs SET state=? WHERE
state=? AND
hidden=0 AND
chat_id=? AND
timestamp<?",
(
MessageState::InNoticed,
MessageState::InFresh,
chat_id,
timestamp_sort,
),
)
.await
.context("UPDATE msgs.state")?;
if chat_id.get_fresh_msg_cnt(context).await? == 0 {
// Removes all notifications for the chat in UIs.
context.emit_event(EventType::MsgsNoticed(chat_id));
} else {
context.emit_msgs_changed_without_msg_id(chat_id);
}
chatlist_events::emit_chatlist_item_changed(context, chat_id);
}
if archived_chats_maybe_noticed {
context.on_archived_chats_maybe_noticed();
}
}
}
if mime_parser.is_call() {
@@ -1244,17 +1331,34 @@ async fn decide_chat_assignment(
if from_id != ContactId::SELF && !has_self_addr {
num_recipients += 1;
}
let can_be_11_chat = num_recipients <= 1
&& (from_id != ContactId::SELF
|| !(mime_parser.recipients.is_empty() || has_self_addr)
|| mime_parser.was_encrypted());
let mut can_be_11_chat_log = String::new();
let mut l = |cond: bool, s: String| {
can_be_11_chat_log += &s;
cond
};
let can_be_11_chat = l(
num_recipients <= 1,
format!("num_recipients={num_recipients}."),
) && (l(from_id != ContactId::SELF, format!(" from_id={from_id}."))
|| !(l(
mime_parser.recipients.is_empty(),
format!(" Raw recipients len={}.", mime_parser.recipients.len()),
) || l(has_self_addr, format!(" has_self_addr={has_self_addr}.")))
|| l(
mime_parser.was_encrypted(),
format!(" was_encrypted={}.", mime_parser.was_encrypted()),
));
let chat_assignment_log;
let chat_assignment = if should_trash {
chat_assignment_log = "".to_string();
ChatAssignment::Trash
} else if mime_parser.get_mailinglist_header().is_some() {
chat_assignment_log = "Mailing list header found.".to_string();
ChatAssignment::MailingListOrBroadcast
} else if let Some(grpid) = mime_parser.get_chat_group_id() {
if mime_parser.was_encrypted() {
chat_assignment_log = "Encrypted group message.".to_string();
ChatAssignment::GroupChat {
grpid: grpid.to_string(),
}
@@ -1263,11 +1367,13 @@ async fn decide_chat_assignment(
lookup_chat_by_reply(context, mime_parser, parent).await?
{
// Try to assign to a chat based on In-Reply-To/References.
chat_assignment_log = "Unencrypted group reply.".to_string();
ChatAssignment::ExistingChat {
chat_id,
chat_id_blocked,
}
} else {
chat_assignment_log = "Unencrypted group reply.".to_string();
ChatAssignment::AdHocGroup
}
} else {
@@ -1278,6 +1384,7 @@ async fn decide_chat_assignment(
// even if it had only two members.
//
// Group ID is ignored, however.
chat_assignment_log = "Unencrypted group message, no parent.".to_string();
ChatAssignment::AdHocGroup
}
} else if let Some(parent) = &parent_message {
@@ -1285,24 +1392,38 @@ async fn decide_chat_assignment(
lookup_chat_by_reply(context, mime_parser, parent).await?
{
// Try to assign to a chat based on In-Reply-To/References.
chat_assignment_log = "Reply w/o grpid.".to_string();
ChatAssignment::ExistingChat {
chat_id,
chat_id_blocked,
}
} else if mime_parser.get_header(HeaderDef::ChatGroupName).is_some() {
chat_assignment_log = "Reply with Chat-Group-Name.".to_string();
ChatAssignment::AdHocGroup
} else if can_be_11_chat {
chat_assignment_log = format!("Non-group reply. {can_be_11_chat_log}");
ChatAssignment::OneOneChat
} else {
chat_assignment_log = format!("Non-group reply. {can_be_11_chat_log}");
ChatAssignment::AdHocGroup
}
} else if mime_parser.get_header(HeaderDef::ChatGroupName).is_some() {
chat_assignment_log = "Message with Chat-Group-Name, no parent.".to_string();
ChatAssignment::AdHocGroup
} else if can_be_11_chat {
chat_assignment_log = format!("Non-group message, no parent. {can_be_11_chat_log}");
ChatAssignment::OneOneChat
} else {
chat_assignment_log = format!("Non-group message, no parent. {can_be_11_chat_log}");
ChatAssignment::AdHocGroup
};
if !chat_assignment_log.is_empty() {
info!(
context,
"{chat_assignment_log} Chat assignment = {chat_assignment:?}."
);
}
Ok(chat_assignment)
}
@@ -1696,8 +1817,6 @@ async fn add_parts(
}
let is_location_kml = mime_parser.location_kml.is_some();
let is_mdn = !mime_parser.mdn_reports.is_empty();
let mut group_changes = match chat.typ {
_ if chat.id.is_special() => GroupChangesInfo::default(),
Chattype::Single => GroupChangesInfo::default(),
@@ -1733,7 +1852,10 @@ async fn add_parts(
let state = if !mime_parser.incoming {
MessageState::OutDelivered
} else if seen || is_mdn || chat_id_blocked == Blocked::Yes || group_changes.silent
} else if seen
|| !mime_parser.mdn_reports.is_empty()
|| chat_id_blocked == Blocked::Yes
|| group_changes.silent
// No check for `hidden` because only reactions are such and they should be `InFresh`.
{
MessageState::InSeen
@@ -2251,19 +2373,13 @@ RETURNING id
}
}
// Normally outgoing MDNs sent by us never appear in mailboxes, but Gmail saves all
// outgoing messages, including MDNs, to the Sent folder. If we detect such saved MDN,
// delete it.
let needs_delete_job =
!mime_parser.incoming && is_mdn && is_dc_message == MessengerMessage::Yes;
Ok(ReceivedMsg {
chat_id,
state,
hidden,
sort_timestamp,
msg_ids: created_db_entries,
needs_delete_job,
needs_delete_job: false,
})
}
@@ -2602,6 +2718,9 @@ async fn lookup_or_create_adhoc_group(
let to_ids: Vec<ContactId> = to_ids.iter().filter_map(|x| *x).collect();
let mut contact_ids = BTreeSet::<ContactId>::from_iter(to_ids.iter().copied());
contact_ids.insert(from_id);
if mime_parser.was_encrypted() {
contact_ids.remove(&ContactId::SELF);
}
let trans_fn = |t: &mut rusqlite::Transaction| {
t.pragma_update(None, "query_only", "0")?;
t.execute(
@@ -2909,6 +3028,8 @@ async fn apply_group_changes(
to_ids: &[Option<ContactId>],
past_ids: &[Option<ContactId>],
) -> Result<GroupChangesInfo> {
let from_is_key_contact = Contact::get_by_id(context, from_id).await?.is_key_contact();
ensure!(from_is_key_contact || chat.grpid.is_empty());
let to_ids_flat: Vec<ContactId> = to_ids.iter().filter_map(|x| *x).collect();
ensure!(chat.typ == Chattype::Group);
ensure!(!chat.id.is_special());
@@ -2990,7 +3111,10 @@ async fn apply_group_changes(
)
.await?;
if chat.member_list_is_stale(context).await? {
// Avoid insertion of `from_id` into a group with inappropriate encryption state.
if from_is_key_contact != chat.grpid.is_empty()
&& chat.member_list_is_stale(context).await?
{
info!(context, "Member list is stale.");
let mut new_members: HashSet<ContactId> =
HashSet::from_iter(to_ids_flat.iter().copied());
@@ -2998,7 +3122,9 @@ async fn apply_group_changes(
if !from_id.is_special() {
new_members.insert(from_id);
}
if mime_parser.was_encrypted() && chat.grpid.is_empty() {
new_members.remove(&ContactId::SELF);
}
context
.sql
.transaction(|transaction| {
@@ -3048,7 +3174,7 @@ async fn apply_group_changes(
if self_added {
new_members = HashSet::from_iter(to_ids_flat.iter().copied());
new_members.insert(ContactId::SELF);
if !from_id.is_special() {
if !from_id.is_special() && from_is_key_contact != chat.grpid.is_empty() {
new_members.insert(from_id);
}
} else {
@@ -3072,6 +3198,10 @@ async fn apply_group_changes(
new_members.remove(&removed_id);
}
if mime_parser.was_encrypted() && chat.grpid.is_empty() {
new_members.remove(&ContactId::SELF);
}
if new_members != chat_contacts {
chat::update_chat_contacts_table(
context,
@@ -3713,11 +3843,15 @@ async fn create_adhoc_group(
to_ids: &[ContactId],
grpname: &str,
) -> Result<Option<(ChatId, Blocked)>> {
let mut member_ids: Vec<ContactId> = to_ids.to_vec();
if !member_ids.contains(&(from_id)) {
let mut member_ids: Vec<ContactId> = to_ids
.iter()
.copied()
.filter(|&id| id != ContactId::SELF)
.collect();
if from_id != ContactId::SELF && !member_ids.contains(&from_id) {
member_ids.push(from_id);
}
if !member_ids.contains(&(ContactId::SELF)) {
if !mime_parser.was_encrypted() {
member_ids.push(ContactId::SELF);
}
@@ -3806,7 +3940,7 @@ async fn has_verified_encryption(
let signed_with_verified_key = mimeparser
.signature
.as_ref()
.is_some_and(|signature| *signature == fingerprint);
.is_some_and(|(signature, _)| *signature == fingerprint);
if signed_with_verified_key {
Ok(Verified)
} else {
@@ -3934,12 +4068,13 @@ async fn add_or_lookup_key_contacts(
let mut contact_ids = Vec::new();
let mut fingerprint_iter = fingerprints.iter();
for info in address_list {
let fp = fingerprint_iter.next();
let addr = &info.addr;
if !may_be_valid_addr(addr) {
contact_ids.push(None);
continue;
}
let fingerprint: String = if let Some(fp) = fingerprint_iter.next() {
let fingerprint: String = if let Some(fp) = fp {
// Iterator has not ran out of fingerprints yet.
fp.hex()
} else if let Some(key) = gossiped_keys.get(addr) {
@@ -4090,7 +4225,7 @@ async fn lookup_key_contact_by_fingerprint(
}
}
/// Looks up key-contacts by email addresses.
/// Adds or looks up key-contacts by fingerprints or by email addresses in the given chat.
///
/// `fingerprints` may be empty.
/// This is used as a fallback when email addresses are available,
@@ -4105,7 +4240,7 @@ async fn lookup_key_contact_by_fingerprint(
/// is the same as the number of addresses in the header
/// and it is possible to find corresponding
/// `Chat-Group-Member-Timestamps` items.
async fn lookup_key_contacts_by_address_list(
async fn lookup_key_contacts_fallback_to_chat(
context: &Context,
address_list: &[SingleInfo],
fingerprints: &[Fingerprint],
@@ -4114,13 +4249,14 @@ async fn lookup_key_contacts_by_address_list(
let mut contact_ids = Vec::new();
let mut fingerprint_iter = fingerprints.iter();
for info in address_list {
let fp = fingerprint_iter.next();
let addr = &info.addr;
if !may_be_valid_addr(addr) {
contact_ids.push(None);
continue;
}
if let Some(fp) = fingerprint_iter.next() {
if let Some(fp) = fp {
// Iterator has not ran out of fingerprints yet.
let display_name = info.display_name.as_deref();
let fingerprint: String = fp.hex();
+102 -8
View File
@@ -4,8 +4,9 @@ use tokio::fs;
use super::*;
use crate::chat::{
ChatItem, ChatVisibility, add_contact_to_chat, add_to_chat_contacts_table, create_group,
get_chat_contacts, get_chat_msgs, is_contact_in_chat, remove_contact_from_chat, send_text_msg,
CantSendReason, ChatItem, ChatVisibility, add_contact_to_chat, add_to_chat_contacts_table,
create_group, get_chat_contacts, get_chat_msgs, is_contact_in_chat, remove_contact_from_chat,
send_text_msg,
};
use crate::chatlist::Chatlist;
use crate::constants::DC_GCL_FOR_FORWARDING;
@@ -3283,7 +3284,8 @@ async fn test_blocked_contact_creates_group() -> Result<()> {
let sent = bob.send_text(group_id, "Heyho, I'm a spammer!").await;
let rcvd = alice.recv_msg(&sent).await;
// Alice blocked Bob, so she shouldn't get the message
// Alice blocked Bob, so she shouldn't be notified.
assert_eq!(rcvd.state, MessageState::InSeen);
assert_eq!(rcvd.chat_blocked, Blocked::Yes);
// Fiona didn't block Bob, though, so she gets the message
@@ -5106,6 +5108,100 @@ async fn test_dont_verify_by_verified_by_unknown() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recv_outgoing_msg_before_securejoin() -> Result<()> {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
let a0 = &tcm.elena().await;
let a1 = &tcm.elena().await;
tcm.execute_securejoin(bob, a0).await;
let chat_id_a0_bob = a0.create_chat_id(bob).await;
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi").await;
bob.recv_msg(&sent_msg).await;
let msg_a1 = a1.recv_msg(&sent_msg).await;
assert!(msg_a1.get_showpadlock());
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
assert_eq!(chat_a1.typ, Chattype::Group);
assert!(!chat_a1.is_encrypted(a1).await?);
assert_eq!(
chat::get_chat_contacts(a1, chat_a1.id).await?,
[a1.add_or_lookup_address_contact_id(bob).await]
);
assert_eq!(
chat_a1.why_cant_send(a1).await?,
Some(CantSendReason::NotAMember)
);
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi again").await;
bob.recv_msg(&sent_msg).await;
let msg_a1 = a1.recv_msg(&sent_msg).await;
assert!(msg_a1.get_showpadlock());
assert_eq!(msg_a1.chat_id, chat_a1.id);
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
assert_eq!(
chat_a1.why_cant_send(a1).await?,
Some(CantSendReason::NotAMember)
);
let msg_a1 = tcm.send_recv(bob, a1, "Hi back").await;
assert!(msg_a1.get_showpadlock());
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
assert_eq!(chat_a1.typ, Chattype::Single);
assert!(chat_a1.is_encrypted(a1).await?);
// Weird, but fine, anyway the bigger problem is the conversation split into two chats.
assert_eq!(
chat_a1.why_cant_send(a1).await?,
Some(CantSendReason::ContactRequest)
);
let a0 = &tcm.alice().await;
let a1 = &tcm.alice().await;
tcm.execute_securejoin(bob, a0).await;
let chat_id_a0_bob = a0.create_chat_id(bob).await;
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi").await;
bob.recv_msg(&sent_msg).await;
let msg_a1 = a1.recv_msg(&sent_msg).await;
assert!(msg_a1.get_showpadlock());
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
assert_eq!(chat_a1.typ, Chattype::Single);
assert!(chat_a1.is_encrypted(a1).await?);
assert_eq!(
chat::get_chat_contacts(a1, chat_a1.id).await?,
[a1.add_or_lookup_contact_id(bob).await]
);
assert!(chat_a1.can_send(a1).await?);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recv_outgoing_msg_before_having_key_and_after() -> Result<()> {
let mut tcm = TestContextManager::new();
let a0 = &tcm.elena().await;
let a1 = &tcm.elena().await;
let bob = &tcm.bob().await;
tcm.execute_securejoin(bob, a0).await;
let chat_id_a0_bob = a0.create_chat_id(bob).await;
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi").await;
let msg_a1 = a1.recv_msg(&sent_msg).await;
assert!(msg_a1.get_showpadlock());
let chat_a1 = Chat::load_from_db(a1, msg_a1.chat_id).await?;
assert_eq!(chat_a1.typ, Chattype::Group);
assert!(!chat_a1.is_encrypted(a1).await?);
// Device a1 somehow learns Bob's key and creates the corresponding chat. However, this doesn't
// help because we only look up key contacts by address in a particular chat and the new chat
// isn't referenced by the received message. This is fixed by sending and receiving Intended
// Recipient Fingerprint subpackets which elena doesn't send.
a1.create_chat_id(bob).await;
let sent_msg = a0.send_text(chat_id_a0_bob, "Hi again").await;
let msg_a1 = a1.recv_msg(&sent_msg).await;
assert!(msg_a1.get_showpadlock());
assert_eq!(msg_a1.chat_id, chat_a1.id);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sanitize_filename_in_received() -> Result<()> {
let alice = &TestContext::new_alice().await;
@@ -5365,16 +5461,14 @@ async fn test_encrypted_adhoc_group_message() -> Result<()> {
assert_eq!(chat.is_encrypted(bob).await?, false);
let contact_ids = get_chat_contacts(bob, chat.id).await?;
assert_eq!(contact_ids.len(), 3);
assert!(chat.is_self_in_chat(bob).await?);
assert_eq!(contact_ids.len(), 2);
assert!(!chat.is_self_in_chat(bob).await?);
// Since the group is unencrypted, all contacts have
// to be address-contacts.
for contact_id in contact_ids {
let contact = Contact::get_by_id(bob, contact_id).await?;
if contact_id != ContactId::SELF {
assert_eq!(contact.is_key_contact(), false);
}
assert_eq!(contact.is_key_contact(), false);
}
// `from_id` of the message corresponds to key-contact of Alice
+1 -24
View File
@@ -11,7 +11,7 @@ use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
pub(crate) use self::connectivity::ConnectivityStore;
use crate::config::{self, Config};
use crate::config::Config;
use crate::contact::{ContactId, RecentlySeenLoop};
use crate::context::Context;
use crate::download::{download_known_post_messages_without_pre_message, download_msgs};
@@ -477,29 +477,6 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
};
maybe_send_stats(ctx).await.log_err(ctx).ok();
match ctx.get_config_bool(Config::FetchedExistingMsgs).await {
Ok(fetched_existing_msgs) => {
if !fetched_existing_msgs {
// Consider it done even if we fail.
//
// This operation is not critical enough to retry,
// especially if the error is persistent.
if let Err(err) = ctx
.set_config_internal(Config::FetchedExistingMsgs, config::from_bool(true))
.await
{
warn!(ctx, "Can't set Config::FetchedExistingMsgs: {:#}", err);
}
if let Err(err) = imap.fetch_existing_msgs(ctx, &mut session).await {
warn!(ctx, "Failed to fetch existing messages: {:#}", err);
}
}
}
Err(err) => {
warn!(ctx, "Can't get Config::FetchedExistingMsgs: {:#}", err);
}
}
session
.update_metadata(ctx)
+1 -1
View File
@@ -784,7 +784,7 @@ fn encrypted_and_signed(
mimeparser: &MimeMessage,
expected_fingerprint: &Fingerprint,
) -> bool {
if let Some(signature) = mimeparser.signature.as_ref() {
if let Some((signature, _)) = mimeparser.signature.as_ref() {
if signature == expected_fingerprint {
true
} else {
+59 -10
View File
@@ -13,7 +13,7 @@ use crate::config::Config;
use crate::contact::{Contact, ContactId};
use crate::context::Context;
use crate::events::EventType;
use crate::log::warn;
use crate::log::{LogExt, warn};
use crate::message::Message;
use crate::message::{self, MsgId};
use crate::mimefactory::MimeFactory;
@@ -184,6 +184,9 @@ pub(crate) async fn smtp_send(
smtp: &mut Smtp,
msg_id: Option<MsgId>,
) -> SendResult {
if recipients.is_empty() {
return SendResult::Success;
}
if std::env::var(crate::DCC_MIME_DEBUG).is_ok() {
info!(context, "SMTP-sending out mime message:\n{message}");
}
@@ -374,15 +377,15 @@ pub(crate) async fn send_msg_to_smtp(
return Ok(());
};
if retries > 6 {
if let Some(mut msg) = Message::load_from_db_optional(context, msg_id).await? {
message::set_msg_failed(context, &mut msg, "Number of retries exceeded the limit.")
.await?;
}
context
.sql
.execute("DELETE FROM smtp WHERE id=?", (rowid,))
.await
.context("Failed to remove message with exceeded retry limit from smtp table")?;
if let Some(mut msg) = Message::load_from_db_optional(context, msg_id).await? {
message::set_msg_failed(context, &mut msg, "Number of retries exceeded the limit.")
.await?;
}
return Ok(());
}
info!(
@@ -570,17 +573,32 @@ async fn send_mdn_rfc724_mid(
additional_rfc724_mids.clone(),
)
.await?;
let encrypted = mimefactory.will_be_encrypted();
let rendered_msg = mimefactory.render(context).await?;
let body = rendered_msg.message;
let addr = contact.get_addr();
let recipient = async_smtp::EmailAddress::new(addr.to_string())
.map_err(|err| format_err!("invalid recipient: {addr} {err:?}"))?;
let recipients = vec![recipient];
let mut recipients = Vec::new();
if contact_id != ContactId::SELF {
recipients.push(contact.get_addr().to_string());
}
if context.get_config_bool(Config::BccSelf).await? {
add_self_recipients(context, &mut recipients, encrypted).await?;
}
let recipients: Vec<_> = recipients
.into_iter()
.filter_map(|addr| {
async_smtp::EmailAddress::new(addr.clone())
.with_context(|| format!("Invalid recipient: {addr}"))
.log_err(context)
.ok()
})
.collect();
match smtp_send(context, &recipients, &body, smtp, None).await {
SendResult::Success => {
info!(context, "Successfully sent MDN for {rfc724_mid}.");
if !recipients.is_empty() {
info!(context, "Successfully sent MDN for {rfc724_mid}.");
}
context
.sql
.transaction(|transaction| {
@@ -667,3 +685,34 @@ async fn send_mdn(context: &Context, smtp: &mut Smtp) -> Result<bool> {
}
}
}
/// Adds self-addresses to `recipients` as necessary.
/// This doesn't check `Config::BccSelf`, it should be checked by the caller if needed.
pub(crate) async fn add_self_recipients(
context: &Context,
recipients: &mut Vec<String>,
encrypted: bool,
) -> Result<()> {
// Previous versions of Delta Chat did not send BCC self
// if DeleteServerAfter was set to immediately delete messages
// from the server. This is not the case anymore
// because BCC-self messages are also used to detect
// that message was sent if SMTP server is slow to respond
// and connection is frequently lost
// before receiving status line. NB: This is not a problem for chatmail servers, so `BccSelf`
// disabled by default is fine.
if context.get_config_delete_server_after().await? != Some(0) || !recipients.is_empty() {
// Avoid sending unencrypted messages to all transports, chatmail relays won't accept
// them. Normally the user should have a non-chatmail primary transport to send unencrypted
// messages.
if encrypted {
for addr in context.get_secondary_self_addrs().await? {
recipients.push(addr);
}
}
// `from` must be the last addr, see `receive_imf_inner()` why.
let from = context.get_primary_self_addr().await?;
recipients.push(from);
}
Ok(())
}
-7
View File
@@ -96,9 +96,6 @@ async fn test_key_contacts_migration_email1() -> Result<()> {
assert_eq!(email_bob.fingerprint(), None);
assert_eq!(email_bob.get_verifier_id(&t).await?, None);
let bob_chat_contacts = chat::get_chat_contacts(&t, ChatId::new(10)).await?;
dbg!(&bob_chat_contacts);
Ok(())
}
@@ -137,9 +134,6 @@ async fn test_key_contacts_migration_email2() -> Result<()> {
assert_eq!(email_bob.fingerprint(), None);
assert_eq!(email_bob.get_verifier_id(&t).await?, None);
let bob_chat_contacts = chat::get_chat_contacts(&t, ChatId::new(10)).await?;
dbg!(&bob_chat_contacts);
Ok(())
}
@@ -176,7 +170,6 @@ async fn test_key_contacts_migration_verified() -> Result<()> {
let pgp_bob_id = bob_chat_contacts[0];
let pgp_bob = Contact::get_by_id(&t, pgp_bob_id).await?;
dbg!(&pgp_bob);
assert_eq!(pgp_bob.origin, Origin::OutgoingTo);
assert_eq!(pgp_bob.e2ee_avail(&t).await?, true);
assert_eq!(
+5 -2
View File
@@ -9,6 +9,7 @@ use anyhow::{Context as _, Result};
use deltachat_derive::FromSql;
use num_traits::ToPrimitive;
use pgp::types::PublicKeyTrait;
use rand::distr::SampleString as _;
use rusqlite::OptionalExtension;
use serde::Serialize;
@@ -21,7 +22,7 @@ use crate::key::load_self_public_keyring;
use crate::log::LogExt;
use crate::message::{Message, Viewtype};
use crate::securejoin::QrInvite;
use crate::tools::{create_id, time};
use crate::tools::time;
pub(crate) const STATISTICS_BOT_EMAIL: &str = "self_reporting@testrun.org";
const STATISTICS_BOT_VCARD: &str = include_str!("../assets/statistics-bot.vcf");
@@ -390,7 +391,9 @@ pub(crate) async fn stats_id(context: &Context) -> Result<String> {
Ok(match context.get_config(Config::StatsId).await? {
Some(id) => id,
None => {
let id = create_id();
let id = rand::distr::Alphabetic
.sample_string(&mut rand::rng(), 25)
.to_lowercase();
context
.set_config_internal(Config::StatsId, Some(&id))
.await?;
+1 -9
View File
@@ -63,10 +63,7 @@ pub enum StockMessage {
#[strum(props(fallback = "GIF"))]
Gif = 23,
#[strum(props(fallback = "End-to-end encryption available"))]
E2eAvailable = 25,
#[strum(props(fallback = "No encryption"))]
#[strum(props(fallback = "No encryption."))]
EncrNone = 28,
#[strum(props(fallback = "Fingerprints"))]
@@ -707,11 +704,6 @@ pub(crate) async fn gif(context: &Context) -> String {
translated(context, StockMessage::Gif).await
}
/// Stock string: `End-to-end encryption available.`.
pub(crate) async fn e2e_available(context: &Context) -> String {
translated(context, StockMessage::E2eAvailable).await
}
/// Stock string: `No encryption.`.
pub(crate) async fn encr_none(context: &Context) -> String {
translated(context, StockMessage::EncrNone).await
+37 -16
View File
@@ -4,6 +4,9 @@ use std::borrow::Cow;
use std::fmt;
use std::str;
use anyhow::Result;
use num_traits::FromPrimitive;
use crate::calls::{CallState, call_state};
use crate::chat::Chat;
use crate::constants::Chattype;
@@ -15,7 +18,6 @@ use crate::param::Param;
use crate::stock_str;
use crate::stock_str::msg_reacted;
use crate::tools::truncate;
use anyhow::Result;
/// Prefix displayed before message and separated by ":" in the chatlist.
#[derive(Debug)]
@@ -167,7 +169,15 @@ impl Message {
/// Returns a summary text without "Forwarded:" prefix.
async fn get_summary_text_without_prefix(&self, context: &Context) -> String {
let (emoji, type_name, type_file, append_text);
match self.viewtype {
let viewtype = match self
.param
.get_i64(Param::PostMessageViewtype)
.and_then(Viewtype::from_i64)
{
Some(vt) => vt,
None => self.viewtype,
};
match viewtype {
Viewtype::Image => {
emoji = Some("📷");
type_name = Some(stock_str::image(context).await);
@@ -211,20 +221,29 @@ impl Message {
append_text = true
}
Viewtype::Webxdc => {
emoji = None;
type_name = None;
type_file = Some(
self.get_webxdc_info(context)
.await
.map(|info| info.name)
.unwrap_or_else(|_| "ErrWebxdcName".to_string()),
);
if self.viewtype == Viewtype::Webxdc {
emoji = None;
type_file = Some(
self.get_webxdc_info(context)
.await
.map(|info| info.name)
.unwrap_or_else(|_| "ErrWebxdcName".to_string()),
);
} else {
emoji = Some("📱");
type_file = Some(viewtype.to_locale_string(context).await);
}
append_text = true;
}
Viewtype::Vcard => {
emoji = Some("👤");
type_name = None;
type_file = self.param.get(Param::Summary1).map(|s| s.to_string());
if self.viewtype == Viewtype::Vcard {
type_file = self.param.get(Param::Summary1).map(|s| s.to_string());
} else {
type_file = None;
}
append_text = true;
}
Viewtype::Call => {
@@ -261,7 +280,7 @@ impl Message {
}
};
let text = self.text.clone() + &self.additional_text;
let text = self.text.clone();
let summary = if let Some(type_file) = type_file {
if append_text && !text.is_empty() {
@@ -293,6 +312,13 @@ impl Message {
}
}
#[cfg(test)]
/// Asserts that the summary text with and w/o prefix is `expected`.
pub async fn assert_summary_texts(msg: &Message, ctx: &Context, expected: &str) {
assert_eq!(msg.get_summary_text(ctx).await, expected);
assert_eq!(msg.get_summary_text_without_prefix(ctx).await, expected);
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
@@ -302,11 +328,6 @@ mod tests {
use crate::param::Param;
use crate::test_utils::TestContext;
async fn assert_summary_texts(msg: &Message, ctx: &Context, expected: &str) {
assert_eq!(msg.get_summary_text(ctx).await, expected);
assert_eq!(msg.get_summary_text_without_prefix(ctx).await, expected);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_summary_text() {
let d = TestContext::new_alice().await;
+12 -1
View File
@@ -44,7 +44,7 @@ use crate::stock_str::StockStrings;
use crate::tools::time;
/// The number of info messages added to new e2ee chats.
/// Currently this is "End-to-end encryption available", string `E2eAvailable`.
/// Currently this is "Messages are end-to-end encrypted.", string `ChatProtectionEnabled`.
pub const E2EE_INFO_MSGS: usize = 1;
#[allow(non_upper_case_globals)]
@@ -117,6 +117,8 @@ impl TestContextManager {
.await
}
/// Returns new elena's "device".
/// Elena doesn't send Intended Recipient Fingerprint subpackets to simulate old Delta Chat.
pub async fn elena(&mut self) -> TestContext {
TestContext::builder()
.configure_elena()
@@ -1492,6 +1494,15 @@ impl EventTracker {
pub fn clear_events(&self) {
while let Ok(_ev) = self.try_recv() {}
}
/// Takes all items from event queue and returns them.
pub fn take_events(&self) -> Vec<Event> {
let mut events = Vec::new();
while let Ok(event) = self.try_recv() {
events.push(event);
}
events
}
}
/// Gets a specific message from a chat and asserts that the chat has a specific length.
+34
View File
@@ -10,6 +10,7 @@ use crate::message::{Message, MessageState, Viewtype, delete_msgs, markseen_msgs
use crate::mimeparser::MimeMessage;
use crate::param::Param;
use crate::reaction::{get_msg_reactions, send_reaction};
use crate::summary::assert_summary_texts;
use crate::test_utils::TestContextManager;
use crate::tests::pre_messages::util::{
send_large_file_message, send_large_image_message, send_large_webxdc_message,
@@ -76,6 +77,39 @@ async fn test_receive_pre_message() -> Result<()> {
assert_eq!(msg.get_filebytes(bob).await?, Some(1_000_000));
assert_eq!(msg.get_post_message_viewtype(), Some(Viewtype::File));
assert_eq!(msg.get_filename(), Some("test.bin".to_owned()));
assert_summary_texts(&msg, bob, "📎 test.bin – test").await;
// Some viewtypes are displayed unwell currently, still test them.
let (pre_message, ..) = send_large_webxdc_message(alice, alice_group_id).await?;
let msg = bob.recv_msg(&pre_message).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert_summary_texts(
&msg,
bob,
&format!("📱 {} – test", Viewtype::Webxdc.to_locale_string(bob).await),
)
.await;
let (pre_message, ..) = send_large_file_message(
alice,
alice_group_id,
Viewtype::Vcard,
format!(
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
EMAIL:alice@example.org\r\n\
FN:Alice\r\n\
NOTE:{}\r\n\
END:VCARD\r\n",
String::from_utf8(vec![97u8; 1_000_000])?,
)
.as_bytes(),
)
.await?;
let msg = bob.recv_msg(&pre_message).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert_summary_texts(&msg, bob, "👤 test").await;
Ok(())
}
+4 -4
View File
@@ -17,10 +17,10 @@ pub async fn send_large_file_message<'a>(
content: &[u8],
) -> Result<(SentMessage<'a>, SentMessage<'a>, MsgId)> {
let mut msg = Message::new(view_type);
let file_name = if view_type == Viewtype::Webxdc {
"test.xdc"
} else {
"test.bin"
let file_name = match view_type {
Viewtype::Webxdc => "test.xdc",
Viewtype::Vcard => "test.vcf",
_ => "test.bin",
};
msg.set_file_from_bytes(sender, file_name, content, None)?;
msg.set_text("test".to_owned());
+8
View File
@@ -9,6 +9,7 @@
//! and configured list of connection candidates.
use std::fmt;
use std::pin::Pin;
use anyhow::{Context as _, Result, bail, format_err};
use deltachat_contact_tools::{EmailAddress, addr_normalize};
@@ -760,11 +761,18 @@ pub(crate) async fn sync_transports(
.await?;
if modified {
tokio::task::spawn(restart_io_if_running_boxed(context.clone()));
context.emit_event(EventType::TransportsModified);
}
Ok(())
}
/// Same as `context.restart_io_if_running()`, but `Box::pin`ed and with a `+ Send` bound,
/// so that it can be called recursively.
fn restart_io_if_running_boxed(context: Context) -> Pin<Box<dyn Future<Output = ()> + Send>> {
Box::pin(async move { context.restart_io_if_running().await })
}
/// Adds transport entry to the `transports` table with empty configuration.
pub(crate) async fn add_pseudo_transport(context: &Context, addr: &str) -> Result<()> {
context.sql
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB