Compare commits

..

150 Commits

Author SHA1 Message Date
holger krekel
e88c7f88ee feat: report ready/init_error on startup via JSON-RPC notification
Why:
When the deltachat-rpc-server encounters a fatal error during early startup
(e.g., when the accounts directory is invalid, a file instead of a dir, or
otherwise inaccessible), it exits. The Python RPC client previously lacked
a structured way to wait for the server to be fully initialized or to
detect early startup failures gracefully. This led to hanging tests or
obscure broken pipe errors rather than clear initialization errors.

How:
- The RPC server now sends a JSON-RPC notification on stdout at startup:
  - "ready" with core_version, server_path, and accounts_dir on success
  - "init_error" with error message if accounts directory initialization fails
- The Python RPC client reads the first line from stdout to ensure the server is ready.
- The Python client raises JsonRpcError on init_error, enabling early
  failure detection and fast-failing rather than stalling.
- Added tests to ensure the client fails immediately on invalid dirs.
2026-02-24 17:30:07 +01:00
iequidoo
ba64d8d19b feat: Send webxdc name instead of raw file name in pre-messages. Display it in summary (#7790)
The webxdc file name itself isn't informative for users. Still, send and display it if the webxdc
manifest can't be parsed, it's better than sending "Mini App" and this isn't a normal case anyway.
2026-02-23 15:20:53 -03:00
iequidoo
4041d9a54e feat: Add 📱 to all webxdc summaries (#7790)
This can be done now as Desktop doesn't prepend icons from webxdc archives to summaries anymore.
2026-02-23 15:20:53 -03:00
link2xt
bbf9a86bce perf: batched event reception 2026-02-23 15:58:06 +00:00
Hocuri
cdb0e0ce29 fix: Make clicking on broadcast member-added messages work always (#7882)
fix #7876
2026-02-23 15:44:52 +01:00
link2xt
0e7f3c8238 test: fail fast when CHATMAIL_DOMAIN is unset
This code does not expect the variable to be unset,
so use indexing to fail with KeyError instead.
Otherwise getenv() returns None which is then converted to "none" string by formatting
and the test only fails because of connection attempts to "none" domain.
2026-02-23 14:44:16 +00:00
link2xt
16c85a9585 chore(cargo): update async-native-tls from 0.5.0 to 0.6.0 2026-02-23 14:44:16 +00:00
Hocuri
ff7023580f fix: If there was no chat description, and it's set to be an empty string, don't send out a "chat description changed" message (#7879)
fix #7877

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

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

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

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

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

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

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

Implementation notes:

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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


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

---------

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

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

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

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

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

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

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

One potential problem with sending MDNs is that it may trigger ratelimits on some providers and
count as another recipient.
2026-01-17 20:54:35 -03:00
link2xt
659d21aa9d docs: fix formatting of indoc! link 2026-01-17 14:40:17 +00:00
link2xt
8f604e74ec fix: do not resolve ICE server hostnames during IMAP loop
Hostname resolution may timeout if DNS servers are not responding.
It is also not necessary to resolve fallback ICE server hostnames
if the user is not going to use calls.
2026-01-17 12:55:21 +00:00
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
missytake
47b49fd02e api(jsonrpc): add run_until parameter for bots (#7688)
This commit also makes testing hooks easier, as it allows to process
events and run hooks on them, until a certain event occurs.

---------

Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
2026-01-14 19:58:44 +01:00
iequidoo
f50e3d6ffa feat: Don't scale up Origin of multiple and broadcast recipients when sending a message
84161f4202 promotes group members to `Origin::IncomingTo` when
accepting it, instead of `CreateChat` as before, but this changes almost nothing because it happens
rarely that the user only accepts a group and writes nothing there soon. Now if a message has
multiple recipients, i.e. it's a 3-or-more-member group, or if it's a broadcast message, we don't
scale up its recipients to `Origin::OutgoingTo`.
2026-01-14 14:32:59 -03:00
Simon Laux
2ecb537307 api!: jsonrpc: remove contacts from FullChat. To migrate load contacts on demand via get_contacts_by_ids using FullChat.contactIds (#7282)
closes #6945

### Why not deprecate it instead?
Because empty contact list is a more annoying-to-find bug in your app
than failing to build or getting undefined at runtime.

Also you would not see the deprecated hints anyway because for that you
need autogenerated types and those only exist for typescript currently.
2026-01-14 15:35:57 +00:00
Hocuri
ccae73f6db feat: Don't put text into post-message (#7714)
Before this PR, when a user with current main sends a large message to a
user with an old Delta Chat (before #7431), the text will be duplicated:
One message will arrive with only the text, and one message with
attachment+text.

This PR changes this - there will be one message with only the text, and
one message with only the attachment.

If we want to guard against lost pre-messages, then we can revert this
PR in a few months, though I'm not sure that's necessary - it's unlikely
that the small pre-message gets lost but the big post-message gets
through.
2026-01-14 11:10:17 +01:00
iequidoo
fce91f3ee0 feat: Disable partial search by contact address
Implement suggestion from #7477 so that there's no incremental search by contact address in UIs,
only direct matches are returned.
2026-01-13 22:13:54 -03:00
Hocuri
d446a16fc6 Sync broadcast subscribers list (#7578)
fix #7497
2026-01-13 15:04:51 +01:00
iequidoo
c3a6e48882 docs: set_config_from_qr() configures context for "DCACCOUNT:" and "DCLOGIN:" QRs (#7450)
Also remove "you can now call 'configure'" from the REPL output, probably users of the REPL tool can
read the code documentation to know when 'configure' should be run.
2026-01-13 01:26:35 -03:00
Simon Laux
46ec3a469b fix: logging errors in deltachat-rpc-server during startup (#7707)
Before this PR there were cases where error messages never reach the
user because logging was not initialized yet.
This PR moves log initialization to the start of the program, so that
all logged messages reach the user.
2026-01-12 00:18:23 +00:00
iequidoo
fe3b1ea16d fix: Only emit TransportsModified if transports are really modified
Otherwise it's not possible to write tests reliably because sync messages may be executed multiple
times if they arrive from different transports. This should fix flaky
`test_transport_synchronization`.

Also always emit `TransportsModified` if the primary transport is changed by a sync message, even if
it doesn't contain `SyncData::Transports`.

Also don't decrease `add_timestamp` in `save_transport()` if nothing else changes, this doesn't make
sense.
2026-01-11 16:52:39 -03:00
iequidoo
ed300b6f97 feat: Execute sync message before checking for primary transport update
This way, if the sync message updates transports, the check for a new primary transport is done
against the updated transport list which is more reliable.
2026-01-11 16:52:39 -03:00
iequidoo
e456be4e21 fix: Send bcc-self messages to all own relays (#7656)
This fixes the bug when a new transport doesn't become primary on the 2nd device because INBOX from
the new transport isn't fully fetched. Now the `Transports` sync message is received from the old
transport, but as it has updated "From", it updates the primary transport correspondingly. NB: I/O
for the new primary transport isn't immediately started however, this needs a separate fix.
2026-01-11 16:52:39 -03:00
iequidoo
ba4055b7df test(rpc-client): Replace remaining print()s with logging (#6082)
This is to fix tests failing with `OSError: [Errno 9] Bad file descriptor`. Maybe stdout closes
earlier than stderr, before the test finishes, not sure. For reference, the previous commit removing
print()s is 800edc6fce.
2026-01-11 14:52:13 -03:00
B. Petersen
c06f53cb86 docs: fix chat types
- DC_CHAT_TYPE_BROADCAST does no longer exist
- DC_CHAT_TYPE_MAILINGLIST is no longer always read-only
- avoid double docs by moving everything to DC_CHAT_TYPE group
  (here, things were also updated recently)
2026-01-11 01:48:55 +01:00
link2xt
13dafa46b5 fix: take transport_id into account when marking messages with \Seen flags
Otherwise wrong IMAP client corresponding to a different transport
may pick up the job to mark the message as seen there,
and fail to do it as the message does not exist.
It may also mark the wrong message with the correct folder
and UID, but wrong IMAP server.
2026-01-09 18:40:46 +00:00
iequidoo
d552250dc4 test: Port test_dont_move_sync_msgs to JSON-RPC (#7676) 2026-01-09 15:26:00 -03:00
Simon Laux
1383e790c3 feat: connectivity view: move quota up and combine with IMAP state. (#7653)
like described in
https://github.com/chatmail/core/pull/7630#discussion_r2641514867


## classical account (with legacy option mvbox)

|||
|---|---|
| <img width="891" height="635" alt="image"
src="https://github.com/user-attachments/assets/723f3dba-79dc-4b57-a14f-c5879c1a1d1d"
/>| <img width="890" height="578" alt="image"
src="https://github.com/user-attachments/assets/d45eaf35-d7b2-40d4-8c37-bbc77947c27d"
/>|

## multi transport

|||
|---|---|
|<img width="891" height="1236" alt="image"
src="https://github.com/user-attachments/assets/053cb088-7d9d-4591-b2bc-6b49399d33a0"
/> |<img width="885" height="1230" alt="image"
src="https://github.com/user-attachments/assets/c455b4f1-f521-4ae8-8884-9042af62ca46"
/>|
2026-01-09 17:20:59 +00:00
Simon Laux
b536902827 fix: do not show contact address in message info (#7695)
closes #7686
2026-01-09 17:04:22 +00:00
Simon Laux
2631745a57 feat: pre-messages / next version of download on demand (#7371)
Closes <https://github.com/chatmail/core/issues/7367>

Co-authored-by: iequidoo <dgreshilov@gmail.com>
Co-authored-by: Hocuri <hocuri@gmx.de>
2026-01-08 22:14:32 +00:00
link2xt
46bbe5f077 chore(release): prepare for 2.37.0 2026-01-08 20:45:33 +00:00
iequidoo
0f14edd5d9 feat: Don't download group messages unconditionally
There was a comment that group messages should always be downloaded to avoid inconsistent group
state, but this is solved by the group consistency algo nowadays in the sense that inconsistent
group state won't spread to other members if we send to the group.
2026-01-08 16:57:39 -03:00
Simon Laux
fe6e942191 api: cffi api to create account manager with existing events channel to see events emitted during startup. dc_event_channel_new, dc_event_channel_unref, dc_event_channel_get_event_emitter and dc_accounts_new_with_event_channel (#7609)
closes #7606
2026-01-08 18:08:00 +00:00
B. Petersen
67aac12995 docs: update instructions to UI where to display the address 2026-01-08 17:19:54 +01:00
B. Petersen
f2fb59f0cc test that channel summary does not have sender name 2026-01-08 17:17:16 +01:00
B. Petersen
55ab1b86f7 feat: more text instead of sender in channel summary
the sender is usually always the same and is not needed already in the summary;
making more place for the message instead
2026-01-08 17:17:16 +01:00
Hocuri
ceba687df3 feat: Config option to skip seen synchronization (#7694)
At urgent request from @hpk42, this adds a config option `team_profile`.
This option is only settable via SQLite (not exposed in the UI), and the
only thing it does is disabling synchronization of seen status.

I tested manually on my Android phone that it works.

Not straigthforward to write an automatic test, because we want to test that something
does _not_ happen (i.e. that the seen status is _not_ synchronized), and
it's not clear how long to wait before we check.

Probably it's fine to just not add a test.

This is what I tried:

```python
@pytest.mark.parametrize("team_profile", [True, False])
def test_markseen_basic(team_profile, acfactory):
    """
    Test that seen status is synchronized iff `team_profile` isn't set.
    """
    alice, bob = acfactory.get_online_accounts(2)

    # Bob sets up a second device.
    bob2 = bob.clone()
    bob2.start_io()

    alice_chat_bob = alice.create_chat(bob)
    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()

    # PROBLEM: We're not waiting 'long enough',
    # so, the 'state == MessageState.IN_SEEN' assertion below fails
    bob.create_chat(bob).send_text("Self-sent message")
    self_sent = bob2.wait_for_msg(EventType.MSGS_CHANGED)
    assert self_sent.get_snapshot().text == "Self-sent message"

    if team_profile:
        assert message2.get_snapshot().state == MessageState.IN_FRESH
    else:
        assert message2.get_snapshot().state == MessageState.IN_SEEN
```
2026-01-08 17:06:03 +01:00
link2xt
7e811469b3 api: add who_can_call_me config option 2026-01-07 22:00:54 +00:00
link2xt
cdacad235e chore: update lru 0.12.3 to 0.12.5 and add RUSTSEC-2026-0002 exception
Closes https://github.com/chatmail/core/issues/7692
2026-01-07 20:18:32 +00:00
link2xt
c766397abc test: regression test for vc-request encrypted by the server 2026-01-07 15:47:16 +00:00
link2xt
14a59afd5d fix: do not rely on Secure-Join header to detect {vc,vg}-request 2026-01-07 15:47:16 +00:00
dependabot[bot]
9c883e6424 chore(cargo): bump rsa from 0.9.9 to 0.9.10
Bumps [rsa](https://github.com/RustCrypto/RSA) from 0.9.9 to 0.9.10.
- [Changelog](https://github.com/RustCrypto/RSA/blob/v0.9.10/CHANGELOG.md)
- [Commits](https://github.com/RustCrypto/RSA/compare/v0.9.9...v0.9.10)

---
updated-dependencies:
- dependency-name: rsa
  dependency-version: 0.9.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-06 17:08:26 -03:00
146 changed files with 7136 additions and 4033 deletions

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
@@ -59,9 +59,9 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: EmbarkStudios/cargo-deny-action@76cd80eb775d7bbbd2d80292136d74d39e1b4918
- uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979
with:
arguments: --all-features --workspace
arguments: --workspace --all-features --locked
command: check
command-arguments: "-Dwarnings"

View File

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

View File

@@ -1,5 +1,258 @@
# Changelog
## [2.43.0] - 2026-02-17
### Features / Changes
- Group and broadcast channel descriptions ([#7829](https://github.com/chatmail/core/pull/7829)).
### Fixes
- Assign iroh gossip topic to pre-message when post-message is received.
### Miscellaneous Tasks
- Update fast-socks5 to version 1.0.
- cargo: Update keccak from 0.1.5 to 0.1.6.
- deps: Bump astral-sh/setup-uv from 7.1.6 to 7.3.0.
### Performance
- Use recv_direct() instead of recv() on the event channel.
### Refactor
- Enable `clippy::manual_is_variant_and`.
### Tests
- Fix flaky `test_transport_synchronization` ([#7850](https://github.com/chatmail/core/pull/7850)).
## [2.42.0] - 2026-02-10
### Fixes
- Set `mvbox_move` to '0' explicitly for existing chatmail profiles.
It's needed to prevent device message about deprecated `mvbox_move` option from appearing in chatmail profiles.
### Features / Changes
- Do not scan not watched folders.
### Miscellaneous Tasks
- Update rPGP from 0.18.0 to 0.19.0.
- cargo: Bump quick-xml from 0.38.4 to 0.39.0.
### Tests
- Remove test_dont_show_emails.
### Other
- Fix typo in CHANGELOG for marknoticed_all_chats.
## [2.41.0] - 2026-02-06
### Features / Changes
- Do not require `ShowEmails` to be set to `All` for adding second relay.
- Use different strings for audio and video calls.
### Fixes
- Don't set download state to Failure if message is available on another Session's transport ([#7684](https://github.com/chatmail/core/pull/7684)).
- Make use of call stock strings.
### Miscellaneous Tasks
- cargo: Bump `time` from 0.3.37 to 0.3.47.
## [2.40.0] - 2026-02-04
### Features / Changes
- Receive_imf: Log reasoning for chat assignment.
- Use more fitting encryption info message.
- Send Intended Recipient Fingerprint subpackets.
- Trash messages with intended recipient fingerprints, but w/o our one included.
- Do not collect email addresses from messages after configuration.
- Add device message about legacy `mvbox_move`.
- Never create IMAP folders.
- Make summary for pre-messages look like summary for fully downloaded messages ([#7775](https://github.com/chatmail/core/pull/7775)).
- Don't call `BlobObject::create_and_deduplicate()` when forwarding message to the same account.
- Allow clients to specify whether a call has video initially or not ([#7740](https://github.com/chatmail/core/pull/7740)).
- Do not load more than one own key from the keychain.
### Fixes
- Cross-account forwarding of a message which `has_html()` ([#7791](https://github.com/chatmail/core/pull/7791)).
- Make self-contact a key-contact even if key isn't generated yet.
- `apply_group_changes()`: Check whether From is key-contact.
- Don't add SELF to unencrypted chat created from encrypted message ([#7661](https://github.com/chatmail/core/pull/7661)).
- Don't upscale images and test that image resolution isn't changed unnecessarily ([#7769](https://github.com/chatmail/core/pull/7769)).
- Restart i/o when there are new transports in a sync message ([#7640](https://github.com/chatmail/core/pull/7640)).
- `add_or_lookup_key_contacts*()`: Advance fingerprint_iter on invalid address.
- `receive_imf`: Look up key contact by intended recipient fingerprint ([#7661](https://github.com/chatmail/core/pull/7661)).
- Remove `Config::DeleteToTrash` and `Config::ConfiguredTrashFolder`.
### API-Changes
- jsonrpc(python): Process events forever by default.
### CI
- Make scripts/deny.sh test the locked version of dependencies.
### Refactor
- Remove unneeded dbg! statements ([#7776](https://github.com/chatmail/core/pull/7776)).
- Remove unused Context.is_inbox().
- Rename lookup_key_contacts_by_address_list() to lookup_key_contacts_fallback_to_chat().
- Mark `ProviderOptions` as `non_exhaustive`.
### Miscellaneous Tasks
- Update provider database.
- cargo: Update `bytes` from 1.11.0 to 1.11.1.
- cargo: Bump tokio from 1.48.0 to 1.49.0.
- cargo: Bump tokio-util from 0.7.17 to 0.7.18.
- cargo: Bump libc from 0.2.178 to 0.2.180.
- cargo: Bump quote from 1.0.42 to 1.0.44.
- cargo: Bump syn from 2.0.111 to 2.0.114.
- cargo: Bump human-panic from 2.0.4 to 2.0.6.
- cargo: Bump chrono from 0.4.42 to 0.4.43.
- cargo: Bump data-encoding from 2.9.0 to 2.10.0.
- cargo: Bump colorutils-rs from 0.7.5 to 0.7.6.
- Update provider database.
- cargo: Bump thiserror from 2.0.17 to 2.0.18.
- deps: Bump EmbarkStudios/cargo-deny-action from 2.0.14 to 2.0.15.
- Remove RUSTSEC-2026-0002 exception from deny.toml.
- cargo: Bump tokio-stream from 0.1.17 to 0.1.18.
- cargo: Bump toml from 0.9.10+spec-1.1.0 to 0.9.11+spec-1.1.0.
- cargo: Bump serde_json from 1.0.148 to 1.0.149.
- cargo: Bump uuid from 1.19.0 to 1.20.0.
- cargo: Bump rustls-pki-types from 1.13.2 to 1.14.0.
- cargo: Bump tracing-subscriber from 0.3.20 to 0.3.22.
### Tests
- 2nd device receives message via new primary transport.
- Make `test_dont_move_sync_msgs` less flaky.
- Encrypted incoming message goes to encrypted 1:1 chat even if references messages in ad-hoc group.
- Message in blocked chat arrives as InSeen.
- Set `mvbox_move` to 0 for test rust accounts.
## [2.39.0] - 2026-01-23
### CI
- Update Rust to 1.93.0.
### Documentation
- RELEASE.md: Push preparation commit to the main branch before tagging.
- RELEASE.md: Add section about dealing with failed releases.
### Fixes
- Forward message with file ([#7755](https://github.com/chatmail/core/pull/7755)).
- Do not additionally reduce the resolution of images that fit into the resolution-limit and are larger than the file-size-limit ([#7760](https://github.com/chatmail/core/pull/7760)).
### Miscellaneous Tasks
- Merge v2.38.0 into main branch.
- Cleanup deprecated functions/defines ([#7763](https://github.com/chatmail/core/pull/7763)).
## [2.38.0] - 2026-01-22
### API-Changes
- [**breaking**] Jsonrpc: remove `contacts` from `FullChat`. To migrate load contacts on demand via `get_contacts_by_ids` using `FullChat.contactIds` ([#7282](https://github.com/chatmail/core/pull/7282)).
- jsonrpc: Add run_until parameter for bots ([#7688](https://github.com/chatmail/core/pull/7688)).
- rust, jsonrpc: Add `get_message_read_receipt_count` method ([#7732](https://github.com/chatmail/core/pull/7732)).
- rust and jsonrpc: Marknoticed_all_chats method to mark all chats as noticed, including muted ones. ([#7709](https://github.com/chatmail/core/pull/7709)).
- Public re-export of Connectivity ([#7737](https://github.com/chatmail/core/pull/7737)).
### Documentation
- Fix chat types.
- Set_config_from_qr() configures context for "DCACCOUNT:" and "DCLOGIN:" QRs ([#7450](https://github.com/chatmail/core/pull/7450)).
- Fix formatting of `indoc!` link.
### Features / Changes
- Pre-messages / next version of download on demand ([#7371](https://github.com/chatmail/core/pull/7371)).
- Connectivity view: move quota up and combine with IMAP state. ([#7653](https://github.com/chatmail/core/pull/7653)).
- Execute sync message before checking for primary transport update.
- Disable partial search by contact address.
- Don't put text into post-message ([#7714](https://github.com/chatmail/core/pull/7714)).
- Don't scale up Origin of multiple and broadcast recipients when sending a message.
- pgp: Use preferred hash algorithm for signing instead of hardcoded SHA256.
- In teamprofiles, don't mark chat as read on outgoing message ([#7717](https://github.com/chatmail/core/pull/7717)).
- Send and apply MDNs to self ([#7005](https://github.com/chatmail/core/pull/7005))
### Fixes
- Do not show contact address in message info ([#7695](https://github.com/chatmail/core/pull/7695)).
- Take transport_id into account when marking messages with \Seen flags.
- Send bcc-self messages to all own relays ([#7656](https://github.com/chatmail/core/pull/7656)).
- Only emit TransportsModified if transports are really modified.
- Logging errors in deltachat-rpc-server during startup ([#7707](https://github.com/chatmail/core/pull/7707)).
- Use only lowercase letters for stats id ([#7700](https://github.com/chatmail/core/pull/7700)).
- Hide incoming broadcasts in `DC_GCL_FOR_FORWARDING` ([#7726](https://github.com/chatmail/core/pull/7726)).
- Do not resolve ICE server hostnames during IMAP loop.
- More reliable parsing of `dclogin:` links with ip address as host ([#7734](https://github.com/chatmail/core/pull/7734)).
- Don't remember old channel members in the database ([#7716](https://github.com/chatmail/core/pull/7716)).
- Make it possible to leave and immediately delete a chat ([#7744](https://github.com/chatmail/core/pull/7744)).
- Emit MsgsChanged instead of MsgsNoticed on self-MDN if chat still has fresh messages.
- Prevent possible infinite loop with invalid `smtp` row ([#7746](https://github.com/chatmail/core/pull/7746)).
- Sync broadcast subscribers list ([#7578](https://github.com/chatmail/core/pull/7578))
### Refactor
- Don't use `concat!` in sql statements ([#7720](https://github.com/chatmail/core/pull/7720)).
### Tests
- Port test_dont_move_sync_msgs to JSON-RPC ([#7676](https://github.com/chatmail/core/pull/7676)).
- rpc-client: Replace remaining print()s with `logging` ([#6082](https://github.com/chatmail/core/pull/6082)).
## [2.37.0] - 2026-01-08
### API-Changes
- JSON-RPC API `get_all_ui_config_keys` to get all "ui.*" config keys ([#7579](https://github.com/chatmail/core/pull/7579)).
- Add `who_can_call_me` config option.
- cffi api to create account manager with existing events channel to see events emitted during startup. `dc_event_channel_new`, `dc_event_channel_unref`, `dc_event_channel_get_event_emitter` and `dc_accounts_new_with_event_channel` ([#7609](https://github.com/chatmail/core/pull/7609)).
### Features / Changes
- Config option to skip seen synchronization ([#7694](https://github.com/chatmail/core/pull/7694)).
- More text instead of sender in channel summary.
### Fixes
- Do not rely on Secure-Join header to detect {vc,vg}-request.
### Documentation
- Update instructions to UI where to display the address.
### Miscellaneous Tasks
- cargo: bump rsa from 0.9.9 to 0.9.10.
- Update lru 0.12.3 to 0.12.5 and add RUSTSEC-2026-0002 exception.
### Refactor
- ffi: Replace implicit drop in cffi with explicit `drop(Arc::from_raw(var))` ([#7664](https://github.com/chatmail/core/pull/7664)).
### Tests
- Regression test for vc-request encrypted by the server.
- Test that channel summary does not have sender name.
## [2.36.0] - 2026-01-03
### CI
@@ -150,7 +403,7 @@ that failed to be published for 2.31.0 due to not configured "trusted publishers
### Features / Changes
- Lookup_or_create_adhoc_group(): Add context to SQL errors ([#7554](https://github.com/chatmail/core/pull/7554)).
- `lookup_or_create_adhoc_group()`: Add context to SQL errors ([#7554](https://github.com/chatmail/core/pull/7554)).
## [2.31.0] - 2025-12-04
@@ -7507,3 +7760,10 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.34.0]: https://github.com/chatmail/core/compare/v2.33.0..v2.34.0
[2.35.0]: https://github.com/chatmail/core/compare/v2.34.0..v2.35.0
[2.36.0]: https://github.com/chatmail/core/compare/v2.35.0..v2.36.0
[2.37.0]: https://github.com/chatmail/core/compare/v2.36.0..v2.37.0
[2.38.0]: https://github.com/chatmail/core/compare/v2.37.0..v2.38.0
[2.39.0]: https://github.com/chatmail/core/compare/v2.38.0..v2.39.0
[2.40.0]: https://github.com/chatmail/core/compare/v2.39.0..v2.40.0
[2.41.0]: https://github.com/chatmail/core/compare/v2.40.0..v2.41.0
[2.42.0]: https://github.com/chatmail/core/compare/v2.41.0..v2.42.0
[2.43.0]: https://github.com/chatmail/core/compare/v2.42.0..v2.43.0

389
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.36.0"
version = "2.43.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.88"
@@ -45,7 +45,7 @@ anyhow = { workspace = true }
async-broadcast = "0.7.2"
async-channel = { workspace = true }
async-imap = { version = "0.11.1", default-features = false, features = ["runtime-tokio", "compress"] }
async-native-tls = { version = "0.5", default-features = false, features = ["runtime-tokio"] }
async-native-tls = { version = "0.6", default-features = false, features = ["runtime-tokio"] }
async-smtp = { version = "0.10.2", default-features = false, features = ["runtime-tokio"] }
async_zip = { version = "0.0.18", default-features = false, features = ["deflate", "tokio-fs"] }
base64 = { workspace = true }
@@ -56,7 +56,7 @@ chrono = { workspace = true, features = ["alloc", "clock", "std"] }
colorutils-rs = { version = "0.7.5", default-features = false }
data-encoding = "2.9.0"
escaper = "0.1"
fast-socks5 = "0.10"
fast-socks5 = "1"
fd-lock = "4"
futures-lite = { workspace = true }
futures = { workspace = true }
@@ -78,10 +78,10 @@ num-derive = "0.4"
num-traits = { workspace = true }
parking_lot = "0.12.4"
percent-encoding = "2.3"
pgp = { version = "0.18.0", default-features = false }
pgp = { version = "0.19.0", default-features = false }
pin-project = "1"
qrcodegen = "1.7.0"
quick-xml = { version = "0.38", features = ["escape-html"] }
quick-xml = { version = "0.39", features = ["escape-html"] }
rand-old = { package = "rand", version = "0.8" }
rand = { workspace = true }
regex = { workspace = true }
@@ -182,7 +182,7 @@ harness = false
anyhow = "1"
async-channel = "2.5.0"
base64 = "0.22"
chrono = { version = "0.4.42", default-features = false }
chrono = { version = "0.4.43", default-features = false }
deltachat-contact-tools = { path = "deltachat-contact-tools" }
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
deltachat = { path = ".", default-features = false }
@@ -202,7 +202,7 @@ serde_json = "1"
tempfile = "3.24.0"
thiserror = "2"
tokio = "1"
tokio-util = "0.7.17"
tokio-util = "0.7.18"
tracing-subscriber = "0.3"
yerpc = "0.6.4"

View File

@@ -1,4 +1,4 @@
# Releasing a new version of DeltaChat core
# Releasing a new version of chatmail core
For example, to release version 1.116.0 of the core, do the following steps.
@@ -14,8 +14,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.

View File

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

View File

@@ -58,7 +58,7 @@ async fn create_context() -> Context {
.await
.unwrap();
let secret = key_from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap();
let public = secret.signed_public_key();
let public = secret.to_public_key();
let key_pair = KeyPair { public, secret };
store_self_keypair(&context, &key_pair)
.await

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ typedef struct _dc_lot dc_lot_t;
typedef struct _dc_provider dc_provider_t;
typedef struct _dc_event dc_event_t;
typedef struct _dc_event_emitter dc_event_emitter_t;
typedef struct _dc_event_channel dc_event_channel_t;
typedef struct _dc_jsonrpc_instance dc_jsonrpc_instance_t;
typedef struct _dc_backup_provider dc_backup_provider_t;
@@ -485,12 +486,15 @@ char* dc_get_blobdir (const dc_context_t* context);
* 0=use IMAP IDLE if the server supports it.
* This is a developer option used for testing polling used as an IDLE fallback.
* - `download_limit` = Messages up to this number of bytes are downloaded automatically.
* For larger messages, only the header is downloaded and a placeholder is shown.
* These messages can be downloaded fully using dc_download_full_msg() later.
* The limit is compared against raw message sizes, including headers.
* The actually used limit may be corrected
* to not mess up with non-delivery-reports or read-receipts.
* 0=no limit (default).
* For messages with large attachments, two messages are sent:
* a Pre-Message containing metadata and text and a Post-Message additionally
* containing the attachment. NB: Some "extra" metadata like avatars and gossiped
* encryption keys is stripped from post-messages to save traffic.
* Pre-Messages are shown as placeholder messages. They can be downloaded fully
* using dc_download_full_msg() later. Post-Messages are automatically
* downloaded if they are smaller than the download_limit. Other messages are
* always auto-downloaded.
* 0 = no limit (default).
* Changes affect future messages only.
* - `protect_autocrypt` = Enable Header Protection for Autocrypt header.
* This is an experimental option not compatible to other MUAs
@@ -516,6 +520,10 @@ char* dc_get_blobdir (const dc_context_t* context);
* - `webxdc_realtime_enabled` = Whether the realtime APIs should be enabled.
* 0 = WebXDC realtime API is disabled and behaves as noop.
* 1 = WebXDC realtime API is enabled (default).
* - `who_can_call_me` = Who can cause call notifications.
* 0 = Everybody (except explicitly blocked contacts),
* 1 = Contacts (default, does not include contact requests),
* 2 = Nobody (calls never result in a notification).
*
* If you want to retrieve a value, use dc_get_config().
*
@@ -572,11 +580,10 @@ int dc_set_stock_translation(dc_context_t* context, uint32_t stock_i
/**
* Set configuration values from a QR code.
* Before this function is called, dc_check_qr() should confirm the type of the
* QR code is DC_QR_ACCOUNT or DC_QR_LOGIN.
* Before this function is called, dc_check_qr() should be used to get the QR code type.
*
* Internally, the function will call dc_set_config() with the appropriate keys,
* e.g. `addr` and `mail_pw` for DC_QR_ACCOUNT and DC_QR_LOGIN.
* DC_QR_ACCOUNT and DC_QR_LOGIN QR codes configure the context, but I/O mustn't be started for such
* QR codes.
*
* @memberof dc_context_t
* @param context The context object.
@@ -887,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
@@ -1235,11 +1242,12 @@ uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t c
* This needs to be a one-to-one chat.
* @param place_call_info any data that other devices receive
* in #DC_EVENT_INCOMING_CALL.
* @param has_video_initially Whether the call has video.
* This allows the recipient's client to adjust UX.
* @param has_video Whether the call has video initially.
* This allows the recipient's client to adjust incoming call UX.
* A call can be upgraded to include video later.
* @return ID of the system message announcing the call.
*/
uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info, int has_video_initially);
uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info, int has_video);
/**
@@ -1558,7 +1566,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().
@@ -1846,15 +1854,16 @@ int dc_remove_contact_from_chat (dc_context_t* context, uint32_t ch
/**
* Set group name.
* Set the name of a group or broadcast channel.
*
* If the group is already _promoted_ (any message was sent to the group),
* all group members are informed by a special status message that is sent automatically by this function.
* or if this is a brodacast channel,
* all members are informed by a special status message that is sent automatically by this function.
*
* Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
*
* @memberof dc_context_t
* @param chat_id The chat ID to set the name for. Must be a group chat.
* @param chat_id The chat ID to set the name for. Must be a group chat or broadcast channel.
* @param name New name of the group.
* @param context The context object.
* @return 1=success, 0=error
@@ -1881,10 +1890,11 @@ int dc_set_chat_name (dc_context_t* context, uint32_t ch
int dc_set_chat_ephemeral_timer (dc_context_t* context, uint32_t chat_id, uint32_t timer);
/**
* Set group profile image.
* Set group or broadcast channel profile image.
*
* If the group is already _promoted_ (any message was sent to the group),
* all group members are informed by a special status message that is sent automatically by this function.
* or if this is a brodacast channel,
* all members are informed by a special status message that is sent automatically by this function.
*
* Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
*
@@ -1892,7 +1902,7 @@ int dc_set_chat_ephemeral_timer (dc_context_t* context, uint32_t chat_id, uint32
*
* @memberof dc_context_t
* @param context The context object.
* @param chat_id The chat ID to set the image for.
* @param chat_id The chat ID to set the image for. Must be a group chat or broadcast channel.
* @param image Full path of the image to use as the group image. The image will immediately be copied to the
* `blobdir`; the original image will not be needed anymore.
* If you pass NULL here, the group image is deleted (for promoted groups, all members are informed about
@@ -2215,10 +2225,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
@@ -2291,17 +2297,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.
*
@@ -2574,7 +2569,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")
@@ -3091,7 +3085,7 @@ int dc_receive_backup (dc_context_t* context, const char* qr);
/**
* Create a new account manager.
* The account manager takes an directory
* The account manager takes a directory
* where all context-databases are placed in.
* To add a context to the account manager,
* use dc_accounts_add_account() or dc_accounts_migrate_account().
@@ -3113,6 +3107,35 @@ int dc_receive_backup (dc_context_t* context, const char* qr);
*/
dc_accounts_t* dc_accounts_new (const char* dir, int writable);
/**
* Create a new account manager with an existing events channel,
* which allows you to see events emitted during startup.
*
* The account manager takes a directory
* where all context-databases are placed in.
* To add a context to the account manager,
* use dc_accounts_add_account() or dc_accounts_migrate_account().
* All account information are persisted.
* To remove a context from the account manager,
* use dc_accounts_remove_account().
*
* @memberof dc_accounts_t
* @param dir The directory to create the context-databases in.
* If the directory does not exist,
* dc_accounts_new_with_event_channel() will try to create it.
* @param writable Whether the returned account manager is writable, i.e. calling these functions on
* it is possible: dc_accounts_add_account(), dc_accounts_add_closed_account(),
* dc_accounts_migrate_account(), dc_accounts_remove_account(), dc_accounts_select_account().
* @param dc_event_channel_t Events Channel to be used for this accounts manager,
* create one with dc_event_channel_new().
* This channel is consumed by this method and can not be used again afterwards,
* so be sure to call `dc_event_channel_get_event_emitter` before.
* @return An account manager object.
* The object must be passed to the other account manager functions
* and must be freed using dc_accounts_unref() after usage.
* On errors, NULL is returned.
*/
dc_accounts_t* dc_accounts_new_with_event_channel(const char* dir, int writable, dc_event_channel_t* events_channel);
/**
* Free an account manager object.
@@ -3353,8 +3376,12 @@ void dc_accounts_set_push_device_token (dc_accounts_t* accounts, const
* Must be freed using dc_event_emitter_unref() after usage.
*
* Note: Use only one event emitter per account manager.
* Having more than one event emitter running at the same time on the same account manager
* will result in events randomly delivered to the one or to the other.
* The result of having multiple event emitters is unspecified.
* Currently events are broadcasted to all existing event emitters,
* but previous versions delivered events to only one event emitter
* and this behavior may change again in the future.
* Events emitted before creation of event emitter
* are not available to event emitter.
*/
dc_event_emitter_t* dc_accounts_get_event_emitter (dc_accounts_t* accounts);
@@ -3731,30 +3758,7 @@ uint32_t dc_chat_get_id (const dc_chat_t* chat);
/**
* Get chat type as one of the @ref DC_CHAT_TYPE constants:
*
* - @ref DC_CHAT_TYPE_SINGLE - a normal chat is a chat with a single contact,
* chats_contacts contains one record for the user. DC_CONTACT_ID_SELF
* (see dc_contact_t::id) is added _only_ for a self talk.
* These chats are created by dc_create_chat_by_contact_id().
*
* - @ref DC_CHAT_TYPE_GROUP - a group chat, chats_contacts contain all group
* members, incl. DC_CONTACT_ID_SELF.
* Groups are created by dc_create_group_chat().
*
* - @ref DC_CHAT_TYPE_MAILINGLIST - a mailing list, this is similar to groups,
* however, the member list cannot be retrieved completely
* and cannot be changed using this api.
* Mailing lists are created as needed by incoming messages
* and usually require some special server;
* they cannot be created by a function call as the other chat types.
* Moreover, for now, mailing lists are read-only.
*
* - @ref DC_CHAT_TYPE_BROADCAST - a broadcast list,
* the recipients will get messages in a one-to-one chats and
* the sender will get answers in a one-to-one as well.
* chats_contacts contain all recipients but DC_CONTACT_ID_SELF.
* Broadcasts are created by dc_create_broadcast_list().
* Get chat type as one of the @ref DC_CHAT_TYPE constants.
*
* @memberof dc_chat_t
* @param chat The chat object.
@@ -4310,6 +4314,7 @@ char* dc_msg_get_webxdc_info (const dc_msg_t* msg);
/**
* Get the size of the file. Returns the size of the file associated with a
* message, if applicable.
* If message is a pre-message, then this returns the size of the file to be downloaded.
*
* Typically, this is used to show the size of document files, e.g. a PDF.
*
@@ -4607,6 +4612,7 @@ int dc_msg_is_info (const dc_msg_t* msg);
* and also offer a way to fix the encryption, eg. by a button offering a QR scan
* - DC_INFO_WEBXDC_INFO_MESSAGE (32) - Info-message created by webxdc app sending `update.info`
* - DC_INFO_CHAT_E2EE (50) - Info-message for "Chat is end-to-end-encrypted"
* - DC_INFO_GROUP_NAME_CHANGED (70) - Info-message "Description changed", UI should open the profile with the description
*
* For the messages that refer to a CONTACT,
* dc_msg_get_info_contact_id() returns the contact ID.
@@ -4659,10 +4665,10 @@ 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
#define DC_INFO_GROUP_DESCRIPTION_CHANGED 70
/**
@@ -5327,8 +5333,8 @@ int dc_contact_is_key_contact (dc_contact_t* contact);
*
* - If dc_contact_get_verifier_id() != 0,
* display text "Introduced by ..."
* with the name and address of the contact
* formatted by dc_contact_get_name_n_addr().
* with the name of the contact
* formatted by dc_contact_get_name().
* Prefix the text by a green checkmark.
*
* - If dc_contact_get_verifier_id() == 0 and dc_contact_is_verified() != 0,
@@ -5759,17 +5765,32 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot);
#define DC_CHAT_TYPE_UNDEFINED 0
/**
* A one-to-one chat with a single contact. See dc_chat_get_type() for details.
* A one-to-one chat with a single contact.
*
* dc_get_chat_contacts() contains one record for the user.
* DC_CONTACT_ID_SELF is added _only_ for a self talk.
* These chats are created by dc_create_chat_by_contact_id().
*/
#define DC_CHAT_TYPE_SINGLE 100
/**
* A group chat. See dc_chat_get_type() for details.
* A group chat.
*
* dc_get_chat_contacts() contain all group members,
* including DC_CONTACT_ID_SELF.
* Groups are created by dc_create_group_chat().
*/
#define DC_CHAT_TYPE_GROUP 120
/**
* A mailing list. See dc_chat_get_type() for details.
* A mailing list.
*
* This is similar to groups,
* however, the member list cannot be retrieved completely
* and cannot be changed using an API from this library.
* Mailing lists are created as needed by incoming messages
* and usually require some special server;
* they cannot be created by a function call as the other chat types.
*/
#define DC_CHAT_TYPE_MAILINGLIST 140
@@ -5998,6 +6019,62 @@ char* dc_jsonrpc_next_response(dc_jsonrpc_instance_t* jsonrpc_instance);
*/
char* dc_jsonrpc_blocking_call(dc_jsonrpc_instance_t* jsonrpc_instance, const char *input);
/**
* @class dc_event_channel_t
*
* Opaque object that is used to create an event emitter which can be used log events during startup of an accounts manger.
* Only used for dc_accounts_new_with_event_channel().
* To use it:
* 1. create an events channel with `dc_event_channel_new()`.
* 2. get an event emitter for it with `dc_event_channel_get_event_emitter()`.
* 3. use it to create your account manager with `dc_accounts_new_with_event_channel()`, which consumes the channel.
* 4. free the empty channel wrapper object with `dc_event_channel_unref()`.
*/
/**
* Create a new event channel.
*
* @memberof dc_event_channel_t
* @return An event channel wrapper object (dc_event_channel_t).
*/
dc_event_channel_t* dc_event_channel_new();
/**
* Release/free the events channel structure.
* This function releases the memory of the `dc_event_channel_t` structure.
*
* you can call it after calling dc_accounts_new_with_event_channel,
* which took the events channel out of it already, so this just frees the underlying option.
*
* @memberof dc_event_channel_t
*/
void dc_event_channel_unref(dc_event_channel_t* event_channel);
/**
* Create the event emitter that is used to receive events.
*
* The library will emit various @ref DC_EVENT events, such as "new message", "message read" etc.
* To get these events, you have to create an event emitter using this function
* and call dc_get_next_event() on the emitter.
*
* This is similar to dc_get_event_emitter(), which, however,
* must not be called for accounts handled by the account manager.
*
* @memberof dc_event_channel_t
* @param The event channel.
* @return Returns the event emitter, NULL on errors.
* Must be freed using dc_event_emitter_unref() after usage.
*
* Note: Use only one event emitter per account manager / event channel.
* The result of having multiple event emitters is unspecified.
* Currently events are broadcasted to all existing event emitters,
* but previous versions delivered events to only one event emitter
* and this behavior may change again in the future.
* Events emitted before creation of event emitter
* are not available to event emitter.
*/
dc_event_emitter_t* dc_event_channel_get_event_emitter(dc_event_channel_t* event_channel);
/**
* @class dc_event_emitter_t
*
@@ -6935,61 +7012,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().
@@ -7000,90 +7032,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.
@@ -7124,13 +7089,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.
@@ -7144,53 +7102,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.
@@ -7224,42 +7135,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.
@@ -7272,22 +7147,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
/// "%1$s message"
///
/// Used as the message body when a message
/// was not yet downloaded completely
/// (dc_msg_get_download_state() is e.g. @ref DC_DOWNLOAD_AVAILABLE).
///
/// `%1$s` will be replaced by human-readable size (e.g. "1.2 MiB").
#define DC_STR_PARTIAL_DOWNLOAD_MSG_BODY 99
/// "Download maximum available until %1$s"
///
/// Appended after some separator to @ref DC_STR_PARTIAL_DOWNLOAD_MSG_BODY.
///
/// `%1$s` will be replaced by human-readable date and time.
#define DC_STR_DOWNLOAD_AVAILABILITY 100
/// "Multi Device Synchronization"
///
/// Used in subjects of outgoing sync messages.
@@ -7313,10 +7172,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.
@@ -7375,8 +7230,7 @@ void dc_event_unref(dc_event_t* event);
/// May be followed by the info-messages
/// #DC_STR_SECURE_JOIN_REPLIES, #DC_STR_CONTACT_VERIFIED and #DC_STR_MSGADDMEMBER.
///
/// `%1$s` will be replaced by name and address of the inviter,
/// `%2$s` will be replaced by the name of the inviter.
/// `%1$s` and `%2$s` will be replaced by name of the inviter.
#define DC_STR_SECURE_JOIN_STARTED 117
/// "%1$s replied, waiting for being added to the group…"
@@ -7393,7 +7247,7 @@ void dc_event_unref(dc_event_t* event);
///
/// Subtitle for verification qrcode svg image generated by the core.
///
/// `%1$s` will be replaced by name and address of the inviter.
/// `%1$s` will be replaced by name of the inviter.
#define DC_STR_SETUP_CONTACT_QR_DESC 119
/// "Scan to join %1$s"
@@ -7408,12 +7262,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.
@@ -7424,7 +7272,7 @@ void dc_event_unref(dc_event_t* event);
///
/// `%1$s` will be replaced by the old group name.
/// `%2$s` will be replaced by the new group name.
/// `%3$s` will be replaced by name and address of the contact who did the action.
/// `%3$s` will be replaced by name of the contact who did the action.
#define DC_STR_GROUP_NAME_CHANGED_BY_OTHER 125
/// "You changed the group image."
@@ -7432,7 +7280,7 @@ void dc_event_unref(dc_event_t* event);
/// "Group image changed by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact who did the action.
/// `%1$s` will be replaced by name of the contact who did the action.
#define DC_STR_GROUP_IMAGE_CHANGED_BY_OTHER 127
/// "You added member %1$s."
@@ -7444,23 +7292,23 @@ void dc_event_unref(dc_event_t* event);
/// "Member %1$s added by %2$s."
///
/// `%1$s` will be replaced by name and address of the contact added to the group.
/// `%2$s` will be replaced by name and address of the contact who did the action.
/// `%1$s` will be replaced by name of the contact added to the group.
/// `%2$s` will be replaced by name of the contact who did the action.
///
/// Used in status messages.
#define DC_STR_ADD_MEMBER_BY_OTHER 129
/// "You removed member %1$s."
///
/// `%1$s` will be replaced by name and address of the contact removed from the group.
/// `%1$s` will be replaced by name of the contact removed from the group.
///
/// Used in status messages.
#define DC_STR_REMOVE_MEMBER_BY_YOU 130
/// "Member %1$s removed by %2$s."
///
/// `%1$s` will be replaced by name and address of the contact removed from the group.
/// `%2$s` will be replaced by name and address of the contact who did the action.
/// `%1$s` will be replaced by name of the contact removed from the group.
/// `%2$s` will be replaced by name of the contact who did the action.
///
/// Used in status messages.
#define DC_STR_REMOVE_MEMBER_BY_OTHER 131
@@ -7472,7 +7320,7 @@ void dc_event_unref(dc_event_t* event);
/// "Group left by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
/// `%1$s` will be replaced by name of the contact.
///
/// Used in status messages.
#define DC_STR_GROUP_LEFT_BY_OTHER 133
@@ -7484,7 +7332,7 @@ void dc_event_unref(dc_event_t* event);
/// "Group image deleted by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
/// `%1$s` will be replaced by name of the contact.
///
/// Used in status messages.
#define DC_STR_GROUP_IMAGE_DELETED_BY_OTHER 135
@@ -7496,7 +7344,7 @@ void dc_event_unref(dc_event_t* event);
/// "Location streaming enabled by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
/// `%1$s` will be replaced by name of the contact.
///
/// Used in status messages.
#define DC_STR_LOCATION_ENABLED_BY_OTHER 137
@@ -7508,7 +7356,7 @@ void dc_event_unref(dc_event_t* event);
/// "Message deletion timer is disabled by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
/// `%1$s` will be replaced by name of the contact.
///
/// Used in status messages.
#define DC_STR_EPHEMERAL_TIMER_DISABLED_BY_OTHER 139
@@ -7523,22 +7371,11 @@ void dc_event_unref(dc_event_t* event);
/// "Message deletion timer is set to %1$s s by %2$s."
///
/// `%1$s` will be replaced by the number of seconds (always >1) the timer is set to.
/// `%2$s` will be replaced by name and address of the contact.
/// `%2$s` will be replaced by name of the contact.
///
/// 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 and address 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.
@@ -7546,7 +7383,7 @@ void dc_event_unref(dc_event_t* event);
/// "Message deletion timer is set to 1 hour by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
/// `%1$s` will be replaced by name of the contact.
///
/// Used in status messages.
#define DC_STR_EPHEMERAL_TIMER_1_HOUR_BY_OTHER 145
@@ -7558,7 +7395,7 @@ void dc_event_unref(dc_event_t* event);
/// "Message deletion timer is set to 1 day by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
/// `%1$s` will be replaced by name of the contact.
///
/// Used in status messages.
#define DC_STR_EPHEMERAL_TIMER_1_DAY_BY_OTHER 147
@@ -7570,7 +7407,7 @@ void dc_event_unref(dc_event_t* event);
/// "Message deletion timer is set to 1 week by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
/// `%1$s` will be replaced by name of the contact.
///
/// Used in status messages.
#define DC_STR_EPHEMERAL_TIMER_1_WEEK_BY_OTHER 149
@@ -7587,7 +7424,7 @@ void dc_event_unref(dc_event_t* event);
/// Used in status messages.
///
/// `%1$s` will be replaced by the number of minutes (always >1) the timer is set to.
/// `%2$s` will be replaced by name and address of the contact.
/// `%2$s` will be replaced by name of the contact.
#define DC_STR_EPHEMERAL_TIMER_MINUTES_BY_OTHER 151
/// "You set message deletion timer to %1$s hours."
@@ -7602,7 +7439,7 @@ void dc_event_unref(dc_event_t* event);
/// Used in status messages.
///
/// `%1$s` will be replaced by the number of hours (always >1) the timer is set to.
/// `%2$s` will be replaced by name and address of the contact.
/// `%2$s` will be replaced by name of the contact.
#define DC_STR_EPHEMERAL_TIMER_HOURS_BY_OTHER 153
/// "You set message deletion timer to %1$s days."
@@ -7617,7 +7454,7 @@ void dc_event_unref(dc_event_t* event);
/// Used in status messages.
///
/// `%1$s` will be replaced by the number of days (always >1) the timer is set to.
/// `%2$s` will be replaced by name and address of the contact.
/// `%2$s` will be replaced by name of the contact.
#define DC_STR_EPHEMERAL_TIMER_DAYS_BY_OTHER 155
/// "You set message deletion timer to %1$s weeks."
@@ -7632,7 +7469,7 @@ void dc_event_unref(dc_event_t* event);
/// Used in status messages.
///
/// `%1$s` will be replaced by the number of weeks (always >1) the timer is set to.
/// `%2$s` will be replaced by name and address of the contact.
/// `%2$s` will be replaced by name of the contact.
#define DC_STR_EPHEMERAL_TIMER_WEEKS_BY_OTHER 157
/// "You set message deletion timer to 1 year."
@@ -7642,14 +7479,14 @@ void dc_event_unref(dc_event_t* event);
/// "Message deletion timer is set to 1 year by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
/// `%1$s` will be replaced by name of the contact.
///
/// Used in status messages.
#define DC_STR_EPHEMERAL_TIMER_1_YEAR_BY_OTHER 159
/// "Scan to set up second device for %1$s"
///
/// `%1$s` will be replaced by name and address of the account.
/// `%1$s` will be replaced by name of the account.
#define DC_STR_BACKUP_TRANSFER_QR 162
/// "Account transferred to your second device."
@@ -7708,26 +7545,9 @@ void dc_event_unref(dc_event_t* event);
/// Used as info message.
#define DC_STR_SECUREJOIN_WAIT 190
/// "Could not yet establish guaranteed end-to-end encryption, but you may already send a message."
///
/// @deprecated 2025-03
#define DC_STR_SECUREJOIN_WAIT_TIMEOUT 191
/// "The contact must be online to proceed. This process will continue automatically in background."
///
/// Used as info message.
/// @deprecated 2025-06-05
#define DC_STR_SECUREJOIN_TAKES_LONGER 192
/// "❤️ Seems you're enjoying Delta Chat!"… (donation request device message)
#define DC_STR_DONATION_REQUEST 193
/// "Outgoing call"
#define DC_STR_OUTGOING_CALL 194
/// "Incoming call"
#define DC_STR_INCOMING_CALL 195
/// "Declined call"
#define DC_STR_DECLINED_CALL 196
@@ -7779,6 +7599,24 @@ void dc_event_unref(dc_event_t* event);
/// Used as the first info messages in newly created classic email threads.
#define DC_STR_CHAT_UNENCRYPTED_EXPLANATON 230
/// "Outgoing audio call"
#define DC_STR_OUTGOING_AUDIO_CALL 232
/// "Outgoing video call"
#define DC_STR_OUTGOING_VIDEO_CALL 233
/// "Incoming audio call"
#define DC_STR_INCOMING_AUDIO_CALL 234
/// "Incoming video call"
#define DC_STR_INCOMING_VIDEO_CALL 235
/// "You changed the chat description."
#define DC_STR_GROUP_DESCRIPTION_CHANGED_BY_YOU 240
/// "Chat description changed by %1$s."
#define DC_STR_GROUP_DESCRIPTION_CHANGED_BY_OTHER 241
/**
* @}
*/

View File

@@ -17,7 +17,7 @@ use std::fmt::Write;
use std::future::Future;
use std::ptr;
use std::str::FromStr;
use std::sync::{Arc, LazyLock};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{Duration, SystemTime};
use anyhow::Context as _;
@@ -1181,7 +1181,7 @@ pub unsafe extern "C" fn dc_place_outgoing_call(
context: *mut dc_context_t,
chat_id: u32,
place_call_info: *const libc::c_char,
has_video_initially: bool,
has_video: bool,
) -> u32 {
if context.is_null() || chat_id == 0 {
eprintln!("ignoring careless call to dc_place_outgoing_call()");
@@ -1191,7 +1191,7 @@ pub unsafe extern "C" fn dc_place_outgoing_call(
let chat_id = ChatId::new(chat_id);
let place_call_info = to_string_lossy(place_call_info);
block_on(ctx.place_outgoing_call(chat_id, place_call_info, has_video_initially))
block_on(ctx.place_outgoing_call(chat_id, place_call_info, has_video))
.context("Failed to place call")
.log_err(ctx)
.map(|msg_id| msg_id.to_u32())
@@ -2261,22 +2261,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,
@@ -4765,6 +4749,98 @@ pub unsafe extern "C" fn dc_accounts_new(
}
}
pub type dc_event_channel_t = Mutex<Option<Events>>;
#[no_mangle]
pub unsafe extern "C" fn dc_event_channel_new() -> *mut dc_event_channel_t {
Box::into_raw(Box::new(Mutex::new(Some(Events::new()))))
}
/// Release the events channel structure.
///
/// This function releases the memory of the `dc_event_channel_t` structure.
///
/// you can call it after calling dc_accounts_new_with_event_channel,
/// which took the events channel out of it already, so this just frees the underlying option.
#[no_mangle]
pub unsafe extern "C" fn dc_event_channel_unref(event_channel: *mut dc_event_channel_t) {
if event_channel.is_null() {
eprintln!("ignoring careless call to dc_event_channel_unref()");
return;
}
drop(Box::from_raw(event_channel))
}
#[no_mangle]
pub unsafe extern "C" fn dc_event_channel_get_event_emitter(
event_channel: *mut dc_event_channel_t,
) -> *mut dc_event_emitter_t {
if event_channel.is_null() {
eprintln!("ignoring careless call to dc_event_channel_get_event_emitter()");
return ptr::null_mut();
}
let Some(event_channel) = &*(*event_channel)
.lock()
.expect("call to dc_event_channel_get_event_emitter() failed: mutex is poisoned")
else {
eprintln!(
"ignoring careless call to dc_event_channel_get_event_emitter()
-> channel was already consumed, make sure you call this before dc_accounts_new_with_event_channel"
);
return ptr::null_mut();
};
let emitter = event_channel.get_emitter();
Box::into_raw(Box::new(emitter))
}
#[no_mangle]
pub unsafe extern "C" fn dc_accounts_new_with_event_channel(
dir: *const libc::c_char,
writable: libc::c_int,
event_channel: *mut dc_event_channel_t,
) -> *const dc_accounts_t {
setup_panic!();
if dir.is_null() || event_channel.is_null() {
eprintln!("ignoring careless call to dc_accounts_new_with_event_channel()");
return ptr::null_mut();
}
// consuming channel enforce that you need to get the event emitter
// before initializing the account manager,
// so that you don't miss events/errors during initialisation.
// It also prevents you from using the same channel on multiple account managers.
let Some(event_channel) = (*event_channel)
.lock()
.expect("call to dc_event_channel_get_event_emitter() failed: mutex is poisoned")
.take()
else {
eprintln!(
"ignoring careless call to dc_accounts_new_with_event_channel()
-> channel was already consumed"
);
return ptr::null_mut();
};
let accs = block_on(Accounts::new_with_events(
as_path(dir).into(),
writable != 0,
event_channel,
));
match accs {
Ok(accs) => Arc::into_raw(Arc::new(RwLock::new(accs))),
Err(err) => {
// We are using Anyhow's .context() and to show the inner error, too, we need the {:#}:
eprintln!("failed to create accounts: {err:#}");
ptr::null_mut()
}
}
}
/// Release the accounts structure.
///
/// This function releases the memory of the `dc_accounts_t` structure.

View File

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

View File

@@ -11,8 +11,8 @@ use deltachat::blob::BlobObject;
use deltachat::calls::ice_servers;
use deltachat::chat::{
self, add_contact_to_chat, forward_msgs, forward_msgs_2ctx, get_chat_media, get_chat_msgs,
get_chat_msgs_ex, marknoticed_chat, remove_contact_from_chat, Chat, ChatId, ChatItem,
MessageListOptions,
get_chat_msgs_ex, marknoticed_all_chats, marknoticed_chat, remove_contact_from_chat, Chat,
ChatId, ChatItem, MessageListOptions,
};
use deltachat::chatlist::Chatlist;
use deltachat::config::{get_all_ui_config_keys, Config};
@@ -23,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,
@@ -194,6 +194,16 @@ impl CommandApi {
.context("event channel is closed")
}
/// Waits for at least one event and return a batch of events.
async fn get_next_event_batch(&self) -> Vec<Event> {
self.event_emitter
.recv_batch()
.await
.into_iter()
.map(|event| event.into())
.collect()
}
// ---------------------------------------------
// Account Management
// ---------------------------------------------
@@ -416,11 +426,11 @@ impl CommandApi {
Ok(())
}
/// Set configuration values from a QR code. (technically from the URI that is stored in the qrcode)
/// Before this function is called, `checkQr()` should confirm the type of the
/// QR code is `account` or `webrtcInstance`.
/// Set configuration values from a QR code (technically from the URI stored in it).
/// Before this function is called, `check_qr()` should be used to get the QR code type.
///
/// Internally, the function will call dc_set_config() with the appropriate keys,
/// "DCACCOUNT:" and "DCLOGIN:" QR codes configure the account, but I/O mustn't be started for
/// such QR codes, consider using [`Self::add_transport_from_qr`] which also restarts I/O.
async fn set_config_from_qr(&self, account_id: u32, qr_content: String) -> Result<()> {
let ctx = self.get_context(account_id).await?;
qr::set_config_from_qr(&ctx, &qr_content).await
@@ -1068,7 +1078,8 @@ impl CommandApi {
/// Set group name.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// all group members are informed by a special status message that is sent automatically by this function.
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
async fn set_chat_name(&self, account_id: u32, chat_id: u32, new_name: String) -> Result<()> {
@@ -1076,10 +1087,39 @@ impl CommandApi {
chat::set_chat_name(&ctx, ChatId::new(chat_id), &new_name).await
}
/// Set group or broadcast channel description.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
///
/// See also [`Self::get_chat_description`] / `getChatDescription()`.
async fn set_chat_description(
&self,
account_id: u32,
chat_id: u32,
description: String,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
chat::set_chat_description(&ctx, ChatId::new(chat_id), &description).await
}
/// Load the chat description from the database.
///
/// UIs show this in the profile page of the chat,
/// it is settable by [`Self::set_chat_description`] / `setChatDescription()`.
async fn get_chat_description(&self, account_id: u32, chat_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
chat::get_chat_description(&ctx, ChatId::new(chat_id)).await
}
/// Set group profile image.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// all group members are informed by a special status message that is sent automatically by this function.
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
///
@@ -1164,10 +1204,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 +1488,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,
@@ -2141,11 +2207,11 @@ impl CommandApi {
account_id: u32,
chat_id: u32,
place_call_info: String,
has_video_initially: bool,
has_video: bool,
) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
let msg_id = ctx
.place_outgoing_call(ChatId::new(chat_id), place_call_info, has_video_initially)
.place_outgoing_call(ChatId::new(chat_id), place_call_info, has_video)
.await?;
Ok(msg_id.to_u32())
}

View File

@@ -16,7 +16,7 @@ pub struct JsonrpcCallInfo {
pub sdp_offer: String,
/// True if the call is started as a video call.
pub has_video_initially: bool,
pub has_video: bool,
/// Call state.
///
@@ -30,12 +30,12 @@ impl JsonrpcCallInfo {
format!("Attempting to get call state of non-call message {msg_id}")
})?;
let sdp_offer = call_info.place_call_info.clone();
let has_video_initially = call_info.has_video_initially();
let has_video = call_info.has_video_initially();
let state = JsonrpcCallState::from_msg_id(context, msg_id).await?;
Ok(JsonrpcCallInfo {
sdp_offer,
has_video_initially,
has_video,
state,
})
}

View File

@@ -10,7 +10,6 @@ use serde::{Deserialize, Serialize};
use typescript_type_def::TypeDef;
use super::color_int_to_hex_string;
use super::contact::ContactObject;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
@@ -48,7 +47,6 @@ pub struct FullChat {
chat_type: JsonrpcChatType,
is_unpromoted: bool,
is_self_talk: bool,
contacts: Vec<ContactObject>,
contact_ids: Vec<u32>,
/// Contact IDs of the past chat members.
@@ -83,20 +81,6 @@ impl FullChat {
let contact_ids = get_chat_contacts(context, rust_chat_id).await?;
let past_contact_ids = get_past_chat_contacts(context, rust_chat_id).await?;
let mut contacts = Vec::with_capacity(contact_ids.len());
for contact_id in &contact_ids {
contacts.push(
ContactObject::try_from_dc_contact(
context,
Contact::get_by_id(context, *contact_id)
.await
.context("failed to load contact")?,
)
.await?,
)
}
let profile_image = match chat.get_profile_image(context).await? {
Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()),
None => None,
@@ -132,7 +116,6 @@ impl FullChat {
chat_type: chat.get_type().into(),
is_unpromoted: chat.is_unpromoted(),
is_self_talk: chat.is_self_talk(),
contacts,
contact_ids: contact_ids.iter().map(|id| id.to_u32()).collect(),
past_contact_ids: past_contact_ids.iter().map(|id| id.to_u32()).collect(),
color,
@@ -150,7 +133,6 @@ impl FullChat {
}
/// cheaper version of fullchat, omits:
/// - contacts
/// - contact_ids
/// - fresh_message_counter
/// - ephemeral_timer

View File

@@ -47,8 +47,7 @@ pub struct ContactObject {
///
/// - If `verifierId` != 0,
/// display text "Introduced by ..."
/// with the name and address of the contact
/// formatted by `name_and_addr`/`nameAndAddr`.
/// with the name of the contact.
/// Prefix the text by a green checkmark.
///
/// - If `verifierId` == 0 and `isVerified` != 0,

View File

@@ -92,6 +92,9 @@ pub struct MessageObject {
file: Option<String>,
file_mime: Option<String>,
/// The size of the file in bytes, if applicable.
/// If message is a pre-message, then this is the size of the file to be downloaded.
file_bytes: u64,
file_name: Option<String>,
@@ -385,6 +388,7 @@ impl From<download::DownloadState> for DownloadState {
pub enum SystemMessageType {
Unknown,
GroupNameChanged,
GroupDescriptionChanged,
GroupImageChanged,
MemberAddedToGroup,
MemberRemovedFromGroup,
@@ -437,6 +441,7 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
match system_message_type {
SystemMessage::Unknown => SystemMessageType::Unknown,
SystemMessage::GroupNameChanged => SystemMessageType::GroupNameChanged,
SystemMessage::GroupDescriptionChanged => SystemMessageType::GroupDescriptionChanged,
SystemMessage::GroupImageChanged => SystemMessageType::GroupImageChanged,
SystemMessage::MemberAddedToGroup => SystemMessageType::MemberAddedToGroup,
SystemMessage::MemberRemovedFromGroup => SystemMessageType::MemberRemovedFromGroup,

View File

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

View File

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

View File

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

View File

@@ -343,6 +343,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
addmember <contact-id>\n\
removemember <contact-id>\n\
groupname <name>\n\
groupdescription <description>\n\
groupimage <image>\n\
chatinfo\n\
sendlocations <seconds>\n\
@@ -770,6 +771,13 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Chat name set");
}
"groupdescription" => {
ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "Argument <description> missing.");
chat::set_chat_description(&context, sel_chat.as_ref().unwrap().get_id(), arg1).await?;
println!("Chat description set");
}
"groupimage" => {
ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "Argument <image> missing.");
@@ -1231,7 +1239,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"setqr" => {
ensure!(!arg1.is_empty(), "Argument <qr-content> missing.");
match set_config_from_qr(&context, arg1).await {
Ok(()) => println!("Config set from QR code, you can now call 'configure'"),
Ok(()) => eprintln!("Config set from the QR code."),
Err(err) => eprintln!("Cannot set config from QR code: {err:?}"),
}
}

View File

@@ -179,7 +179,7 @@ const DB_COMMANDS: [&str; 11] = [
"housekeeping",
];
const CHAT_COMMANDS: [&str; 39] = [
const CHAT_COMMANDS: [&str; 40] = [
"listchats",
"listarchived",
"start-realtime",
@@ -192,6 +192,7 @@ const CHAT_COMMANDS: [&str; 39] = [
"addmember",
"removemember",
"groupname",
"groupdescription",
"groupimage",
"chatinfo",
"sendlocations",

View File

@@ -2,6 +2,9 @@
RPC client connects to standalone Delta Chat RPC server `deltachat-rpc-server`
and provides asynchronous interface to it.
`rpc.start()` blocks until the server is initialized
and will raise an error if initialization fails
(e.g. if the accounts directory could not be used).
## Getting started

View File

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

View File

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

View File

@@ -44,8 +44,13 @@ class AttrDict(dict):
super().__setattr__(attr, val)
def _forever(_event: AttrDict) -> bool:
return False
def run_client_cli(
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
until: Callable[[AttrDict], bool] = _forever,
argv: Optional[list] = None,
**kwargs,
) -> None:
@@ -55,10 +60,11 @@ def run_client_cli(
"""
from .client import Client
_run_cli(Client, hooks, argv, **kwargs)
_run_cli(Client, until, hooks, argv, **kwargs)
def run_bot_cli(
until: Callable[[AttrDict], bool] = _forever,
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
argv: Optional[list] = None,
**kwargs,
@@ -69,11 +75,12 @@ def run_bot_cli(
"""
from .client import Bot
_run_cli(Bot, hooks, argv, **kwargs)
_run_cli(Bot, until, hooks, argv, **kwargs)
def _run_cli(
client_type: Type["Client"],
until: Callable[[AttrDict], bool] = _forever,
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
argv: Optional[list] = None,
**kwargs,
@@ -111,7 +118,7 @@ def _run_cli(
kwargs={"email": args.email, "password": args.password},
)
configure_thread.start()
client.run_forever()
client.run_until(until)
def extract_addr(text: str) -> str:

View File

@@ -14,6 +14,7 @@ from typing import (
from ._utils import (
AttrDict,
_forever,
parse_system_add_remove,
parse_system_image_changed,
parse_system_title_changed,
@@ -91,19 +92,28 @@ class Client:
def run_forever(self) -> None:
"""Process events forever."""
self.run_until(lambda _: False)
self.run_until(_forever)
def run_until(self, func: Callable[[AttrDict], bool]) -> AttrDict:
"""Process events until the given callable evaluates to True.
The callable should accept an AttrDict object representing the
last processed event. The event is returned when the callable
evaluates to True.
"""
"""Start the event processing loop."""
self.logger.debug("Listening to incoming events...")
if self.is_configured():
self.account.start_io()
self._process_messages() # Process old messages.
return self._process_events(until_func=func) # Loop over incoming events
def _process_events(
self,
until_func: Callable[[AttrDict], bool] = _forever,
until_event: EventType = False,
) -> AttrDict:
"""Process events until the given callable evaluates to True,
or until a certain event happens.
The until_func callable should accept an AttrDict object representing
the last processed event. The event is returned when the callable
evaluates to True.
"""
while True:
event = self.account.wait_for_event()
event["kind"] = EventType(event.kind)
@@ -112,10 +122,13 @@ class Client:
if event.kind == EventType.INCOMING_MSG:
self._process_messages()
stop = func(event)
stop = until_func(event)
if stop:
return event
if event.kind == until_event:
return event
def _on_event(self, event: AttrDict, filter_type: Type[EventFilter] = RawEvent) -> None:
for hook, evfilter in self._hooks.get(filter_type, []):
if evfilter.filter(event):

View File

@@ -44,6 +44,14 @@ class Message:
read_receipts = self._rpc.get_message_read_receipts(self.account.id, self.id)
return [AttrDict(read_receipt) for read_receipt in read_receipts]
def get_read_receipt_count(self) -> int:
"""
Returns count of read receipts on message.
This view count is meant as a feedback measure for the channel owner only.
"""
return self._rpc.get_message_read_receipt_count(self.account.id, self.id)
def get_reactions(self) -> Optional[AttrDict]:
"""Get message reactions."""
reactions = self._rpc.get_message_reactions(self.account.id, self.id)

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import os
import pathlib
import platform
@@ -21,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."
"""
@@ -53,13 +54,13 @@ class ACFactory:
def get_credentials(self) -> (str, str):
"""Generate new credentials for chatmail account."""
domain = os.getenv("CHATMAIL_DOMAIN")
domain = os.environ["CHATMAIL_DOMAIN"]
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
return f"{username}@{domain}", f"{username}${username}"
def get_account_qr(self):
"""Return "dcaccount:" QR code for testing chatmail relay."""
domain = os.getenv("CHATMAIL_DOMAIN")
domain = os.environ["CHATMAIL_DOMAIN"]
return f"dcaccount:{domain}"
@futuremethod
@@ -204,14 +205,13 @@ def log():
class Printer:
def section(self, msg: str) -> None:
print()
print("=" * 10, msg, "=" * 10)
logging.info("\n%s %s %s", "=" * 10, msg, "=" * 10)
def step(self, msg: str) -> None:
print("-" * 5, "step " + msg, "-" * 5)
logging.info("%s step %s %s", "-" * 5, msg, "-" * 5)
def indent(self, msg: str) -> None:
print(" " + msg)
logging.info(" " + msg)
return Printer()
@@ -261,7 +261,7 @@ def get_core_python_env(tmp_path_factory):
envs[core_version] = venv
python = find_path(venv, "python")
rpc_server_path = find_path(venv, "deltachat-rpc-server")
print(f"python={python}\nrpc_server={rpc_server_path}")
logging.info(f"Paths:\npython={python}\nrpc_server={rpc_server_path}")
return python, rpc_server_path
return get_versioned_venv

View File

@@ -54,10 +54,19 @@ class RpcMethod:
class Rpc:
"""RPC client."""
def __init__(self, accounts_dir: Optional[str] = None, rpc_server_path="deltachat-rpc-server", **kwargs):
def __init__(
self,
accounts_dir: Optional[str] = None,
rpc_server_path="deltachat-rpc-server",
_skip_ready_check=False,
**kwargs,
):
"""Initialize RPC client.
The 'kwargs' arguments will be passed to subprocess.Popen().
'_skip_ready_check' is for debugging/testing only,
e.g. when using an old server that doesn't send
ready/init_error notifications on startup.
"""
if accounts_dir:
kwargs["env"] = {
@@ -67,6 +76,7 @@ class Rpc:
self._kwargs = kwargs
self.rpc_server_path = rpc_server_path
self._skip_ready_check = _skip_ready_check
self.process: subprocess.Popen
self.id_iterator: Iterator[int]
self.event_queues: dict[int, Queue]
@@ -79,7 +89,13 @@ class Rpc:
self.events_thread: Thread
def start(self) -> None:
"""Start RPC server subprocess."""
"""Start RPC server subprocess and wait for successful initialization.
This method blocks until the RPC server sends a "ready" notification.
If the server fails to initialize
(e.g., due to an invalid accounts directory),
a JsonRpcError is raised with the error message provided by the server.
"""
popen_kwargs = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE}
if sys.version_info >= (3, 11):
# Prevent subprocess from capturing SIGINT.
@@ -90,6 +106,9 @@ class Rpc:
popen_kwargs.update(self._kwargs)
self.process = subprocess.Popen(self.rpc_server_path, **popen_kwargs)
self._wait_for_ready()
self.id_iterator = itertools.count(start=1)
self.event_queues = {}
self.request_results = {}
@@ -102,6 +121,44 @@ class Rpc:
self.events_thread = Thread(target=self.events_loop)
self.events_thread.start()
def _wait_for_ready(self) -> None:
"""Wait for "ready" or "init_error" notification from the server."""
if self._skip_ready_check:
return
# Read the first JSON-RPC notification which is
# "ready" (success) or "init_error" (e.g. bad accounts dir).
line = self.process.stdout.readline()
if not line:
return_code = self.process.wait()
if return_code != 0:
raise JsonRpcError(f"RPC server terminated with exit code {return_code}")
return
try:
status = json.loads(line)
except json.JSONDecodeError:
raise JsonRpcError(f"RPC server sent invalid initial message: {line.decode().strip()}") from None
if status.get("method") == "init_error":
error_msg = status.get("params", ["Unknown error"])[0]
raise JsonRpcError(f"RPC server initialization failed: {error_msg}")
if status.get("method") != "ready":
raise JsonRpcError(f"RPC server sent unexpected initial message: {line.decode().strip()}")
params = status.get("params", [{}])[0]
core_version = params.get("core_version", "unknown")
server_path = params.get("server_path", "unknown")
accounts_dir = params.get("accounts_dir", "unknown")
logging.info(
"RPC server ready. Core version: {}, Server path: {}, Accounts dir: {}".format(
core_version,
server_path,
accounts_dir,
),
)
def close(self) -> None:
"""Terminate RPC server process and wait until the reader loop finishes."""
self.closing = True
@@ -154,15 +211,15 @@ class Rpc:
def events_loop(self) -> None:
"""Request new events and distributes them between queues."""
try:
while True:
while events := self.get_next_event_batch():
for event in events:
account_id = event["contextId"]
queue = self.get_queue(account_id)
payload = event["event"]
logging.debug("account_id=%d got an event %s", account_id, payload)
queue.put(payload)
if self.closing:
return
event = self.get_next_event()
account_id = event["contextId"]
queue = self.get_queue(account_id)
event = event["event"]
logging.debug("account_id=%d got an event %s", account_id, event)
queue.put(event)
except Exception:
# Log an exception if the event loop dies.
logging.exception("Exception in the event loop")

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import imaplib
import io
import logging
import pathlib
import ssl
from contextlib import contextmanager
@@ -45,13 +46,13 @@ class DirectImap:
try:
self.conn.logout()
except (OSError, imaplib.IMAP4.abort):
print("Could not logout direct_imap conn")
logging.warning("Could not logout direct_imap conn")
def create_folder(self, foldername):
try:
self.conn.folder.create(foldername)
except errors.MailboxFolderCreateError as e:
print("Can't create", foldername, "probably it already exists:", str(e))
logging.warning(f"Cannot create '{foldername}', probably it already exists: {str(e)}")
def select_folder(self, foldername: str) -> tuple:
assert not self._idling
@@ -95,7 +96,7 @@ class DirectImap:
messages = self.get_unread_messages()
if messages:
res = self.conn.flag(messages, MailMessageFlags.SEEN, True)
print("marked seen:", messages, res)
logging.info(f"Marked seen: {messages} {res}")
def get_unread_cnt(self) -> int:
return len(self.get_unread_messages())

View File

@@ -10,15 +10,15 @@ def test_calls(acfactory) -> None:
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info, True)
outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info, has_video_initially=True)
assert outgoing_call_message.get_call_info().state.kind == "Alerting"
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
assert incoming_call_event.place_call_info == place_call_info
assert not incoming_call_event.has_video # Cannot be parsed as SDP, so false by default
assert incoming_call_event.has_video
incoming_call_message = Message(bob, incoming_call_event.msg_id)
assert incoming_call_message.get_call_info().state.kind == "Alerting"
assert not incoming_call_message.get_call_info().has_video
assert incoming_call_message.get_call_info().has_video
incoming_call_message.accept_incoming_call(accept_call_info)
assert incoming_call_message.get_call_info().sdp_offer == place_call_info
@@ -41,46 +41,38 @@ def test_video_call(acfactory) -> None:
#
# `s=` cannot be empty according to RFC 3264,
# so it is more clear as `s=-`.
place_call_info = """v=0\r
o=alice 2890844526 2890844526 IN IP6 2001:db8::3\r
s=-\r
c=IN IP6 2001:db8::3\r
t=0 0\r
a=group:BUNDLE foo bar\r
\r
m=audio 10000 RTP/AVP 0 8 97\r
b=AS:200\r
a=mid:foo\r
a=rtcp-mux\r
a=rtpmap:0 PCMU/8000\r
a=rtpmap:8 PCMA/8000\r
a=rtpmap:97 iLBC/8000\r
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r
\r
m=video 10002 RTP/AVP 31 32\r
b=AS:1000\r
a=mid:bar\r
a=rtcp-mux\r
a=rtpmap:31 H261/90000\r
a=rtpmap:32 MPV/90000\r
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r
"""
alice, bob = acfactory.get_online_accounts(2)
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.place_outgoing_call(place_call_info, True)
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
assert incoming_call_event.place_call_info == place_call_info
assert incoming_call_event.place_call_info == "offer"
assert incoming_call_event.has_video
incoming_call_message = Message(bob, incoming_call_event.msg_id)
assert incoming_call_message.get_call_info().has_video
def test_audio_call(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.place_outgoing_call("offer", has_video_initially=False)
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
assert incoming_call_event.place_call_info == "offer"
assert not incoming_call_event.has_video
incoming_call_message = Message(bob, incoming_call_event.msg_id)
assert not incoming_call_message.get_call_info().has_video
def test_ice_servers(acfactory) -> None:
alice = acfactory.get_online_account()
@@ -92,7 +84,7 @@ def test_no_contact_request_call(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer", True)
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
alice_chat_bob.send_text("Hello!")
# Notification for "Hello!" message should arrive
@@ -107,3 +99,48 @@ def test_no_contact_request_call(acfactory) -> None:
msg = bob.get_message_by_id(event.msg_id)
if msg.get_snapshot().text == "Hello!":
break
def test_who_can_call_me_nobody(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
# Bob sets "who can call me" to "nobody" (2)
bob.set_config("who_can_call_me", "2")
# Bob even accepts Alice in advance so the chat does not appear as contact request.
bob.create_chat(alice)
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
alice_chat_bob.send_text("Hello!")
# Notification for "Hello!" message should arrive
# without the call ringing.
while True:
event = bob.wait_for_event()
# There should be no incoming call notification.
assert event.kind != EventType.INCOMING_CALL
if event.kind == EventType.INCOMING_MSG:
msg = bob.get_message_by_id(event.msg_id)
if msg.get_snapshot().text == "Hello!":
break
def test_who_can_call_me_everybody(acfactory) -> None:
"""Test that if "who can call me" setting is set to "everybody", calls arrive even in contact request chats."""
alice, bob = acfactory.get_online_accounts(2)
# Bob sets "who can call me" to "nobody" (0)
bob.set_config("who_can_call_me", "0")
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
incoming_call_message = Message(bob, incoming_call_event.msg_id)
# Even with the call arriving, the chat is still in the contact request mode.
incoming_chat = incoming_call_message.get_snapshot().chat
assert incoming_chat.get_basic_snapshot().is_contact_request

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import base64
import os
from typing import TYPE_CHECKING
from deltachat_rpc_client import Account, EventType, const
@@ -129,7 +127,7 @@ def test_download_on_demand(acfactory: ACFactory) -> None:
msg.get_snapshot().chat.accept()
bob.get_chat_by_id(chat_id).send_message(
"Hello World, this message is bigger than 5 bytes",
html=base64.b64encode(os.urandom(300000)).decode("utf-8"),
file="../test-data/image/screenshot.jpg",
)
message = alice.wait_for_incoming_msg()

View File

@@ -8,7 +8,7 @@ from deltachat_rpc_client import DeltaChat, Rpc
def test_install_venv_and_use_other_core(tmp_path, get_core_python_env):
python, rpc_server_path = get_core_python_env("2.24.0")
subprocess.check_call([python, "-m", "pip", "install", "deltachat-rpc-server==2.24.0"])
rpc = Rpc(accounts_dir=tmp_path.joinpath("accounts"), rpc_server_path=rpc_server_path)
rpc = Rpc(accounts_dir=tmp_path.joinpath("accounts"), rpc_server_path=rpc_server_path, _skip_ready_check=True)
with rpc:
dc = DeltaChat(rpc)

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import pytest
from deltachat_rpc_client import EventType
from deltachat_rpc_client.const import DownloadState
from deltachat_rpc_client.rpc import JsonRpcError
@@ -37,8 +38,8 @@ def test_add_second_address(acfactory) -> None:
with pytest.raises(JsonRpcError):
account.set_config(option, "1")
with pytest.raises(JsonRpcError):
account.set_config("show_emails", "0")
# show_emails does not matter for multi-relay, can be set to anything
account.set_config("show_emails", "0")
@pytest.mark.parametrize("key", ["mvbox_move", "only_fetch_mvbox"])
@@ -57,8 +58,8 @@ def test_no_second_transport_with_mvbox(acfactory, key) -> None:
account.add_transport_from_qr(qr)
def test_no_second_transport_without_classic_emails(acfactory) -> None:
"""Test that second transport cannot be configured if classic emails are not fetched."""
def test_second_transport_without_classic_emails(acfactory) -> None:
"""Test that second transport can be configured if classic emails are not fetched."""
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
@@ -67,8 +68,7 @@ def test_no_second_transport_without_classic_emails(acfactory) -> None:
qr = acfactory.get_account_qr()
account.set_config("show_emails", "0")
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
account.add_transport_from_qr(qr)
def test_change_address(acfactory) -> None:
@@ -120,6 +120,33 @@ def test_change_address(acfactory) -> None:
assert sender_addr2 == new_alice_addr
def test_download_on_demand(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
alice.set_config("download_limit", "1")
alice.stop_io()
qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr)
alice.start_io()
alice.create_chat(bob)
chat_bob_alice = bob.create_chat(alice)
chat_bob_alice.send_message(file="../test-data/image/screenshot.jpg")
msg = alice.wait_for_incoming_msg()
snapshot = msg.get_snapshot()
assert snapshot.download_state == DownloadState.AVAILABLE
chat_id = snapshot.chat_id
# Actually the message isn't available yet. Wait somehow for the post-message to arrive.
chat_bob_alice.send_message("Now you can download my previous message")
alice.wait_for_incoming_msg()
alice._rpc.download_full_message(alice.id, msg.id)
for dstate in [DownloadState.IN_PROGRESS, DownloadState.DONE]:
event = alice.wait_for_event(EventType.MSGS_CHANGED)
assert event.chat_id == chat_id
assert event.msg_id == msg.id
assert msg.get_snapshot().download_state == dstate
@pytest.mark.parametrize("is_chatmail", ["0", "1"])
def test_mvbox_move_first_transport(acfactory, is_chatmail) -> None:
"""Test that mvbox_move is disabled by default even for non-chatmail accounts.
@@ -161,6 +188,13 @@ def test_reconfigure_transport(acfactory) -> None:
def test_transport_synchronization(acfactory, log) -> None:
"""Test synchronization of transports between devices."""
def wait_for_io_started(ac):
while True:
ev = ac.wait_for_event(EventType.INFO)
if "scheduler is running" in ev.msg:
return
ac1, ac2 = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone()
ac1_clone.bring_online()
@@ -169,11 +203,13 @@ def test_transport_synchronization(acfactory, log) -> None:
ac1.add_transport_from_qr(qr)
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1_clone)
assert len(ac1.list_transports()) == 2
assert len(ac1_clone.list_transports()) == 2
ac1_clone.add_transport_from_qr(qr)
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1)
assert len(ac1.list_transports()) == 3
assert len(ac1_clone.list_transports()) == 3
@@ -183,6 +219,7 @@ def test_transport_synchronization(acfactory, log) -> None:
ac1_clone.delete_transport(transport2["addr"])
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1)
[transport1, transport3] = ac1.list_transports()
log.section("ac1 changes the primary transport")
@@ -196,6 +233,7 @@ def test_transport_synchronization(acfactory, log) -> None:
ac1.delete_transport(transport1["addr"])
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1_clone)
[transport3] = ac1_clone.list_transports()
assert transport3["addr"] == addr3
assert ac1_clone.get_config("configured_addr") == addr3
@@ -207,6 +245,38 @@ def test_transport_synchronization(acfactory, log) -> None:
assert ac1_clone.wait_for_incoming_msg().get_snapshot().text == "Hello!"
def test_transport_sync_new_as_primary(acfactory, log) -> None:
"""Test synchronization of new transport as primary between devices."""
ac1, bob = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone()
ac1_clone.bring_online()
qr = acfactory.get_account_qr()
ac1.add_transport_from_qr(qr)
ac1_transports = ac1.list_transports()
assert len(ac1_transports) == 2
[transport1, transport2] = ac1_transports
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
assert len(ac1_clone.list_transports()) == 2
assert ac1_clone.get_config("configured_addr") == transport1["addr"]
log.section("ac1 changes the primary transport")
ac1.set_config("configured_addr", transport2["addr"])
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)

View File

@@ -170,6 +170,7 @@ def test_qr_securejoin_broadcast(acfactory, all_devices_online):
member_added_msg = chat_msgs.pop(0).get_snapshot()
if inviter_side:
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
assert member_added_msg.info_contact_id == contact_snapshot.id
else:
assert member_added_msg.text == "You joined the channel."
assert member_added_msg.is_info
@@ -696,6 +697,6 @@ def test_withdraw_securejoin_qr(acfactory):
event = alice.wait_for_event()
if (
event.kind == EventType.WARNING
and "Ignoring vg-request-with-auth message because of invalid auth code." in event.msg
and "Ignoring RequestWithAuth message because of invalid auth code." in event.msg
):
break

View File

@@ -10,10 +10,10 @@ from unittest.mock import MagicMock
import pytest
from deltachat_rpc_client import Contact, EventType, Message, events
from deltachat_rpc_client import EventType, events
from deltachat_rpc_client.const import DownloadState, MessageState
from deltachat_rpc_client.pytestplugin import E2EE_INFO_MSGS
from deltachat_rpc_client.rpc import JsonRpcError
from deltachat_rpc_client.rpc import JsonRpcError, Rpc
def test_system_info(rpc) -> None:
@@ -333,7 +333,7 @@ def test_receive_imf_failure(acfactory) -> None:
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
bob.set_config("fail_on_receiving_full_msg", "1")
bob.set_config("simulate_receive_imf_error", "1")
alice_chat_bob.send_text("Hello!")
event = bob.wait_for_event(EventType.MSGS_CHANGED)
assert event.chat_id == bob.get_device_chat().id
@@ -343,18 +343,17 @@ def test_receive_imf_failure(acfactory) -> None:
version = bob.get_info()["deltachat_core_version"]
assert (
snapshot.text == "❌ Failed to receive a message:"
" Condition failed: `!context.get_config_bool(Config::FailOnReceivingFullMsg).await?`."
" Condition failed: `!context.get_config_bool(Config::SimulateReceiveImfError).await?`."
f" Core version {version}."
" Please report this bug to delta@merlinux.eu or https://support.delta.chat/."
)
# The failed message doesn't break the IMAP loop.
bob.set_config("fail_on_receiving_full_msg", "0")
bob.set_config("simulate_receive_imf_error", "0")
alice_chat_bob.send_text("Hello again!")
message = bob.wait_for_incoming_msg()
snapshot = message.get_snapshot()
assert snapshot.text == "Hello again!"
assert snapshot.download_state == DownloadState.DONE
assert snapshot.error is None
@@ -372,17 +371,48 @@ def test_selfavatar_sync(acfactory, data, log) -> None:
alice.set_config("selfavatar", image)
avatar_config = alice.get_config("selfavatar")
avatar_hash = os.path.basename(avatar_config)
print("Info: avatar hash is ", avatar_hash)
logging.info(f"Avatar hash is {avatar_hash}")
log.section("First device receives avatar change")
alice2.wait_for_event(EventType.SELFAVATAR_CHANGED)
avatar_config2 = alice2.get_config("selfavatar")
avatar_hash2 = os.path.basename(avatar_config2)
print("Info: avatar hash on second device is ", avatar_hash2)
logging.info(f"Avatar hash on second device is {avatar_hash2}")
assert avatar_hash == avatar_hash2
assert avatar_config != avatar_config2
def test_dont_move_sync_msgs(acfactory, direct_imap):
addr, password = acfactory.get_credentials()
ac1 = acfactory.get_unconfigured_account()
ac1.set_config("bcc_self", "1")
ac1.set_config("fix_is_chatmail", "1")
ac1.add_or_update_transport({"addr": addr, "password": password})
ac1.start_io()
ac1_direct_imap = direct_imap(ac1)
# Sync messages may also be sent during configuration.
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)
# 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:
alice, bob = acfactory.get_online_accounts(2)
alice2 = alice.clone()
@@ -635,6 +665,24 @@ def test_openrpc_command_line() -> None:
assert "methods" in openrpc
def test_early_failure(tmp_path) -> None:
"""Test that Rpc.start() raises on invalid accounts directories."""
# A file instead of a directory.
file_path = tmp_path / "not_a_dir"
file_path.write_text("I am a file, not a directory")
rpc = Rpc(accounts_dir=str(file_path))
with pytest.raises(JsonRpcError, match="initialization failed"):
rpc.start()
# A non-empty directory that is not a deltachat accounts directory.
non_dc_dir = tmp_path / "invalid_dir"
non_dc_dir.mkdir()
(non_dc_dir / "some_file").write_text("content")
rpc = Rpc(accounts_dir=str(non_dc_dir))
with pytest.raises(JsonRpcError, match="initialization failed"):
rpc.start()
def test_provider_info(rpc) -> None:
account_id = rpc.add_account()
@@ -687,60 +735,6 @@ def test_mdn_doesnt_break_autocrypt(acfactory) -> None:
assert snapshot.show_padlock
def test_reaction_to_partially_fetched_msg(acfactory, tmp_path):
"""See https://github.com/deltachat/deltachat-core-rust/issues/3688 "Partially downloaded
messages are received out of order".
If the Inbox contains X small messages followed by Y large messages followed by Z small
messages, Delta Chat first downloaded a batch of X+Z messages, and then a batch of Y messages.
This bug was discovered by @Simon-Laux while testing reactions PR #3644 and can be reproduced
with online test as follows:
- Bob enables download limit and goes offline.
- Alice sends a large message to Bob and reacts to this message with a thumbs-up.
- Bob goes online
- Bob first processes a reaction message and throws it away because there is no corresponding
message, then processes a partially downloaded message.
- As a result, Bob does not see a reaction
"""
download_limit = 300000
ac1, ac2 = acfactory.get_online_accounts(2)
ac1_addr = ac1.get_config("addr")
chat = ac1.create_chat(ac2)
ac2.set_config("download_limit", str(download_limit))
ac2.stop_io()
logging.info("sending small+large messages from ac1 to ac2")
msgs = []
msgs.append(chat.send_text("hi"))
path = tmp_path / "large"
path.write_bytes(os.urandom(download_limit + 1))
msgs.append(chat.send_file(str(path)))
for m in msgs:
m.wait_until_delivered()
logging.info("sending a reaction to the large message from ac1 to ac2")
# TODO: Find the reason of an occasional message reordering on the server (so that the reaction
# has a lower UID than the previous message). W/a is to sleep for some time to let the reaction
# have a later INTERNALDATE.
time.sleep(1.1)
react_str = "\N{THUMBS UP SIGN}"
msgs.append(msgs[-1].send_reaction(react_str))
msgs[-1].wait_until_delivered()
ac2.start_io()
logging.info("wait for ac2 to receive a reaction")
msg2 = Message(ac2, ac2.wait_for_reactions_changed().msg_id)
assert msg2.get_sender_contact().get_snapshot().address == ac1_addr
assert msg2.get_snapshot().download_state == DownloadState.AVAILABLE
reactions = msg2.get_reactions()
contacts = [Contact(ac2, int(i)) for i in reactions.reactions_by_contact]
assert len(contacts) == 1
assert contacts[0].get_snapshot().address == ac1_addr
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
@pytest.mark.parametrize("n_accounts", [3, 2])
def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
download_limit = 300000
@@ -767,14 +761,159 @@ def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
path = tmp_path / "large"
path.write_bytes(os.urandom(download_limit + 1))
n_done = 0
for i in range(10):
logging.info("Sending message %s", i)
alice_group.send_file(str(path))
snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.download_state == DownloadState.AVAILABLE
if snapshot.download_state == DownloadState.DONE:
n_done += 1
# Work around lost and reordered pre-messages.
assert n_done <= 1
else:
assert snapshot.download_state == DownloadState.AVAILABLE
assert snapshot.chat == bob_group
def test_download_small_msg_first(acfactory, tmp_path):
download_limit = 70000
alice, bob0 = acfactory.get_online_accounts(2)
bob1 = bob0.clone()
bob1.set_config("download_limit", str(download_limit))
chat = alice.create_chat(bob0)
path = tmp_path / "large_enough"
path.write_bytes(os.urandom(download_limit + 1))
# Less than 140K, so sent w/o a pre-message.
chat.send_file(str(path))
chat.send_text("hi")
bob0.create_chat(alice)
assert bob0.wait_for_incoming_msg().get_snapshot().text == ""
assert bob0.wait_for_incoming_msg().get_snapshot().text == "hi"
bob1.start_io()
bob1.create_chat(alice)
assert bob1.wait_for_incoming_msg().get_snapshot().text == "hi"
assert bob1.wait_for_incoming_msg().get_snapshot().text == ""
@pytest.mark.parametrize("delete_chat", [False, True])
def test_delete_available_msg(acfactory, tmp_path, direct_imap, delete_chat):
"""
Tests `DownloadState.AVAILABLE` message deletion on the receiver side.
Also tests pre- and post-message deletion on the sender side.
"""
# Min. UI setting as of v2.35
download_limit = 163840
alice, bob = acfactory.get_online_accounts(2)
bob.set_config("download_limit", str(download_limit))
# Avoid immediate deletion from the server
alice.set_config("bcc_self", "1")
bob.set_config("bcc_self", "1")
chat_alice = alice.create_chat(bob)
path = tmp_path / "large"
path.write_bytes(os.urandom(download_limit + 1))
msg_alice = chat_alice.send_file(str(path))
msg_bob = bob.wait_for_incoming_msg()
msg_bob_snapshot = msg_bob.get_snapshot()
assert msg_bob_snapshot.download_state == DownloadState.AVAILABLE
chat_bob = bob.get_chat_by_id(msg_bob_snapshot.chat_id)
# Avoid DeleteMessages sync message
bob.set_config("bcc_self", "0")
if delete_chat:
chat_bob.delete()
else:
bob.delete_messages([msg_bob])
alice.wait_for_event(EventType.SMTP_MESSAGE_SENT)
alice.wait_for_event(EventType.SMTP_MESSAGE_SENT)
alice.set_config("bcc_self", "0")
if delete_chat:
chat_alice.delete()
else:
alice.delete_messages([msg_alice])
for acc in [bob, alice]:
if not delete_chat:
acc.wait_for_event(EventType.MSG_DELETED)
acc_direct_imap = direct_imap(acc)
# Messages may be deleted separately
while True:
acc.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
while True:
event = acc.wait_for_event()
if event.kind == EventType.INFO and "Close/expunge succeeded." in event.msg:
break
if len(acc_direct_imap.get_all_messages()) == 0:
break
def test_delete_fully_downloaded_msg(acfactory, tmp_path, direct_imap):
alice, bob = acfactory.get_online_accounts(2)
# Avoid immediate deletion from the server
bob.set_config("bcc_self", "1")
chat_alice = alice.create_chat(bob)
path = tmp_path / "large"
# Big enough to be sent with a pre-message
path.write_bytes(os.urandom(300000))
chat_alice.send_file(str(path))
msg = bob.wait_for_incoming_msg()
msg_snapshot = msg.get_snapshot()
assert msg_snapshot.download_state == DownloadState.AVAILABLE
msgs_changed_event = bob.wait_for_msgs_changed_event()
assert msgs_changed_event.msg_id == msg.id
msg_snapshot = msg.get_snapshot()
assert msg_snapshot.download_state == DownloadState.DONE
bob_direct_imap = direct_imap(bob)
assert len(bob_direct_imap.get_all_messages()) == 2
# Avoid DeleteMessages sync message
bob.set_config("bcc_self", "0")
bob.delete_messages([msg])
bob.wait_for_event(EventType.MSG_DELETED)
# Messages may be deleted separately
while True:
bob.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
while True:
event = bob.wait_for_event()
if event.kind == EventType.INFO and "Close/expunge succeeded." in event.msg:
break
if len(bob_direct_imap.get_all_messages()) == 0:
break
def test_imap_autodelete_fully_downloaded_msg(acfactory, tmp_path, direct_imap):
alice, bob = acfactory.get_online_accounts(2)
chat_alice = alice.create_chat(bob)
path = tmp_path / "large"
# Big enough to be sent with a pre-message
path.write_bytes(os.urandom(300000))
chat_alice.send_file(str(path))
msg = bob.wait_for_incoming_msg()
msg_snapshot = msg.get_snapshot()
assert msg_snapshot.download_state == DownloadState.AVAILABLE
msgs_changed_event = bob.wait_for_msgs_changed_event()
assert msgs_changed_event.msg_id == msg.id
msg_snapshot = msg.get_snapshot()
assert msg_snapshot.download_state == DownloadState.DONE
bob_direct_imap = direct_imap(bob)
# Messages may be deleted separately
while True:
if len(bob_direct_imap.get_all_messages()) == 0:
break
bob.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
while True:
event = bob.wait_for_event()
if event.kind == EventType.INFO and "Close/expunge succeeded." in event.msg:
break
def test_markseen_contact_request(acfactory):
"""
Test that seen status is synchronized for contact request messages
@@ -798,6 +937,47 @@ def test_markseen_contact_request(acfactory):
assert message2.get_snapshot().state == MessageState.IN_SEEN
@pytest.mark.parametrize("team_profile", [True, False])
def test_no_markseen_in_team_profile(team_profile, acfactory):
"""
Test that seen status is synchronized iff `team_profile` isn't set.
"""
alice, bob = acfactory.get_online_accounts(2)
if team_profile:
bob.set_config("team_profile", "1")
# Bob sets up a second device.
bob2 = bob.clone()
bob2.start_io()
alice_chat_bob = alice.create_chat(bob)
bob_chat_alice = bob.create_chat(alice)
bob2.create_chat(alice)
alice_chat_bob.send_text("Hello Bob!")
message = bob.wait_for_incoming_msg()
message2 = bob2.wait_for_incoming_msg()
assert message2.get_snapshot().state == MessageState.IN_FRESH
message.mark_seen()
# Send a message and wait until it arrives
# in order to wait until Bob2 gets the markseen message.
# This also tests that outgoing messages
# don't mark preceeding messages as seen in team profiles.
bob_chat_alice.send_text("Outgoing message")
while True:
outgoing = bob2.wait_for_msg(EventType.MSGS_CHANGED)
if outgoing.id != 0:
break
assert outgoing.get_snapshot().text == "Outgoing message"
if team_profile:
assert message2.get_snapshot().state == MessageState.IN_FRESH
else:
assert message2.get_snapshot().state == MessageState.IN_SEEN
def test_read_receipt(acfactory):
"""
Test sending a read receipt and ensure it is attributed to the correct contact.
@@ -817,6 +997,9 @@ def test_read_receipt(acfactory):
assert len(read_receipts) == 1
assert read_receipts[0].contact_id == alice_contact_bob.id
read_receipt_cnt = read_msg.get_read_receipt_count()
assert read_receipt_cnt == 1
def test_get_http_response(acfactory):
alice = acfactory.new_configured_account()
@@ -1020,6 +1203,30 @@ def test_leave_broadcast(acfactory, all_devices_online):
check_account(bob2, bob2.create_contact(alice), inviter_side=False)
def test_leave_and_delete_group(acfactory, log):
alice, bob = acfactory.get_online_accounts(2)
log.section("Alice creates a group")
alice_chat = alice.create_group("Group")
alice_chat.add_contact(bob)
assert len(alice_chat.get_contacts()) == 2 # Alice and Bob
alice_chat.send_text("hello")
log.section("Bob sees the group, and leaves and deletes it")
msg = bob.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
msg.chat.accept()
msg.chat.leave()
# Bob deletes the chat. This must not prevent the leave message from being sent.
msg.chat.delete()
log.section("Alice receives the delete message")
# After Bob left, only Alice will be left in the group:
while len(alice_chat.get_contacts()) != 1:
alice.wait_for_event(EventType.CHAT_MODIFIED)
def test_immediate_autodelete(acfactory, direct_imap, log):
ac1, ac2 = acfactory.get_online_accounts(2)
@@ -1152,3 +1359,23 @@ def test_synchronize_member_list_on_group_rejoin(acfactory, log):
assert chat.num_contacts() == 2
assert msg.get_snapshot().chat.num_contacts() == 2
def test_large_message(acfactory) -> None:
"""
Test sending large message without download limit set,
so it is sent with pre-message but downloaded without user interaction.
"""
alice, bob = acfactory.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_message(
"Hello World, this message is bigger than 5 bytes",
file="../test-data/image/screenshot.jpg",
)
msg = bob.wait_for_incoming_msg()
msgs_changed_event = bob.wait_for_msgs_changed_event()
assert msg.id == msgs_changed_event.msg_id
snapshot = msg.get_snapshot()
assert snapshot.text == "Hello World, this message is bigger than 5 bytes"

View File

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

View File

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

View File

@@ -24,6 +24,14 @@ use yerpc::{RpcClient, RpcSession};
#[tokio::main(flavor = "multi_thread")]
async fn main() {
// Logs from `log` crate and traces from `tracing` crate
// are configurable with `RUST_LOG` environment variable
// and go to stderr to avoid interfering with JSON-RPC using stdout.
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let r = main_impl().await;
// From tokio documentation:
// "For technical reasons, stdin is implemented by using an ordinary blocking read on a separate
@@ -64,22 +72,7 @@ async fn main_impl() -> Result<()> {
#[cfg(target_family = "unix")]
let mut sigterm = signal_unix::signal(signal_unix::SignalKind::terminate())?;
// Logs from `log` crate and traces from `tracing` crate
// are configurable with `RUST_LOG` environment variable
// and go to stderr to avoid interfering with JSON-RPC using stdout.
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let path = std::env::var("DC_ACCOUNTS_PATH").unwrap_or_else(|_| "accounts".to_string());
log::info!("Starting with accounts directory `{path}`.");
let writable = true;
let accounts = Accounts::new(PathBuf::from(&path), writable).await?;
log::info!("Creating JSON-RPC API.");
let accounts = Arc::new(RwLock::new(accounts));
let state = CommandApi::from_arc(accounts.clone()).await;
let (accounts, state) = init_accounts_and_report_status().await?;
let (client, mut out_receiver) = RpcClient::new();
let session = RpcSession::new(client.clone(), state.clone());
@@ -160,3 +153,41 @@ async fn main_impl() -> Result<()> {
Ok(())
}
async fn init_accounts_and_report_status() -> Result<(Arc<RwLock<Accounts>>, CommandApi)> {
let path = std::env::var("DC_ACCOUNTS_PATH").unwrap_or_else(|_| "accounts".to_string());
log::info!("Starting with accounts directory `{path}`.");
let path = PathBuf::from(&path);
match Accounts::new(path.clone(), true).await {
Ok(accounts) => {
log::info!("Creating JSON-RPC API.");
let accounts = Arc::new(RwLock::new(accounts));
let state = CommandApi::from_arc(accounts.clone()).await;
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"jsonrpc": "2.0",
"method": "ready",
"params": [{
"core_version": DC_VERSION_STR,
"server_path": env::current_exe()?.display().to_string(),
"accounts_dir": path.display().to_string(),
}]
}))?
);
Ok((accounts, state))
}
Err(err) => {
let error_msg = format!("{err:#}");
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"jsonrpc": "2.0",
"method": "init_error",
"params": [error_msg]
}))?
);
Err(err)
}
}
}

View File

@@ -16,7 +16,7 @@ ignore = [
# Unmaintained rustls-pemfile
# It is a transitive dependency of iroh 0.35.0,
# this should be fixed by upgrading to iroh 1.0 once it is released.
"RUSTSEC-2025-0134"
"RUSTSEC-2025-0134",
]
[bans]
@@ -31,11 +31,10 @@ skip = [
{ name = "derive_more", version = "1.0.0" },
{ name = "event-listener", version = "2.5.3" },
{ name = "getrandom", version = "0.2.12" },
{ name = "hashbrown", version = "0.14.5" },
{ name = "heck", version = "0.4.1" },
{ name = "http", version = "0.2.12" },
{ name = "linux-raw-sys", version = "0.4.14" },
{ name = "lru", version = "0.12.3" },
{ name = "lru", version = "0.12.5" },
{ name = "netlink-packet-route", version = "0.17.1" },
{ name = "nom", version = "7.1.3" },
{ name = "rand_chacha", version = "0.3.1" },

View File

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

View File

@@ -1,7 +1,6 @@
import os
import queue
import sys
import base64
from datetime import datetime, timezone
import pytest
@@ -221,72 +220,6 @@ def test_webxdc_huge_update(acfactory, data, lp):
assert update["payload"] == payload
def test_webxdc_download_on_demand(acfactory, data, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
acfactory.introduce_each_other([ac1, ac2])
chat = acfactory.get_accepted_chat(ac1, ac2)
msg1 = Message.new_empty(ac1, "webxdc")
msg1.set_text("message1")
msg1.set_file(data.get_path("webxdc/minimal.xdc"))
msg1 = chat.send_msg(msg1)
assert msg1.is_webxdc()
assert msg1.filename
msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.is_webxdc()
lp.sec("ac2 sets download limit")
ac2.set_config("download_limit", "100")
assert msg1.send_status_update({"payload": base64.b64encode(os.urandom(300000))}, "some test data")
ac2_update = ac2._evtracker.wait_next_incoming_message()
assert ac2_update.download_state == dc.const.DC_DOWNLOAD_AVAILABLE
assert not msg2.get_status_updates()
ac2_update.download_full()
ac2._evtracker.get_matching("DC_EVENT_WEBXDC_STATUS_UPDATE")
assert msg2.get_status_updates()
# Get a event notifying that the message disappeared from the chat.
msgs_changed_event = ac2._evtracker.get_matching("DC_EVENT_MSGS_CHANGED")
assert msgs_changed_event.data1 == msg2.chat.id
assert msgs_changed_event.data2 == 0
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_dont_move_sync_msgs(acfactory):
ac1 = acfactory.new_online_configuring_account(bcc_self=True, sync_msgs=True, fix_is_chatmail=True)
acfactory.bring_accounts_online()
ac1.direct_imap.select_folder("Inbox")
# Sync messages may also be sent during the configuration.
inbox_msg_cnt = len(ac1.direct_imap.get_all_messages())
ac1.set_config("displayname", "Alice")
ac1._evtracker.get_matching("DC_EVENT_MSG_DELIVERED")
ac1.set_config("displayname", "Bob")
ac1._evtracker.get_matching("DC_EVENT_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
def test_forward_messages(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat = ac1.create_chat(ac2)
@@ -402,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()
@@ -561,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()
@@ -590,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)
@@ -1005,7 +932,6 @@ def test_set_get_group_image(acfactory, data, lp):
def test_connectivity(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1._evtracker.wait_for_connectivity(dc.const.DC_CONNECTIVITY_CONNECTED)

View File

@@ -258,9 +258,6 @@ class TestOfflineChat:
with pytest.raises(ValueError):
ac1.set_stock_translation(dc.const.DC_STR_FILE, "xyz %1$s")
ac1._evtracker.get_matching("DC_EVENT_WARNING")
with pytest.raises(ValueError):
ac1.set_stock_translation(dc.const.DC_STR_CONTACT_NOT_VERIFIED, "xyz %2$s")
ac1._evtracker.get_matching("DC_EVENT_WARNING")
with pytest.raises(ValueError):
ac1.set_stock_translation(500, "xyz %1$s")
ac1._evtracker.get_matching("DC_EVENT_WARNING")

View File

@@ -1 +1 @@
2026-01-03
2026-02-17

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

View File

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

View File

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

View File

@@ -60,8 +60,18 @@ impl Accounts {
if writable && !dir.exists() {
Accounts::create(&dir).await?;
}
let events = Events::new();
Accounts::open(events, dir, writable).await
}
Accounts::open(dir, writable).await
/// Loads or creates an accounts folder at the given `dir`.
/// Uses an existing events channel.
pub async fn new_with_events(dir: PathBuf, writable: bool, events: Events) -> Result<Self> {
if writable && !dir.exists() {
Accounts::create(&dir).await?;
}
Accounts::open(events, dir, writable).await
}
/// Get the ID used to log events.
@@ -85,14 +95,14 @@ impl Accounts {
/// Opens an existing accounts structure. Will error if the folder doesn't exist,
/// no account exists and no config exists.
async fn open(dir: PathBuf, writable: bool) -> Result<Self> {
async fn open(events: Events, dir: PathBuf, writable: bool) -> Result<Self> {
ensure!(dir.exists(), "directory does not exist");
let config_file = dir.join(CONFIG_NAME);
ensure!(config_file.exists(), "{config_file:?} does not exist");
let config = Config::from_file(config_file, writable).await?;
let events = Events::new();
let stockstrings = StockStrings::new();
let push_subscriber = PushSubscriber::new();
let accounts = config
@@ -576,6 +586,7 @@ impl Config {
}
#[cfg(not(target_os = "ios"))]
#[expect(clippy::arithmetic_side_effects)]
async fn create_lock_task(dir: PathBuf) -> Result<Option<JoinHandle<anyhow::Result<()>>>> {
let lockfile = dir.join(LOCKFILE_NAME);
let mut lock = fd_lock::RwLock::new(fs::File::create(lockfile).await?);
@@ -742,6 +753,7 @@ impl Config {
}
/// Creates a new account in the account manager directory.
#[expect(clippy::arithmetic_side_effects)]
async fn new_account(&mut self) -> Result<AccountConfig> {
let id = {
let id = self.inner.next_id;
@@ -831,6 +843,7 @@ impl Config {
///
/// Without this workaround removing account may fail on Windows with an error
/// "The process cannot access the file because it is being used by another process. (os error 32)".
#[expect(clippy::arithmetic_side_effects)]
async fn try_many_times<F, Fut, T>(f: F) -> std::result::Result<(), T>
where
F: Fn() -> Fut,

View File

@@ -73,6 +73,7 @@ impl fmt::Display for Aheader {
let keydata = self.public_key.to_base64().chars().enumerate().fold(
String::new(),
|mut res, (i, c)| {
#[expect(clippy::arithmetic_side_effects)]
if i % 78 == 78 - "keydata=".len() {
res.push(' ')
}
@@ -110,9 +111,9 @@ impl FromStr for Aheader {
SignedPublicKey::from_base64(&raw).context("autocrypt key cannot be decoded")
})
.and_then(|key| {
key.verify()
key.verify_bindings()
.and(Ok(key))
.context("autocrypt key cannot be verified")
.context("Autocrypt key cannot be verified")
})?;
let prefer_encrypt = attributes

View File

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

View File

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

View File

@@ -4,6 +4,7 @@
//! This means, the "Call ID" is a "Message ID" - similar to Webxdc IDs.
use crate::chat::ChatIdBlocked;
use crate::chat::{Chat, ChatId, send_msg};
use crate::config::Config;
use crate::constants::{Blocked, Chattype};
use crate::contact::ContactId;
use crate::context::{Context, WeakContext};
@@ -14,11 +15,12 @@ use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::net::dns::lookup_host_with_cache;
use crate::param::Param;
use crate::stock_str;
use crate::tools::{normalize_text, time};
use anyhow::{Context as _, Result, ensure};
use sdp::SessionDescription;
use deltachat_derive::{FromSql, ToSql};
use num_traits::FromPrimitive;
use serde::Serialize;
use std::io::Cursor;
use std::str::FromStr;
use std::time::Duration;
use tokio::task;
@@ -77,6 +79,7 @@ impl CallInfo {
}
fn remaining_ring_seconds(&self) -> i64 {
#[expect(clippy::arithmetic_side_effects)]
let remaining_seconds = self.msg.timestamp_sent + RINGING_SECONDS - time();
remaining_seconds.clamp(0, RINGING_SECONDS)
}
@@ -101,10 +104,14 @@ impl CallInfo {
};
if self.is_incoming() {
self.update_text(context, &format!("Incoming call\n{duration}"))
let incoming_call_str =
stock_str::incoming_call(context, self.has_video_initially()).await;
self.update_text(context, &format!("{incoming_call_str}\n{duration}"))
.await?;
} else {
self.update_text(context, &format!("Outgoing call\n{duration}"))
let outgoing_call_str =
stock_str::outgoing_call(context, self.has_video_initially()).await;
self.update_text(context, &format!("{outgoing_call_str}\n{duration}"))
.await?;
}
Ok(())
@@ -125,7 +132,10 @@ impl CallInfo {
/// Returns true if the call is started as a video call.
pub fn has_video_initially(&self) -> bool {
self.msg.param.get_bool(Param::CallHasVideoInitially).unwrap_or(false)
self.msg
.param
.get_bool(Param::WebrtcHasVideoInitially)
.unwrap_or(false)
}
/// Returns true if the call is missed
@@ -166,6 +176,7 @@ impl CallInfo {
}
/// Returns call duration in seconds.
#[expect(clippy::arithmetic_side_effects)]
pub fn duration_seconds(&self) -> i64 {
if let (Some(start), Some(end)) = (
self.msg.param.get_i64(CALL_ACCEPTED_TIMESTAMP),
@@ -196,13 +207,15 @@ impl Context {
);
ensure!(!chat.is_self_talk(), "Cannot call self");
let outgoing_call_str = stock_str::outgoing_call(self, has_video_initially).await;
let mut call = Message {
viewtype: Viewtype::Call,
text: "Outgoing call".into(),
text: outgoing_call_str,
..Default::default()
};
call.param.set(Param::WebrtcRoom, &place_call_info);
call.param.set_int(Param::CallHasVideoInitially, has_video_initially as i32);
call.param
.set_int(Param::WebrtcHasVideoInitially, has_video_initially.into());
call.id = send_msg(self, chat_id, &mut call).await?;
let wait = RINGING_SECONDS;
@@ -270,10 +283,12 @@ impl Context {
if !call.is_accepted() {
if call.is_incoming() {
call.mark_as_ended(self).await?;
call.update_text(self, "Declined call").await?;
let declined_call_str = stock_str::declined_call(self).await;
call.update_text(self, &declined_call_str).await?;
} else {
call.mark_as_canceled(self).await?;
call.update_text(self, "Canceled call").await?;
let canceled_call_str = stock_str::canceled_call(self).await;
call.update_text(self, &canceled_call_str).await?;
}
} else {
call.mark_as_ended(self).await?;
@@ -315,10 +330,12 @@ impl Context {
if !call.is_accepted() && !call.is_ended() {
if call.is_incoming() {
call.mark_as_canceled(&context).await?;
call.update_text(&context, "Missed call").await?;
let missed_call_str = stock_str::missed_call(&context).await;
call.update_text(&context, &missed_call_str).await?;
} else {
call.mark_as_ended(&context).await?;
call.update_text(&context, "Canceled call").await?;
let canceled_call_str = stock_str::canceled_call(&context).await;
call.update_text(&context, &canceled_call_str).await?;
}
context.emit_msgs_changed(call.msg.chat_id, call_id);
context.emit_event(EventType::CallEnded {
@@ -343,31 +360,42 @@ impl Context {
if call.is_incoming() {
if call.is_stale() {
call.update_text(self, "Missed call").await?;
let missed_call_str = stock_str::missed_call(self).await;
call.update_text(self, &missed_call_str).await?;
self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call
} else {
call.update_text(self, "Incoming call").await?;
let incoming_call_str =
stock_str::incoming_call(self, call.has_video_initially()).await;
call.update_text(self, &incoming_call_str).await?;
self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified
if let Some(chat_id_blocked) =
ChatIdBlocked::lookup_by_contact(self, from_id).await?
{
match chat_id_blocked.blocked {
Blocked::Not => {
self.emit_event(EventType::IncomingCall {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
place_call_info: call.place_call_info.to_string(),
has_video: call.has_video_initially(),
});
}
Blocked::Yes | Blocked::Request => {
// Do not notify about incoming calls
// from contact requests and blocked contacts.
//
// User can still access the call and accept it
// via the chat in case of contact requests.
}
}
let can_call_me = match who_can_call_me(self).await? {
WhoCanCallMe::Contacts => ChatIdBlocked::lookup_by_contact(self, from_id)
.await?
.is_some_and(|chat_id_blocked| {
match chat_id_blocked.blocked {
Blocked::Not => true,
Blocked::Yes | Blocked::Request => {
// Do not notify about incoming calls
// from contact requests and blocked contacts.
//
// User can still access the call and accept it
// via the chat in case of contact requests.
false
}
}
}),
WhoCanCallMe::Everybody => ChatIdBlocked::lookup_by_contact(self, from_id)
.await?
.is_none_or(|chat_id_blocked| chat_id_blocked.blocked != Blocked::Yes),
WhoCanCallMe::Nobody => false,
};
if can_call_me {
self.emit_event(EventType::IncomingCall {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
place_call_info: call.place_call_info.to_string(),
has_video: call.has_video_initially(),
});
}
let wait = call.remaining_ring_seconds();
let context = self.get_weak_context();
@@ -378,7 +406,9 @@ impl Context {
));
}
} else {
call.update_text(self, "Outgoing call").await?;
let outgoing_call_str =
stock_str::outgoing_call(self, call.has_video_initially()).await;
call.update_text(self, &outgoing_call_str).await?;
self.emit_msgs_changed(call.msg.chat_id, call_id);
}
} else {
@@ -428,19 +458,23 @@ impl Context {
if call.is_incoming() {
if from_id == ContactId::SELF {
call.mark_as_ended(self).await?;
call.update_text(self, "Declined call").await?;
let declined_call_str = stock_str::declined_call(self).await;
call.update_text(self, &declined_call_str).await?;
} else {
call.mark_as_canceled(self).await?;
call.update_text(self, "Missed call").await?;
let missed_call_str = stock_str::missed_call(self).await;
call.update_text(self, &missed_call_str).await?;
}
} else {
// outgoing
if from_id == ContactId::SELF {
call.mark_as_canceled(self).await?;
call.update_text(self, "Canceled call").await?;
let canceled_call_str = stock_str::canceled_call(self).await;
call.update_text(self, &canceled_call_str).await?;
} else {
call.mark_as_ended(self).await?;
call.update_text(self, "Declined call").await?;
let declined_call_str = stock_str::declined_call(self).await;
call.update_text(self, &declined_call_str).await?;
}
}
} else {
@@ -593,33 +627,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=`
@@ -629,20 +637,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
@@ -650,36 +745,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.
@@ -693,11 +770,39 @@ 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())
}
}
/// "Who can call me" config options.
#[derive(
Debug, Default, Display, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive, FromSql, ToSql,
)]
#[repr(u8)]
pub enum WhoCanCallMe {
/// Everybody can call me if they are not blocked.
///
/// This includes contact requests.
Everybody = 0,
/// Every contact who is not blocked and not a contact request, can call.
#[default]
Contacts = 1,
/// Nobody can call me.
Nobody = 2,
}
/// Returns currently configuration of the "who can call me" option.
async fn who_can_call_me(context: &Context) -> Result<WhoCanCallMe> {
let who_can_call_me =
WhoCanCallMe::from_i32(context.get_config_int(Config::WhoCanCallMe).await?)
.unwrap_or_default();
Ok(who_can_call_me)
}
#[cfg(test)]
mod calls_tests;

View File

@@ -2,7 +2,7 @@ use super::*;
use crate::chat::forward_msgs;
use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::receive_imf::{receive_imf, receive_imf_from_inbox};
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager};
struct CallSetup {
@@ -25,13 +25,6 @@ async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()>
const PLACE_INFO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP4 host.anywhere.com\r\ns=-\r\nc=IN IP4 host.anywhere.com\r\nt=0 0\r\nm=audio 62986 RTP/AVP 0 4 18\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=rtpmap:18 G729/8000\r\na=inactive\r\n";
const ACCEPT_INFO: &str = "v=0\r\no=bob 2890844730 2890844731 IN IP4 host.example.com\r\ns=\r\nc=IN IP4 host.example.com\r\nt=0 0\r\nm=audio 54344 RTP/AVP 0 4\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=inactive\r\n";
/// Example from <https://datatracker.ietf.org/doc/rfc9143/>
/// with `s= ` replaced with `s=-`.
///
/// `s=` cannot be empty according to RFC 3264,
/// so it is more clear as `s=-`.
const PLACE_INFO_VIDEO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP6 2001:db8::3\r\ns=-\r\nc=IN IP6 2001:db8::3\r\nt=0 0\r\na=group:BUNDLE foo bar\r\n\r\nm=audio 10000 RTP/AVP 0 8 97\r\nb=AS:200\r\na=mid:foo\r\na=rtcp-mux\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:97 iLBC/8000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n\r\nm=video 10002 RTP/AVP 31 32\r\nb=AS:1000\r\na=mid:bar\r\na=rtcp-mux\r\na=rtpmap:31 H261/90000\r\na=rtpmap:32 MPV/90000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n";
async fn setup_call() -> Result<CallSetup> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
@@ -69,7 +62,7 @@ async fn setup_call() -> Result<CallSetup> {
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_eq!(info.has_video_initially(), true);
assert_text(t, m.id, "Outgoing call").await?;
assert_text(t, m.id, "Outgoing video call").await?;
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
}
@@ -91,7 +84,7 @@ async fn setup_call() -> Result<CallSetup> {
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_eq!(info.has_video_initially(), true);
assert_text(t, m.id, "Incoming call").await?;
assert_text(t, m.id, "Incoming video call").await?;
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
}
@@ -122,7 +115,7 @@ async fn accept_call() -> Result<CallSetup> {
// Bob accepts the incoming call
bob.accept_incoming_call(bob_call.id, ACCEPT_INFO.to_string())
.await?;
assert_text(&bob, bob_call.id, "Incoming call").await?;
assert_text(&bob, bob_call.id, "Incoming video call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.await;
@@ -136,7 +129,7 @@ async fn accept_call() -> Result<CallSetup> {
assert_eq!(call_state(&bob, bob_call.id).await?, CallState::Active);
bob2.recv_msg_trash(&sent2).await;
assert_text(&bob, bob_call.id, "Incoming call").await?;
assert_text(&bob, bob_call.id, "Incoming video call").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.await;
@@ -149,7 +142,7 @@ async fn accept_call() -> Result<CallSetup> {
// Alice receives the acceptance message
alice.recv_msg_trash(&sent2).await;
assert_text(&alice, alice_call.id, "Outgoing call").await?;
assert_text(&alice, alice_call.id, "Outgoing video call").await?;
let ev = alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
@@ -171,7 +164,7 @@ async fn accept_call() -> Result<CallSetup> {
assert_eq!(call_state(&alice, alice_call.id).await?, CallState::Active);
alice2.recv_msg_trash(&sent2).await;
assert_text(&alice2, alice2_call.id, "Outgoing call").await?;
assert_text(&alice2, alice2_call.id, "Outgoing video call").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
@@ -210,7 +203,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
// Bob has accepted the call and also ends it
bob.end_call(bob_call.id).await?;
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -221,7 +214,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
));
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
assert_text(&bob2, bob2_call.id, "Incoming video call\n<1 minute").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -232,7 +225,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
// Alice receives the ending message
alice.recv_msg_trash(&sent3).await;
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
assert_text(&alice, alice_call.id, "Outgoing video call\n<1 minute").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -243,7 +236,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
));
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
assert_text(&alice2, alice2_call.id, "Outgoing video call\n<1 minute").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -273,7 +266,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
// Bob has accepted the call but Alice ends it
alice.end_call(alice_call.id).await?;
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
assert_text(&alice, alice_call.id, "Outgoing video call\n<1 minute").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -285,7 +278,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
));
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
assert_text(&alice2, alice2_call.id, "Outgoing video call\n<1 minute").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -297,7 +290,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
// Bob receives the ending message
bob.recv_msg_trash(&sent3).await;
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -307,7 +300,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
));
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
assert_text(&bob2, bob2_call.id, "Incoming video call\n<1 minute").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -427,7 +420,7 @@ async fn test_caller_cancels_call() -> Result<()> {
// Test that message summary says it is a missed call.
let bob_call_msg = Message::load_from_db(&bob, bob_call.id).await?;
let summary = bob_call_msg.get_summary(&bob, None).await?;
assert_eq!(summary.text, "📞 Missed call");
assert_eq!(summary.text, "🎥 Missed call");
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Missed call").await?;
@@ -605,65 +598,3 @@ async fn test_end_text_call() -> Result<()> {
Ok(())
}
/// Tests that partially downloaded "call ended"
/// messages are not processed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_partial_calls() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let seen = false;
// The messages in the test
// have no `Date` on purpose,
// so they are treated as new.
let received_call = receive_imf(
alice,
b"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <first@example.net>\n\
Chat-Version: 1.0\n\
Chat-Content: call\n\
Chat-Webrtc-Room: YWFhYWFhYWFhCg==\n\
\n\
Hello, this is a call\n",
seen,
)
.await?
.unwrap();
assert_eq!(received_call.msg_ids.len(), 1);
let call_msg = Message::load_from_db(alice, received_call.msg_ids[0])
.await
.unwrap();
assert_eq!(call_msg.viewtype, Viewtype::Call);
assert_eq!(call_state(alice, call_msg.id).await?, CallState::Alerting);
let imf_raw = b"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <second@example.net>\n\
In-Reply-To: <first@example.net>\n\
Chat-Version: 1.0\n\
Chat-Content: call-ended\n\
\n\
Call ended\n";
receive_imf_from_inbox(
alice,
"second@example.net",
imf_raw,
seen,
Some(imf_raw.len().try_into().unwrap()),
)
.await?;
// The call is still not ended.
assert_eq!(call_state(alice, call_msg.id).await?, CallState::Alerting);
// Fully downloading the message ends the call.
receive_imf_from_inbox(alice, "second@example.net", imf_raw, seen, None)
.await
.context("Failed to fully download end call message")?;
assert_eq!(call_state(alice, call_msg.id).await?, CallState::Missed);
Ok(())
}

View File

@@ -12,12 +12,14 @@ use std::time::Duration;
use anyhow::{Context as _, Result, anyhow, bail, ensure};
use chrono::TimeZone;
use deltachat_contact_tools::{ContactAddress, sanitize_bidi_characters, sanitize_single_line};
use humansize::{BINARY, format_size};
use mail_builder::mime::MimePart;
use serde::{Deserialize, Serialize};
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::{
@@ -27,7 +29,9 @@ use crate::constants::{
use crate::contact::{self, Contact, ContactId, Origin};
use crate::context::Context;
use crate::debug_logging::maybe_set_logging_xdc;
use crate::download::DownloadState;
use crate::download::{
DownloadState, PRE_MSG_ATTACHMENT_SIZE_THRESHOLD, PRE_MSG_SIZE_WARNING_THRESHOLD,
};
use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers};
use crate::events::EventType;
use crate::key::self_fingerprint;
@@ -35,11 +39,11 @@ use crate::location;
use crate::log::{LogExt, warn};
use crate::logged_debug_assert;
use crate::message::{self, Message, MessageState, MsgId, Viewtype};
use crate::mimefactory::MimeFactory;
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::{
@@ -48,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;
@@ -615,7 +618,6 @@ impl ChatId {
);
let chat = Chat::load_from_db(context, self).await?;
let delete_msgs_target = context.get_delete_msgs_target().await?;
let sync_id = match sync {
Nosync => None,
Sync => chat.get_sync_id(context).await?,
@@ -625,18 +627,26 @@ impl ChatId {
.sql
.transaction(|transaction| {
transaction.execute(
"UPDATE imap SET target=? WHERE rfc724_mid IN (SELECT rfc724_mid FROM msgs WHERE chat_id=?)",
(delete_msgs_target, self,),
"UPDATE imap SET target='' WHERE rfc724_mid IN (SELECT rfc724_mid FROM msgs WHERE chat_id=? AND rfc724_mid!='')",
(self,),
)?;
transaction.execute(
"DELETE FROM smtp WHERE msg_id IN (SELECT id FROM msgs WHERE chat_id=?)",
"UPDATE imap SET target='' WHERE rfc724_mid IN (SELECT pre_rfc724_mid FROM msgs WHERE chat_id=? AND pre_rfc724_mid!='')",
(self,),
)?;
transaction.execute(
"DELETE FROM msgs_mdns WHERE msg_id IN (SELECT id FROM msgs WHERE chat_id=?)",
(self,),
)?;
transaction.execute("DELETE FROM msgs WHERE chat_id=?", (self,))?;
// If you change which information is preserved here, also change `MsgId::trash()`
// and other places it references.
transaction.execute(
"
INSERT OR REPLACE INTO msgs (id, rfc724_mid, pre_rfc724_mid, timestamp, chat_id, deleted)
SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
",
(DC_CHAT_ID_TRASH, self),
)?;
transaction.execute("DELETE FROM chats_contacts WHERE chat_id=?", (self,))?;
transaction.execute("DELETE FROM chats WHERE id=?", (self,))?;
Ok(())
@@ -931,6 +941,7 @@ impl ChatId {
/// Jaccard similarity coefficient is used to estimate similarity of chat member sets.
///
/// Chat is considered active if something was posted there within the last 42 days.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_similar_chat_ids(self, context: &Context) -> Result<Vec<(ChatId, f64)>> {
// Count number of common members in this and other chats.
let intersection = context
@@ -1135,13 +1146,14 @@ impl ChatId {
/// prefer plaintext emails.
///
/// To get more verbose summary for a contact, including its key fingerprint, use [`Contact::get_encrinfo`].
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_encryption_info(self, context: &Context) -> Result<String> {
let chat = Chat::load_from_db(context, self).await?;
if !chat.is_encrypted(context).await? {
return Ok(stock_str::encr_none(context).await);
}
let mut ret = stock_str::e2e_available(context).await + "\n";
let mut ret = stock_str::messages_e2e_encrypted(context).await + "\n";
for &contact_id in get_chat_contacts(context, self)
.await?
@@ -1720,6 +1732,7 @@ impl Chat {
///
/// If `update_msg_id` is set, that record is reused;
/// if `update_msg_id` is None, a new record is created.
#[expect(clippy::arithmetic_side_effects)]
async fn prepare_msg_raw(
&mut self,
context: &Context,
@@ -1753,10 +1766,11 @@ impl Chat {
} else if matches!(self.typ, Chattype::Group | Chattype::OutBroadcast)
&& self.param.get_int(Param::Unpromoted).unwrap_or_default() == 1
{
msg.param.set_int(Param::AttachGroupImage, 1);
msg.param.set_int(Param::AttachChatAvatarAndDescription, 1);
self.param
.remove(Param::Unpromoted)
.set_i64(Param::GroupNameTimestamp, msg.timestamp_sort);
.set_i64(Param::GroupNameTimestamp, msg.timestamp_sort)
.set_i64(Param::GroupDescriptionTimestamp, msg.timestamp_sort);
self.update_param(context).await?;
// TODO: Remove this compat code needed because Core <= v1.143:
// - doesn't accept synchronization of QR code tokens for unpromoted groups, so we also
@@ -1875,11 +1889,7 @@ impl Chat {
let (msg_text, was_truncated) = truncate_msg_text(context, msg.text.clone()).await?;
let new_mime_headers = if msg.has_html() {
if msg.param.exists(Param::Forwarded) {
msg.get_id().get_html(context).await?
} else {
msg.param.get(Param::SendHtml).map(|s| s.to_string())
}
msg.param.get(Param::SendHtml).map(|s| s.to_string())
} else {
None
};
@@ -2120,10 +2130,11 @@ pub(crate) async fn sync(context: &Context, id: SyncId, action: SyncAction) -> R
}
/// Whether the chat is pinned or archived.
#[derive(Debug, Copy, Eq, PartialEq, Clone, Serialize, Deserialize, EnumIter)]
#[derive(Debug, Copy, Eq, PartialEq, Clone, Serialize, Deserialize, EnumIter, Default)]
#[repr(i8)]
pub enum ChatVisibility {
/// Chat is neither archived nor pinned.
#[default]
Normal = 0,
/// Chat is archived.
@@ -2736,6 +2747,60 @@ async fn prepare_send_msg(
Ok(row_ids)
}
/// Renders the Message or splits it into Pre- and Post-Message.
///
/// Pre-Message is a small message with metadata which announces a larger Post-Message.
/// Post-Messages are not downloaded in the background.
///
/// If pre-message is not nessesary, this returns `None` as the 0th value.
async fn render_mime_message_and_pre_message(
context: &Context,
msg: &mut Message,
mimefactory: MimeFactory,
) -> Result<(Option<RenderedEmail>, RenderedEmail)> {
let needs_pre_message = msg.viewtype.has_file()
&& mimefactory.will_be_encrypted() // unencrypted is likely email, we don't want to spam by sending multiple messages
&& msg
.get_filebytes(context)
.await?
.context("filebytes not available, even though message has attachment")?
> PRE_MSG_ATTACHMENT_SIZE_THRESHOLD;
if needs_pre_message {
info!(
context,
"Message {} is large and will be split into pre- and post-messages.", msg.id,
);
let mut mimefactory_post_msg = mimefactory.clone();
mimefactory_post_msg.set_as_post_message();
let rendered_msg = mimefactory_post_msg
.render(context)
.await
.context("Failed to render post-message")?;
let mut mimefactory_pre_msg = mimefactory;
mimefactory_pre_msg.set_as_pre_message_for(&rendered_msg);
let rendered_pre_msg = mimefactory_pre_msg
.render(context)
.await
.context("pre-message failed to render")?;
if rendered_pre_msg.message.len() > PRE_MSG_SIZE_WARNING_THRESHOLD {
warn!(
context,
"Pre-message for message {} is larger than expected: {}.",
msg.id,
rendered_pre_msg.message.len()
);
}
Ok((Some(rendered_pre_msg), rendered_msg))
} else {
Ok((None, mimefactory.render(context).await?))
}
}
/// Constructs jobs for sending a message and inserts them into the `smtp` table.
///
/// Updates the message `GuaranteeE2ee` parameter and persists it
@@ -2746,9 +2811,18 @@ async fn prepare_send_msg(
///
/// The caller has to interrupt SMTP loop or otherwise process new rows.
pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Vec<i64>> {
if msg.param.get_cmd() == SystemMessage::GroupNameChanged {
let cmd = msg.param.get_cmd();
if cmd == SystemMessage::GroupNameChanged || cmd == SystemMessage::GroupDescriptionChanged {
msg.chat_id
.update_timestamp(context, Param::GroupNameTimestamp, msg.timestamp_sort)
.update_timestamp(
context,
if cmd == SystemMessage::GroupNameChanged {
Param::GroupNameTimestamp
} else {
Param::GroupDescriptionTimestamp
},
msg.timestamp_sort,
)
.await?;
}
@@ -2769,24 +2843,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
{
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
@@ -2807,13 +2868,32 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
return Ok(Vec::new());
}
let rendered_msg = match mimefactory.render(context).await {
Ok(res) => Ok(res),
Err(err) => {
message::set_msg_failed(context, msg, &err.to_string()).await?;
Err(err)
}
}?;
let (rendered_pre_msg, rendered_msg) =
match render_mime_message_and_pre_message(context, msg, mimefactory).await {
Ok(res) => Ok(res),
Err(err) => {
message::set_msg_failed(context, msg, &err.to_string()).await?;
Err(err)
}
}?;
if let (post_msg, Some(pre_msg)) = (&rendered_msg, &rendered_pre_msg) {
info!(
context,
"Message {} sizes: pre-message: {}; post-message: {}.",
msg.id,
format_size(pre_msg.message.len(), BINARY),
format_size(post_msg.message.len(), BINARY),
);
msg.pre_rfc724_mid = pre_msg.rfc724_mid.clone();
} else {
info!(
context,
"Message {} will be sent in one shot (no pre- and post-message). Size: {}.",
msg.id,
format_size(rendered_msg.message.len(), BINARY),
);
}
if needs_encryption && !rendered_msg.is_encrypted {
/* unrecoverable */
@@ -2852,8 +2932,13 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
context
.sql
.execute(
"UPDATE msgs SET subject=?, param=? WHERE id=?",
(&msg.subject, msg.param.to_string(), msg.id),
"UPDATE msgs SET pre_rfc724_mid=?, subject=?, param=? WHERE id=?",
(
&msg.pre_rfc724_mid,
&msg.subject,
msg.param.to_string(),
msg.id,
),
)
.await?;
@@ -2867,19 +2952,27 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
(),
)?;
}
let mut stmt = t.prepare(
"INSERT INTO smtp (rfc724_mid, recipients, mime, msg_id)
VALUES (?1, ?2, ?3, ?4)",
)?;
for recipients_chunk in recipients.chunks(chunk_size) {
let recipients_chunk = recipients_chunk.join(" ");
let row_id = t.execute(
"INSERT INTO smtp (rfc724_mid, recipients, mime, msg_id) \
VALUES (?1, ?2, ?3, ?4)",
(
&rendered_msg.rfc724_mid,
recipients_chunk,
&rendered_msg.message,
if let Some(pre_msg) = &rendered_pre_msg {
let row_id = stmt.execute((
&pre_msg.rfc724_mid,
&recipients_chunk,
&pre_msg.message,
msg.id,
),
)?;
))?;
row_ids.push(row_id.try_into()?);
}
let row_id = stmt.execute((
&rendered_msg.rfc724_mid,
&recipients_chunk,
&rendered_msg.message,
msg.id,
))?;
row_ids.push(row_id.try_into()?);
}
Ok(row_ids)
@@ -2905,6 +2998,7 @@ pub async fn send_text_msg(
}
/// Sends chat members a request to edit the given message's text.
#[expect(clippy::arithmetic_side_effects)]
pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: String) -> Result<()> {
let mut original_msg = Message::load_from_db(context, msg_id).await?;
ensure!(
@@ -3010,6 +3104,7 @@ pub async fn get_chat_msgs(context: &Context, chat_id: ChatId) -> Result<Vec<Cha
}
/// Returns messages belonging to the chat according to the given options.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_chat_msgs_ex(
context: &Context,
chat_id: ChatId,
@@ -3121,6 +3216,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<()> {
@@ -3182,7 +3307,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=?
@@ -3190,16 +3315,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(());
}
@@ -3221,6 +3341,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(());
@@ -3773,9 +3897,11 @@ pub(crate) async fn add_contact_to_chat_ex(
let sync_qr_code_tokens;
if from_handshake && chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 {
let smeared_time = smeared_time(context);
chat.param
.remove(Param::Unpromoted)
.set_i64(Param::GroupNameTimestamp, smeared_time(context));
.set_i64(Param::GroupNameTimestamp, smeared_time)
.set_i64(Param::GroupDescriptionTimestamp, smeared_time);
chat.update_param(context).await?;
sync_qr_code_tokens = true;
} else {
@@ -3857,6 +3983,7 @@ pub(crate) async fn add_contact_to_chat_ex(
/// This function does not check if the avatar is set.
/// If avatar is not set and this function returns `true`,
/// a `Chat-User-Avatar: 0` header should be sent to reset the avatar.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn shall_attach_selfavatar(context: &Context, chat_id: ChatId) -> Result<bool> {
let timestamp_some_days_ago = time() - DC_RESEND_USER_AVATAR_DAYS * 24 * 60 * 60;
let needs_attach = context
@@ -4003,7 +4130,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?;
@@ -4078,7 +4205,108 @@ async fn send_member_removal_msg(
send_msg(context, chat.id, &mut msg).await
}
/// Sets group or mailing list chat name.
/// Set group or broadcast channel description.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
///
/// See also [`get_chat_description`]
pub async fn set_chat_description(
context: &Context,
chat_id: ChatId,
new_description: &str,
) -> Result<()> {
set_chat_description_ex(context, Sync, chat_id, new_description).await
}
async fn set_chat_description_ex(
context: &Context,
mut sync: sync::Sync,
chat_id: ChatId,
new_description: &str,
) -> Result<()> {
let new_description = sanitize_bidi_characters(new_description.trim());
ensure!(!chat_id.is_special(), "Invalid chat ID");
let chat = Chat::load_from_db(context, chat_id).await?;
ensure!(
chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast,
"Can only set description for groups / broadcasts"
);
ensure!(
!chat.grpid.is_empty(),
"Cannot set description for ad hoc groups"
);
if !chat.is_self_in_chat(context).await? {
context.emit_event(EventType::ErrorSelfNotInGroup(
"Cannot set chat description; self not in group".into(),
));
bail!("Cannot set chat description; self not in group");
}
let old_description = get_chat_description(context, chat_id).await?;
if old_description == new_description {
return Ok(());
}
context
.sql
.execute(
"INSERT OR REPLACE INTO chats_descriptions(chat_id, description) VALUES(?, ?)",
(chat_id, &new_description),
)
.await?;
if chat.is_promoted() {
let mut msg = Message::new(Viewtype::Text);
msg.text =
"[Chat description changed. To see this and other new features, please update the app]"
.to_string();
msg.param.set_cmd(SystemMessage::GroupDescriptionChanged);
msg.id = send_msg(context, chat_id, &mut msg).await?;
context.emit_msgs_changed(chat_id, msg.id);
sync = Nosync;
}
context.emit_event(EventType::ChatModified(chat_id));
if sync.into() {
chat.sync(context, SyncAction::SetDescription(new_description))
.await
.log_err(context)
.ok();
}
Ok(())
}
/// Load the chat description from the database.
///
/// UIs show this in the profile page of the chat,
/// it is settable by [`set_chat_description`]
pub async fn get_chat_description(context: &Context, chat_id: ChatId) -> Result<String> {
let description = context
.sql
.query_get_value(
"SELECT description FROM chats_descriptions WHERE chat_id=?",
(chat_id,),
)
.await?
.unwrap_or_default();
Ok(description)
}
/// Sets group, mailing list, or broadcast channel chat name.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
pub async fn set_chat_name(context: &Context, chat_id: ChatId, new_name: &str) -> Result<()> {
rename_ex(context, Sync, chat_id, new_name).await
}
@@ -4212,6 +4440,7 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
}
/// Forwards multiple messages to a chat in another context.
#[expect(clippy::arithmetic_side_effects)]
pub async fn forward_msgs_2ctx(
ctx_src: &Context,
msg_ids: &[MsgId],
@@ -4253,16 +4482,30 @@ pub async fn forward_msgs_2ctx(
msg.param = Params::new();
if msg.get_viewtype() != Viewtype::Sticker {
let forwarded_msg_id = match ctx_src.blobdir == ctx_dst.blobdir {
true => src_msg_id,
false => MsgId::new_unset(),
};
msg.param
.set_int(Param::Forwarded, src_msg_id.to_u32() as i32);
.set_int(Param::Forwarded, forwarded_msg_id.to_u32() as i32);
}
if msg.get_viewtype() == Viewtype::Call {
msg.viewtype = Viewtype::Text;
}
msg.text += &msg.additional_text;
let param = &mut param;
msg.param.steal(param, Param::File);
// When forwarding between different accounts, blob files must be physically copied
// because each account has its own blob directory.
if ctx_src.blobdir == ctx_dst.blobdir {
msg.param.steal(param, Param::File);
} else if let Some(src_path) = param.get_file_path(ctx_src)? {
let new_blob = BlobObject::create_and_deduplicate(ctx_dst, &src_path, &src_path)
.context("Failed to copy blob file to destination account")?;
msg.param.set(Param::File, new_blob.as_name());
}
msg.param.steal(param, Param::Filename);
msg.param.steal(param, Param::Width);
msg.param.steal(param, Param::Height);
@@ -4271,6 +4514,9 @@ pub async fn forward_msgs_2ctx(
msg.param.steal(param, Param::ProtectQuote);
msg.param.steal(param, Param::Quote);
msg.param.steal(param, Param::Summary1);
if msg.has_html() {
msg.set_html(src_msg_id.get_html(ctx_src).await?);
}
msg.in_reply_to = None;
// do not leak data as group names; a default subject is generated by mimefactory
@@ -4325,6 +4571,7 @@ pub async fn save_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
/// the copy contains a reference to the original message
/// as well as to the original chat in case the original message gets deleted.
/// Returns data needed to add a `SaveMessage` sync item.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn save_copy_in_self_talk(
context: &Context,
src_msg_id: MsgId,
@@ -4337,12 +4584,16 @@ pub(crate) async fn save_copy_in_self_talk(
msg.param.remove(Param::WebxdcDocumentTimestamp);
msg.param.remove(Param::WebxdcSummary);
msg.param.remove(Param::WebxdcSummaryTimestamp);
msg.param.remove(Param::PostMessageFileBytes);
msg.param.remove(Param::PostMessageViewtype);
msg.text += &msg.additional_text;
if !msg.original_msg_id.is_unset() {
bail!("message already saved.");
}
let copy_fields = "from_id, to_id, timestamp_rcvd, type, txt,
let copy_fields = "from_id, to_id, timestamp_rcvd, type,
mime_modified, mime_headers, mime_compressed, mime_in_reply_to, subject, msgrmsg";
let row_id = context
.sql
@@ -4350,7 +4601,7 @@ pub(crate) async fn save_copy_in_self_talk(
&format!(
"INSERT INTO msgs ({copy_fields},
timestamp_sent,
chat_id, rfc724_mid, state, timestamp, param, starred)
txt, chat_id, rfc724_mid, state, timestamp, param, starred)
SELECT {copy_fields},
-- Outgoing messages on originating device
-- have timestamp_sent == 0.
@@ -4358,10 +4609,11 @@ pub(crate) async fn save_copy_in_self_talk(
-- so UIs display the same timestamp
-- for saved and original message.
IIF(timestamp_sent == 0, timestamp, timestamp_sent),
?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?
FROM msgs WHERE id=?;"
),
(
msg.text,
dest_chat_id,
dest_rfc724_mid,
if msg.from_id == ContactId::SELF {
@@ -4498,6 +4750,7 @@ pub(crate) async fn get_chat_id_by_grpid(
///
/// Optional `label` can be provided to ensure that message is added only once.
/// If `important` is true, a notification will be sent.
#[expect(clippy::arithmetic_side_effects)]
pub async fn add_device_msg_with_importance(
context: &Context,
label: Option<&str>,
@@ -4821,12 +5074,20 @@ async fn set_contacts_by_fingerprints(
context
.sql
.transaction(move |transaction| {
transaction.execute("DELETE FROM chats_contacts WHERE chat_id=?", (id,))?;
// For broadcast channels, we only add members,
// because we don't use the membership consistency algorithm,
// and are using sync messages as a basic way to ensure consistency between devices.
// For groups, we also remove members,
// because the sync message is used in order to sync unpromoted groups.
if chat.typ != Chattype::OutBroadcast {
transaction.execute("DELETE FROM chats_contacts WHERE chat_id=?", (id,))?;
}
// We do not care about `add_timestamp` column
// because timestamps are not used for broadcast channels.
let mut statement = transaction
.prepare("INSERT INTO chats_contacts (chat_id, contact_id) VALUES (?, ?)")?;
let mut statement = transaction.prepare(
"INSERT OR IGNORE INTO chats_contacts (chat_id, contact_id) VALUES (?, ?)",
)?;
for contact_id in &contacts {
statement.execute((id, contact_id))?;
}
@@ -4876,6 +5137,7 @@ pub(crate) enum SyncAction {
///
/// The list is a list of pairs of fingerprint and address.
SetPgpContacts(Vec<(String, String)>),
SetDescription(String),
Delete,
}
@@ -4974,6 +5236,9 @@ impl Context {
Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request."))
}
SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await,
SyncAction::SetDescription(to) => {
set_chat_description_ex(self, Nosync, chat_id, to).await
}
SyncAction::SetContacts(addrs) => set_contacts_by_addrs(self, chat_id, addrs).await,
SyncAction::SetPgpContacts(fingerprint_addrs) => {
set_contacts_by_fingerprints(self, chat_id, fingerprint_addrs).await

View File

@@ -1,6 +1,7 @@
use std::sync::Arc;
use super::*;
use crate::Event;
use crate::chatlist::get_archived_cnt;
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS};
use crate::ephemeral::Timer;
@@ -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;
@@ -2862,6 +2953,123 @@ async fn test_broadcast_multidev() -> Result<()> {
Ok(())
}
/// Test that, if the broadcast channel owner has multiple devices
/// and they have diverging views on the recipients,
/// it is synced when sending a member-addition message.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_recipients_sync1() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice1 = &tcm.alice().await;
let alice2 = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
for a in &[alice1, alice2] {
a.set_config_bool(Config::SyncMsgs, true).await?;
}
// Alice1 creates a broadcast and adds Bob, but for some reason
// (e.g. because alice2 runs an older version of DC),
// Alice2 doesn't get to know about it
let a1_broadcast_id = create_broadcast(alice1, "Channel".to_string()).await?;
alice1.send_sync_msg().await.unwrap();
alice1.pop_sent_msg().await;
let qr = get_securejoin_qr(alice1, Some(a1_broadcast_id))
.await
.unwrap();
tcm.exec_securejoin_qr(bob, alice1, &qr).await;
// The first sync message got lost, so, alice2 doesn't know about the channel now
sync(alice1, alice2).await;
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
assert!(a2_chatlist.is_empty());
// Alice1 adds Charlie to the broadcast channel,
// and now, Alice2 receives the messages
join_securejoin(charlie, &qr).await.unwrap();
let request = charlie.pop_sent_msg().await;
alice1.recv_msg_trash(&request).await;
alice2.recv_msg_trash(&request).await;
let auth_required = alice1.pop_sent_msg().await;
charlie.recv_msg_trash(&auth_required).await;
alice2.recv_msg_trash(&auth_required).await;
let request_with_auth = charlie.pop_sent_msg().await;
alice1.recv_msg_trash(&request_with_auth).await;
alice2.recv_msg_trash(&request_with_auth).await;
let member_added = alice1.pop_sent_msg().await;
let a2_member_added = alice2.recv_msg(&member_added).await;
let _c_member_added = charlie.recv_msg(&member_added).await;
// Alice1 will now sync the full member list to Alice2:
sync(alice1, alice2).await;
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
assert_eq!(a2_chatlist.get_msg_id(0)?.unwrap(), a2_member_added.id);
let a2_bob_contact = alice2.add_or_lookup_contact_id(bob).await;
let a2_charlie_contact = alice2.add_or_lookup_contact_id(charlie).await;
let a2_chat_members = get_chat_contacts(alice2, a2_member_added.chat_id).await?;
assert!(a2_chat_members.contains(&a2_bob_contact));
assert!(a2_chat_members.contains(&a2_charlie_contact));
assert_eq!(a2_chat_members.len(), 2);
Ok(())
}
/// Test that, if the broadcast channel owner has multiple devices
/// and they have diverging views on the recipients,
/// sync messages only add members but don't remove them.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_recipients_sync2() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice1 = &tcm.alice().await;
let alice2 = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
for a in &[alice1, alice2] {
a.set_config_bool(Config::SyncMsgs, true).await?;
}
let a1_broadcast_id = create_broadcast(alice1, "Channel".to_string()).await?;
sync(alice1, alice2).await;
tcm.section("Alice1 adds Bob, but Alice2 misses it for some reason");
let qr = get_securejoin_qr(alice1, Some(a1_broadcast_id))
.await
.unwrap();
tcm.exec_securejoin_qr(bob, alice1, &qr).await;
tcm.section("Alice2 adds Charlie, but Alice1 misses it for some reason");
let a2_broadcast_id = Chatlist::try_load(alice2, 0, Some("Channel"), None)
.await?
.get_chat_id(0)
.unwrap();
let qr = get_securejoin_qr(alice2, Some(a2_broadcast_id))
.await
.unwrap();
tcm.exec_securejoin_qr(charlie, alice2, &qr).await;
tcm.section("The sync messages should correct the problem");
sync(alice1, alice2).await;
sync(alice2, alice1).await;
for (alice, broadcast_id) in [(alice1, a1_broadcast_id), (alice2, a2_broadcast_id)] {
let bob_contact = alice.add_or_lookup_contact_id(bob).await;
let charlie_contact = alice.add_or_lookup_contact_id(charlie).await;
let chat_members = get_chat_contacts(alice, broadcast_id).await?;
assert!(chat_members.contains(&bob_contact));
assert!(chat_members.contains(&charlie_contact));
assert_eq!(chat_members.len(), 2);
}
Ok(())
}
/// - Create a broadcast channel
/// - Send a message into it in order to promote it
/// - Add a contact
@@ -2948,6 +3156,149 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_description_basic() {
test_chat_description("", false).await.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_description_unpromoted_description() {
test_chat_description("Unpromoted description in the beginning", false)
.await
.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_description_qr() {
test_chat_description("", true).await.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_description_unpromoted_description_qr() {
test_chat_description("Unpromoted description in the beginning", true)
.await
.unwrap()
}
async fn test_chat_description(initial_description: &str, join_via_qr: bool) -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await;
let bob = &tcm.bob().await;
alice.set_config_bool(Config::SyncMsgs, true).await?;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
tcm.section("Create a group chat, and add Bob");
let alice_chat_id = create_group(alice, "My Group").await?;
if !initial_description.is_empty() {
set_chat_description(alice, alice_chat_id, initial_description).await?;
}
sync(alice, alice2).await;
let alice2_chat_id = get_chat_id_by_grpid(
alice2,
&Chat::load_from_db(alice, alice_chat_id).await?.grpid,
)
.await?
.unwrap()
.0;
assert_eq!(
get_chat_description(alice2, alice2_chat_id).await?,
initial_description
);
let bob_chat_id = if join_via_qr {
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await
} else {
let alice_bob_id = alice.add_or_lookup_contact_id(bob).await;
add_contact_to_chat(alice, alice_chat_id, alice_bob_id).await?;
let sent = alice.send_text(alice_chat_id, "promoting the group").await;
bob.recv_msg(&sent).await.chat_id
};
assert_eq!(
get_chat_description(bob, bob_chat_id).await?,
initial_description
);
for description in ["This is a cool group", "", "ä ẟ 😂"] {
tcm.section(&format!(
"Alice sets the chat description to '{description}'"
));
set_chat_description(alice, alice_chat_id, description).await?;
let sent = alice.pop_sent_msg().await;
assert_eq!(
sent.load_from_db().await.text,
"[Chat description changed. To see this and other new features, please update the app]"
);
tcm.section("Bob receives the description change");
let rcvd = bob.recv_msg(&sent).await;
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupDescriptionChanged);
assert_eq!(rcvd.text, "Chat description changed by alice@example.org.");
assert_eq!(get_chat_description(bob, rcvd.chat_id).await?, description);
tcm.section("Check Alice's second device");
alice2.recv_msg(&sent).await;
let alice2_chat_id = get_chat_id_by_grpid(
alice2,
&Chat::load_from_db(alice, alice_chat_id).await?.grpid,
)
.await?
.unwrap()
.0;
assert_eq!(
get_chat_description(alice2, alice2_chat_id).await?,
description
);
}
tcm.section("Alice calls set_chat_description() without actually changing the description");
set_chat_description(alice, alice_chat_id, "ä ẟ 😂").await?;
assert!(
alice
.pop_sent_msg_opt(Duration::from_secs(0))
.await
.is_none()
);
Ok(())
}
/// Tests explicitly setting an empty chat description
/// doesn't trigger sending out a message
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setting_empty_chat_description() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
tcm.section("Create a group chat, and add Bob in order to promote it");
let alice_chat_id = create_group(alice, "My Group").await?;
add_contact_to_chat(
alice,
alice_chat_id,
alice.add_or_lookup_contact_id(bob).await,
)
.await?;
let _hi = alice.send_text(alice_chat_id, "hi").await;
set_chat_description(alice, alice_chat_id, "").await?;
assert!(
alice
.pop_sent_msg_opt(Duration::from_secs(0))
.await
.is_none()
);
Ok(())
}
/// Tests that directly after broadcast-securejoin,
/// the brodacast is shown correctly on both devices.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -3116,7 +3467,7 @@ async fn test_broadcast_channel_protected_listid() -> Result<()> {
.await?
.grpid;
let parsed = mimeparser::MimeMessage::from_bytes(bob, sent.payload.as_bytes(), None).await?;
let parsed = mimeparser::MimeMessage::from_bytes(bob, sent.payload.as_bytes()).await?;
assert_eq!(
parsed.get_mailinglist_header().unwrap(),
format!("My Channel <{}>", alice_list_id)
@@ -3227,6 +3578,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.");
@@ -3311,7 +3667,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
remove_contact_from_chat(bob0, bob_chat_id, ContactId::SELF).await?;
let leave_msg = bob0.pop_sent_msg().await;
let parsed = MimeMessage::from_bytes(bob1, leave_msg.payload().as_bytes(), None).await?;
let parsed = MimeMessage::from_bytes(bob1, leave_msg.payload().as_bytes()).await?;
assert_eq!(parsed.parts[0].msg, "I left the group.");
let rcvd = bob1.recv_msg(&leave_msg).await;
@@ -3603,13 +3959,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\
@@ -3619,7 +3975,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\
@@ -3633,13 +3989,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\
@@ -5278,6 +5634,97 @@ async fn test_forward_msgs_2ctx() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_forward_msgs_2ctx_with_file() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// First, establish a chat between Alice and Bob to have the chat IDs
let alice_chat = alice.create_chat(bob).await;
let alice_initial = alice.send_text(alice_chat.id, "hi").await;
let bob_alice_msg = bob.recv_msg(&alice_initial).await;
let bob_chat_id = bob_alice_msg.chat_id;
bob_chat_id.accept(bob).await?;
// Alice sends a message with an attached file to her self-chat
let alice_self_chat = alice.get_self_chat().await;
let file_bytes = b"test file content";
let file = alice.get_blobdir().join("test.txt");
tokio::fs::write(&file, file_bytes).await?;
let mut msg = Message::new(Viewtype::File);
msg.set_file_and_deduplicate(alice, &file, Some("test.txt"), Some("text/plain"))?;
msg.set_text("Here's a file".to_string());
alice.send_msg(alice_self_chat.id, &mut msg).await;
let alice_self_msg = alice.get_last_msg().await;
// Verify the file exists in Alice's blobdir
assert_eq!(alice_self_msg.viewtype, Viewtype::File);
let alice_original_file_path = alice_self_msg.get_file(alice).unwrap();
let alice_original_content = tokio::fs::read(&alice_original_file_path).await?;
assert_eq!(alice_original_content, file_bytes);
// Alice forwards the message to Bob using forward_msgs_2ctx
forward_msgs_2ctx(alice, &[alice_self_msg.id], bob, bob_chat_id).await?;
// Bob should have the forwarded message with the file in his database
let bob_msg = bob.get_last_msg().await;
assert_eq!(bob_msg.viewtype, Viewtype::File);
assert!(bob_msg.is_forwarded());
assert_eq!(bob_msg.text, "Here's a file");
assert_eq!(bob_msg.from_id, ContactId::SELF);
// Verify Bob has the file in his blobdir with correct content
let bob_file_path = bob_msg.get_file(bob).unwrap();
let bob_file_content = tokio::fs::read(&bob_file_path).await?;
assert_eq!(bob_file_content, file_bytes);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_forward_msgs_2ctx_missing_blob() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
let alice_initial = alice.send_text(alice_chat.id, "hi").await;
let bob_alice_msg = bob.recv_msg(&alice_initial).await;
let bob_chat_id = bob_alice_msg.chat_id;
bob_chat_id.accept(bob).await?;
// Alice sends a file to her self-chat
let alice_self_chat = alice.get_self_chat().await;
let file_bytes = b"test content";
let file = alice.get_blobdir().join("test.txt");
tokio::fs::write(&file, file_bytes).await?;
let mut msg = Message::new(Viewtype::File);
msg.set_file_and_deduplicate(alice, &file, Some("test.txt"), Some("text/plain"))?;
msg.set_text("File message".to_string());
alice.send_msg(alice_self_chat.id, &mut msg).await;
let alice_self_msg = alice.get_last_msg().await;
// Delete the blob file from Alice's blobdir to simulate a missing file
let alice_file_path = alice_self_msg.get_file(alice).unwrap();
tokio::fs::remove_file(&alice_file_path).await?;
// Alice tries to forward the message - this should fail with an error
let result = forward_msgs_2ctx(alice, &[alice_self_msg.id], bob, bob_chat_id).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to copy blob file")
);
Ok(())
}
/// Tests that in multi-device setup
/// second device learns the key of a contact
/// via Autocrypt-Gossip in 1:1 chats.

View File

@@ -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 {
@@ -396,8 +397,6 @@ impl Chatlist {
if lastmsg.from_id == ContactId::SELF {
None
} else if chat.typ == Chattype::Group
|| chat.typ == Chattype::OutBroadcast
|| chat.typ == Chattype::InBroadcast
|| chat.typ == Chattype::Mailinglist
|| chat.is_self_talk()
{
@@ -471,10 +470,11 @@ mod tests {
use super::*;
use crate::chat::save_msgs;
use crate::chat::{
add_contact_to_chat, create_group, get_chat_contacts, remove_contact_from_chat,
send_text_msg, set_chat_name,
add_contact_to_chat, create_broadcast, create_group, get_chat_contacts,
remove_contact_from_chat, send_text_msg, set_chat_name,
};
use crate::receive_imf::receive_imf;
use crate::securejoin::get_securejoin_qr;
use crate::stock_str::StockMessage;
use crate::test_utils::TestContext;
use crate::test_utils::TestContextManager;
@@ -598,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;
@@ -806,6 +841,32 @@ mod tests {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_summary_prefix_for_channel() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let alice_chat_id = create_broadcast(&alice, "alice's channel".to_string()).await?;
let qr = get_securejoin_qr(&alice, Some(alice_chat_id)).await?;
tcm.exec_securejoin_qr(&bob, &alice, &qr).await;
send_text_msg(&alice, alice_chat_id, "hi".into()).await?;
let sent1 = alice.pop_sent_msg().await;
let chatlist = Chatlist::try_load(&alice, 0, None, None).await?;
let summary = chatlist.get_summary(&alice, 0, None).await?;
assert!(summary.prefix.is_none());
assert_eq!(summary.text, "hi");
bob.recv_msg(&sent1).await;
let chatlist = Chatlist::try_load(&bob, 0, None, None).await?;
let summary = chatlist.get_summary(&bob, 0, None).await?;
assert!(summary.prefix.is_none());
assert_eq!(summary.text, "hi");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_load_broken() {
let t = TestContext::new_bob().await;

View File

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

View File

@@ -175,11 +175,6 @@ pub enum Config {
#[strum(props(default = "0"))] // also change MediaQuality.default() on changes
MediaQuality,
/// If set to "1", then existing messages are considered to be already fetched.
/// This flag is reset after successful configuration.
#[strum(props(default = "1"))]
FetchedExistingMsgs,
/// Timer in seconds after which the message is deleted from the
/// server.
///
@@ -199,10 +194,6 @@ pub enum Config {
#[strum(props(default = "0"))]
DeleteDeviceAfter,
/// Move messages to the Trash folder instead of marking them "\Deleted". Overrides
/// `ProviderOptions::delete_to_trash`.
DeleteToTrash,
/// The primary email address.
ConfiguredAddr,
@@ -280,9 +271,6 @@ pub enum Config {
/// Configured folder for chat messages.
ConfiguredMvboxFolder,
/// Configured "Trash" folder.
ConfiguredTrashFolder,
/// Unix timestamp of the last successful configuration.
ConfiguredTimestamp,
@@ -340,10 +328,6 @@ pub enum Config {
/// Timestamp of the last `CantDecryptOutgoingMsgs` notification.
LastCantDecryptOutgoingMsgs,
/// To how many seconds to debounce scan_all_folders. Used mainly in tests, to disable debouncing completely.
#[strum(props(default = "60"))]
ScanAllFoldersDebounceSecs,
/// Whether to avoid using IMAP IDLE even if the server supports it.
///
/// This is a developer option for testing "fake idle".
@@ -354,7 +338,17 @@ pub enum Config {
DonationRequestNextCheck,
/// Defines the max. size (in bytes) of messages downloaded automatically.
///
/// For messages with large attachments, two messages are sent:
/// a Pre-Message containing metadata and text and a Post-Message additionally
/// containing the attachment. NB: Some "extra" metadata like avatars and gossiped
/// encryption keys is stripped from post-messages to save traffic.
/// Pre-Messages are shown as placeholder messages. They can be downloaded fully using
/// `MsgId::download_full()` later. Post-Messages are automatically downloaded if they are
/// smaller than the download_limit. Other messages are always auto-downloaded.
///
/// 0 = no limit.
/// Changes only affect future messages.
#[strum(props(default = "0"))]
DownloadLimit,
@@ -438,14 +432,24 @@ pub enum Config {
/// using this still run unmodified code.
TestHooks,
/// Return an error from `receive_imf_inner()` for a fully downloaded message. For tests.
FailOnReceivingFullMsg,
/// Return an error from `receive_imf_inner()`. For tests.
SimulateReceiveImfError,
/// Enable composing emails with Header Protection as defined in
/// <https://www.rfc-editor.org/rfc/rfc9788.html> "Header Protection for Cryptographically
/// Protected Email".
#[strum(props(default = "1"))]
StdHeaderProtectionComposing,
/// Who can call me.
///
/// The options are from the `WhoCanCallMe` enum.
#[strum(props(default = "1"))]
WhoCanCallMe,
/// Experimental option denoting that the current profile is shared between multiple team members.
/// For now, the only effect of this option is that seen flags are not synchronized.
TeamProfile,
}
impl Config {
@@ -587,8 +591,7 @@ impl Context {
.get_config(key)
.await?
.and_then(|s| s.parse::<i32>().ok())
.map(|x| x != 0)
.unwrap_or_default())
.is_some_and(|x| x != 0))
}
/// Returns true if movebox ("DeltaChat" folder) should be watched.
@@ -681,7 +684,6 @@ impl Context {
| Config::MdnsEnabled
| Config::MvboxMove
| Config::OnlyFetchMvbox
| Config::DeleteToTrash
| Config::Configured
| Config::Bot
| Config::NotifyAboutWrongPw
@@ -705,12 +707,7 @@ impl Context {
Self::check_config(key, value)?;
let n_transports = self.count_transports().await?;
if n_transports > 1
&& matches!(
key,
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ShowEmails
)
{
if n_transports > 1 && matches!(key, Config::MvboxMove | Config::OnlyFetchMvbox) {
bail!("Cannot reconfigure {key} when multiple transports are configured");
}
@@ -947,7 +944,7 @@ impl Context {
Ok(())
}
/// Returns all primary and secondary self addresses.
/// Returns the primary self address followed by all secondary ones.
pub(crate) async fn get_all_self_addrs(&self) -> Result<Vec<String>> {
let primary_addrs = self.get_config(Config::ConfiguredAddr).await?.into_iter();
let secondary_addrs = self.get_secondary_self_addrs().await?.into_iter();

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;
@@ -286,11 +286,6 @@ impl Context {
"To use additional relays, disable the legacy option \"Settings / Advanced / Move automatically to DeltaChat Folder\"."
);
}
if self.get_config(Config::ShowEmails).await?.as_deref() != Some("2") {
bail!(
"To use additional relays, set the legacy option \"Settings / Advanced / Show Classic Emails\" to \"All\"."
);
}
if self
.sql
@@ -631,8 +626,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);

View File

@@ -673,6 +673,7 @@ impl Contact {
}
/// Returns `true` if this contact was seen recently.
#[expect(clippy::arithmetic_side_effects)]
pub fn was_seen_recently(&self) -> bool {
time() - self.last_seen <= SEEN_RECENTLY_SECONDS
}
@@ -1071,6 +1072,7 @@ VALUES (?, ?, ?, ?, ?, ?)
/// The `addr_book` is a multiline string in the format `Name one\nAddress one\nName two\nAddress two`.
///
/// Returns the number of modified contacts.
#[expect(clippy::arithmetic_side_effects)]
pub async fn add_address_book(context: &Context, addr_book: &str) -> Result<usize> {
let mut modify_cnt = 0;
@@ -1133,7 +1135,8 @@ VALUES (?, ?, ?, ?, ?, ?)
Origin::IncomingReplyTo
};
if query.is_some() {
let s3str_like_cmd = format!("%{}%", query.unwrap_or("").to_lowercase());
let query_lowercased = query.unwrap_or("").to_lowercase();
let s3str_like_cmd = format!("%{}%", query_lowercased);
context
.sql
.query_map(
@@ -1151,7 +1154,7 @@ ORDER BY c.origin>=? DESC, c.last_seen DESC, c.id DESC
flag_address,
minimal_origin,
&s3str_like_cmd,
&s3str_like_cmd,
&query_lowercased,
Origin::CreateChat,
),
|row| {
@@ -1298,18 +1301,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)
@@ -1353,13 +1344,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?
@@ -1465,7 +1456,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.
@@ -1658,8 +1649,7 @@ WHERE addr=?
///
/// If this returns Some(_),
/// display green checkmark in the profile and "Introduced by ..." line
/// with the name and address of the contact
/// formatted by [Self::get_name_n_addr].
/// with the name of the contact.
///
/// If this returns `Some(None)`, then the contact is verified,
/// but it's unclear by whom.
@@ -1921,6 +1911,7 @@ pub(crate) async fn set_status(
}
/// Updates last seen timestamp of the contact if it is earlier than the given `timestamp`.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn update_last_seen(
context: &Context,
contact_id: ContactId,
@@ -2012,6 +2003,7 @@ pub(crate) async fn mark_contact_id_as_verified(
Ok(())
}
#[expect(clippy::arithmetic_side_effects)]
fn cat_fingerprint(ret: &mut String, name: &str, addr: &str, fingerprint: &str) {
*ret += &format!("\n\n{name} ({addr}):\n{fingerprint}");
}
@@ -2053,6 +2045,7 @@ impl RecentlySeenLoop {
}
}
#[expect(clippy::arithmetic_side_effects)]
async fn run(context: Context, interrupt: Receiver<RecentlySeenInterrupt>) {
type MyHeapElem = (Reverse<i64>, ContactId);

View File

@@ -85,10 +85,15 @@ async fn test_get_contacts() -> Result<()> {
assert_eq!(contacts.len(), 1);
assert_eq!(contacts.first(), Some(&id));
// Search by address.
// Search by address is case-insensitive, but only returns direct matches.
let contacts = Contact::get_all(&context, 0, Some("alice@example.org")).await?;
assert_eq!(contacts.len(), 1);
assert_eq!(contacts.first(), Some(&id));
let contacts = Contact::get_all(&context, 0, Some("Alice@example.org")).await?;
assert_eq!(contacts.len(), 1);
assert_eq!(contacts.first(), Some(&id));
let contacts = Contact::get_all(&context, 0, Some("alice@")).await?;
assert_eq!(contacts.len(), 0);
let contacts = Contact::get_all(&context, 0, Some("Foobar")).await?;
assert_eq!(contacts.len(), 0);
@@ -818,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?);
@@ -827,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):

View File

@@ -8,7 +8,7 @@ use std::sync::atomic::AtomicBool;
use std::sync::{Arc, OnceLock, Weak};
use std::time::Duration;
use anyhow::{Context as _, Result, bail, ensure};
use anyhow::{Result, bail, ensure};
use async_channel::{self as channel, Receiver, Sender};
use ratelimit::Ratelimit;
use tokio::sync::{Mutex, Notify, RwLock};
@@ -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
@@ -340,6 +342,7 @@ enum RunningState {
/// actual keys and their values which will be present are not
/// guaranteed. Calling [Context::get_info] also includes information
/// about the context on top of the information here.
#[expect(clippy::arithmetic_side_effects)]
pub fn get_info() -> BTreeMap<&'static str, String> {
let mut res = BTreeMap::new();
@@ -878,10 +881,6 @@ impl Context {
.get_config(Config::ConfiguredMvboxFolder)
.await?
.unwrap_or_else(|| "<unset>".to_string());
let configured_trash_folder = self
.get_config(Config::ConfiguredTrashFolder)
.await?
.unwrap_or_else(|| "<unset>".to_string());
let mut res = get_info();
@@ -944,16 +943,14 @@ 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(),
);
res.insert(
"who_can_call_me",
self.get_config_int(Config::WhoCanCallMe).await?.to_string(),
);
res.insert(
"download_limit",
self.get_config_int(Config::DownloadLimit)
@@ -968,7 +965,6 @@ impl Context {
);
res.insert("configured_inbox_folder", configured_inbox_folder);
res.insert("configured_mvbox_folder", configured_mvbox_folder);
res.insert("configured_trash_folder", configured_trash_folder);
res.insert("mdns_enabled", mdns_enabled.to_string());
res.insert("bcc_self", bcc_self.to_string());
res.insert("sync_msgs", sync_msgs.to_string());
@@ -992,12 +988,6 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"delete_to_trash",
self.get_config(Config::DeleteToTrash)
.await?
.unwrap_or_else(|| "<unset>".to_string()),
);
res.insert(
"last_housekeeping",
self.get_config_int(Config::LastHousekeeping)
@@ -1010,12 +1000,6 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"scan_all_folders_debounce_secs",
self.get_config_int(Config::ScanAllFoldersDebounceSecs)
.await?
.to_string(),
);
res.insert(
"quota_exceeding",
self.get_config_int(Config::QuotaExceeding)
@@ -1088,13 +1072,6 @@ impl Context {
.await?
.unwrap_or_default(),
);
res.insert(
"fail_on_receiving_full_msg",
self.sql
.get_raw_config("fail_on_receiving_full_msg")
.await?
.unwrap_or_default(),
);
res.insert(
"std_header_protection_composing",
self.sql
@@ -1102,6 +1079,10 @@ impl Context {
.await?
.unwrap_or_default(),
);
res.insert(
"team_profile",
self.get_config_bool(Config::TeamProfile).await?.to_string(),
);
let elapsed = time_elapsed(&self.creation_time);
res.insert("uptime", duration_to_str(elapsed));
@@ -1119,21 +1100,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)?;
@@ -1285,45 +1264,12 @@ 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?;
Ok(mvbox.as_deref() == Some(folder_name))
}
/// Returns true if given folder name is the name of the trash folder.
pub async fn is_trash(&self, folder_name: &str) -> Result<bool> {
let trash = self.get_config(Config::ConfiguredTrashFolder).await?;
Ok(trash.as_deref() == Some(folder_name))
}
pub(crate) async fn should_delete_to_trash(&self) -> Result<bool> {
if let Some(v) = self.get_config_bool_opt(Config::DeleteToTrash).await? {
return Ok(v);
}
if let Some(provider) = self.get_configured_provider().await? {
return Ok(provider.opt.delete_to_trash);
}
Ok(false)
}
/// Returns `target` for deleted messages as per `imap` table. Empty string means "delete w/o
/// moving to trash".
pub(crate) async fn get_delete_msgs_target(&self) -> Result<String> {
if !self.should_delete_to_trash().await? {
return Ok("".into());
}
self.get_config(Config::ConfiguredTrashFolder)
.await?
.context("No configured trash folder")
}
pub(crate) fn derive_blobdir(dbfile: &Path) -> PathBuf {
let mut blob_fname = OsString::new();
blob_fname.push(dbfile.file_name().unwrap_or_default());

View File

@@ -297,6 +297,7 @@ async fn test_get_info_completeness() {
"encrypted_device_token",
"stats_last_update",
"stats_last_old_contact_id",
"simulate_receive_imf_error", // only used in tests
];
let t = TestContext::new().await;
let info = t.get_info().await.unwrap();

View File

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

View File

@@ -1,27 +1,19 @@
//! # Download large messages manually.
use std::cmp::max;
use std::collections::BTreeMap;
use anyhow::{Result, anyhow, bail, ensure};
use deltachat_derive::{FromSql, ToSql};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::context::Context;
use crate::imap::session::Session;
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, Part};
use crate::tools::time;
use crate::{EventType, chatlist_events, stock_str};
use crate::log::warn;
use crate::message::{self, Message, MsgId, rfc724_mid_exists};
use crate::{EventType, chatlist_events};
/// Download limits should not be used below `MIN_DOWNLOAD_LIMIT`.
///
/// For better UX, some messages as add-member, non-delivery-reports (NDN) or read-receipts (MDN)
/// should always be downloaded completely to handle them correctly,
/// also in larger groups and if group and contact avatar are attached.
/// Most of these cases are caught by `MIN_DOWNLOAD_LIMIT`.
pub(crate) const MIN_DOWNLOAD_LIMIT: u32 = 163840;
pub(crate) mod post_msg_metadata;
pub(crate) use post_msg_metadata::PostMsgMetadata;
/// If a message is downloaded only partially
/// and `delete_server_after` is set to small timeouts (eg. "at once"),
@@ -29,6 +21,16 @@ pub(crate) const MIN_DOWNLOAD_LIMIT: u32 = 163840;
/// `MIN_DELETE_SERVER_AFTER` increases the timeout in this case.
pub(crate) const MIN_DELETE_SERVER_AFTER: i64 = 48 * 60 * 60;
/// From this point onward outgoing messages are considered large
/// and get a Pre-Message, which announces the Post-Message.
/// This is only about sending so we can modify it any time.
/// Current value is a bit less than the minimum auto-download setting from the UIs (which is 160
/// KiB).
pub(crate) const PRE_MSG_ATTACHMENT_SIZE_THRESHOLD: u64 = 140_000;
/// Max size for pre messages. A warning is emitted when this is exceeded.
pub(crate) const PRE_MSG_SIZE_WARNING_THRESHOLD: usize = 150_000;
/// Download state of the message.
#[derive(
Debug,
@@ -64,20 +66,8 @@ pub enum DownloadState {
InProgress = 1000,
}
impl Context {
// Returns validated download limit or `None` for "no limit".
pub(crate) async fn download_limit(&self) -> Result<Option<u32>> {
let download_limit = self.get_config_int(Config::DownloadLimit).await?;
if download_limit <= 0 {
Ok(None)
} else {
Ok(Some(max(MIN_DOWNLOAD_LIMIT, download_limit as u32)))
}
}
}
impl MsgId {
/// Schedules full message download for partially downloaded message.
/// Schedules Post-Message download for partially downloaded message.
pub async fn download_full(self, context: &Context) -> Result<()> {
let msg = Message::load_from_db(context, self).await?;
match msg.download_state() {
@@ -86,11 +76,22 @@ impl MsgId {
}
DownloadState::InProgress => return Err(anyhow!("Download already in progress.")),
DownloadState::Available | DownloadState::Failure => {
if msg.rfc724_mid().is_empty() {
return Err(anyhow!("Download not possible, message has no rfc724_mid"));
}
self.update_download_state(context, DownloadState::InProgress)
.await?;
info!(
context,
"Requesting full download of {:?}.",
msg.rfc724_mid()
);
context
.sql
.execute("INSERT INTO download (msg_id) VALUES (?)", (self,))
.execute(
"INSERT INTO download (rfc724_mid, msg_id) VALUES (?,?)",
(msg.rfc724_mid(), msg.id),
)
.await?;
context.scheduler.interrupt_inbox().await;
}
@@ -98,7 +99,8 @@ impl MsgId {
Ok(())
}
/// Updates the message download state. Returns `Ok` if the message doesn't exist anymore.
/// Updates the message download state. Returns `Ok` if the message doesn't exist anymore or has
/// the download state up to date.
pub(crate) async fn update_download_state(
self,
context: &Context,
@@ -107,7 +109,7 @@ impl MsgId {
if context
.sql
.execute(
"UPDATE msgs SET download_state=? WHERE id=?;",
"UPDATE msgs SET download_state=? WHERE id=? AND download_state<>?1",
(download_state, self),
)
.await?
@@ -134,51 +136,46 @@ impl Message {
}
}
/// Actually download a message partially downloaded before.
/// Actually downloads a message partially downloaded before if the message is available on the
/// session transport, in which case returns `Some`. If the message is available on another
/// transport, returns `None`.
///
/// Most messages are downloaded automatically on fetch instead.
pub(crate) async fn download_msg(
context: &Context,
msg_id: MsgId,
rfc724_mid: String,
session: &mut Session,
) -> Result<()> {
let Some(msg) = Message::load_from_db_optional(context, msg_id).await? else {
// If partially downloaded message was already deleted
// we do not know its Message-ID anymore
// so cannot download it.
//
// Probably the message expired due to `delete_device_after`
// setting or was otherwise removed from the device,
// so we don't want it to reappear anyway.
return Ok(());
};
) -> Result<Option<()>> {
let transport_id = session.transport_id();
let row = context
.sql
.query_row_optional(
"SELECT uid, folder FROM imap
WHERE rfc724_mid=?
AND transport_id=?
AND target!=''",
(&msg.rfc724_mid, transport_id),
"SELECT uid, folder, transport_id FROM imap
WHERE rfc724_mid=? AND target!=''
ORDER BY transport_id=? DESC LIMIT 1",
(&rfc724_mid, transport_id),
|row| {
let server_uid: u32 = row.get(0)?;
let server_folder: String = row.get(1)?;
Ok((server_uid, server_folder))
let msg_transport_id: u32 = row.get(2)?;
Ok((server_uid, server_folder, msg_transport_id))
},
)
.await?;
let Some((server_uid, server_folder)) = row else {
let Some((server_uid, server_folder, msg_transport_id)) = row else {
// No IMAP record found, we don't know the UID and folder.
return Err(anyhow!("Call download_full() again to try over."));
return Err(anyhow!(
"IMAP location for {rfc724_mid:?} post-message is unknown"
));
};
if msg_transport_id != transport_id {
return Ok(None);
}
session
.fetch_single_msg(context, &server_folder, server_uid, msg.rfc724_mid.clone())
.fetch_single_msg(context, &server_folder, server_uid, rfc724_mid)
.await?;
Ok(())
Ok(Some(()))
}
impl Session {
@@ -197,10 +194,7 @@ impl Session {
bail!("Attempt to fetch UID 0");
}
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
ensure!(folder_exists, "No folder {folder}");
// we are connected, and the folder is selected
@@ -209,7 +203,7 @@ impl Session {
let mut uid_message_ids: BTreeMap<u32, String> = BTreeMap::new();
uid_message_ids.insert(uid, rfc724_mid);
let (sender, receiver) = async_channel::unbounded();
self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, false, sender)
self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, sender)
.await?;
if receiver.recv().await.is_err() {
bail!("Failed to fetch UID {uid}");
@@ -218,41 +212,139 @@ impl Session {
}
}
impl MimeMessage {
/// Creates a placeholder part and add that to `parts`.
///
/// To create the placeholder, only the outermost header can be used,
/// the mime-structure itself is not available.
///
/// The placeholder part currently contains a text with size and availability of the message.
pub(crate) async fn create_stub_from_partial_download(
&mut self,
context: &Context,
org_bytes: u32,
) -> Result<()> {
let mut text = format!(
"[{}]",
stock_str::partial_download_msg_body(context, org_bytes).await
);
if let Some(delete_server_after) = context.get_config_delete_server_after().await? {
let until = stock_str::download_availability(
context,
time() + max(delete_server_after, MIN_DELETE_SERVER_AFTER),
)
.await;
text += format!(" [{until}]").as_str();
};
info!(context, "Partial download: {}", text);
self.do_add_single_part(Part {
typ: Viewtype::Text,
msg: text,
..Default::default()
});
Ok(())
async fn set_state_to_failure(context: &Context, rfc724_mid: &str) -> Result<()> {
if let Some(msg_id) = rfc724_mid_exists(context, rfc724_mid).await? {
// Update download state to failure
// so it can be retried.
//
// On success update_download_state() is not needed
// as receive_imf() already
// set the state and emitted the event.
msg_id
.update_download_state(context, DownloadState::Failure)
.await?;
}
Ok(())
}
async fn available_post_msgs_contains_rfc724_mid(
context: &Context,
rfc724_mid: &str,
) -> Result<bool> {
Ok(context
.sql
.query_get_value::<String>(
"SELECT rfc724_mid FROM available_post_msgs WHERE rfc724_mid=?",
(&rfc724_mid,),
)
.await?
.is_some())
}
async fn delete_from_available_post_msgs(context: &Context, rfc724_mid: &str) -> Result<()> {
context
.sql
.execute(
"DELETE FROM available_post_msgs WHERE rfc724_mid=?",
(&rfc724_mid,),
)
.await?;
Ok(())
}
async fn delete_from_downloads(context: &Context, rfc724_mid: &str) -> Result<()> {
context
.sql
.execute("DELETE FROM download WHERE rfc724_mid=?", (&rfc724_mid,))
.await?;
Ok(())
}
pub(crate) async fn msg_is_downloaded_for(context: &Context, rfc724_mid: &str) -> Result<bool> {
Ok(message::rfc724_mid_exists(context, rfc724_mid)
.await?
.is_some())
}
pub(crate) async fn download_msgs(context: &Context, session: &mut Session) -> Result<()> {
let rfc724_mids = context
.sql
.query_map_vec("SELECT rfc724_mid FROM download", (), |row| {
let rfc724_mid: String = row.get(0)?;
Ok(rfc724_mid)
})
.await?;
for rfc724_mid in &rfc724_mids {
let res = download_msg(context, rfc724_mid.clone(), session).await;
if let Ok(Some(())) = res {
delete_from_downloads(context, rfc724_mid).await?;
delete_from_available_post_msgs(context, rfc724_mid).await?;
}
if let Err(err) = res {
warn!(
context,
"Failed to download message rfc724_mid={rfc724_mid}: {:#}.", err
);
if !msg_is_downloaded_for(context, rfc724_mid).await? {
// This is probably a classical email that vanished before we could download it
warn!(
context,
"{rfc724_mid} download failed and there is no downloaded pre-message."
);
delete_from_downloads(context, rfc724_mid).await?;
} else if available_post_msgs_contains_rfc724_mid(context, rfc724_mid).await? {
warn!(
context,
"{rfc724_mid} is in available_post_msgs table but we failed to fetch it,
so set the message to DownloadState::Failure - probably it was deleted on the server in the meantime"
);
set_state_to_failure(context, rfc724_mid).await?;
delete_from_downloads(context, rfc724_mid).await?;
delete_from_available_post_msgs(context, rfc724_mid).await?;
} else {
// leave the message in DownloadState::InProgress;
// it will be downloaded once it arrives.
}
}
}
Ok(())
}
/// Downloads known post-messages without pre-messages
/// in order to guard against lost pre-messages.
pub(crate) async fn download_known_post_messages_without_pre_message(
context: &Context,
session: &mut Session,
) -> Result<()> {
let rfc724_mids = context
.sql
.query_map_vec("SELECT rfc724_mid FROM available_post_msgs", (), |row| {
let rfc724_mid: String = row.get(0)?;
Ok(rfc724_mid)
})
.await?;
for rfc724_mid in &rfc724_mids {
if !msg_is_downloaded_for(context, rfc724_mid).await? {
// Download the Post-Message unconditionally,
// because the Pre-Message got lost.
// The message may be in the wrong order,
// but at least we have it at all.
let res = download_msg(context, rfc724_mid.clone(), session).await;
if let Ok(Some(())) = res {
delete_from_available_post_msgs(context, rfc724_mid).await?;
}
if let Err(err) = res {
warn!(
context,
"download_known_post_messages_without_pre_message: Failed to download message rfc724_mid={rfc724_mid}: {:#}.",
err
);
}
}
}
Ok(())
}
#[cfg(test)]
@@ -260,11 +352,8 @@ mod tests {
use num_traits::FromPrimitive;
use super::*;
use crate::chat::{get_chat_msgs, send_msg};
use crate::ephemeral::Timer;
use crate::message::delete_msgs;
use crate::receive_imf::receive_imf_from_inbox;
use crate::test_utils::{E2EE_INFO_MSGS, TestContext, TestContextManager};
use crate::chat::send_msg;
use crate::test_utils::TestContext;
#[test]
fn test_downloadstate_values() {
@@ -282,29 +371,6 @@ mod tests {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_download_limit() -> Result<()> {
let t = TestContext::new_alice().await;
assert_eq!(t.download_limit().await?, None);
t.set_config(Config::DownloadLimit, Some("200000")).await?;
assert_eq!(t.download_limit().await?, Some(200000));
t.set_config(Config::DownloadLimit, Some("20000")).await?;
assert_eq!(t.download_limit().await?, Some(MIN_DOWNLOAD_LIMIT));
t.set_config(Config::DownloadLimit, None).await?;
assert_eq!(t.download_limit().await?, None);
for val in &["0", "-1", "-100", "", "foo"] {
t.set_config(Config::DownloadLimit, Some(val)).await?;
assert_eq!(t.download_limit().await?, None);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_update_download_state() -> Result<()> {
let t = TestContext::new_alice().await;
@@ -336,230 +402,4 @@ mod tests {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_partial_receive_imf() -> Result<()> {
let t = TestContext::new_alice().await;
let header = "Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
From: bob@example.com\n\
To: alice@example.org\n\
Subject: foo\n\
Message-ID: <Mr.12345678901@example.com>\n\
Chat-Version: 1.0\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\
Content-Type: text/plain";
receive_imf_from_inbox(
&t,
"Mr.12345678901@example.com",
header.as_bytes(),
false,
Some(100000),
)
.await?;
let msg = t.get_last_msg().await;
assert_eq!(msg.download_state(), DownloadState::Available);
assert_eq!(msg.get_subject(), "foo");
assert!(
msg.get_text()
.contains(&stock_str::partial_download_msg_body(&t, 100000).await)
);
receive_imf_from_inbox(
&t,
"Mr.12345678901@example.com",
format!("{header}\n\n100k text...").as_bytes(),
false,
None,
)
.await?;
let msg = t.get_last_msg().await;
assert_eq!(msg.download_state(), DownloadState::Done);
assert_eq!(msg.get_subject(), "foo");
assert_eq!(msg.get_text(), "100k text...");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_partial_download_and_ephemeral() -> Result<()> {
let t = TestContext::new_alice().await;
let chat_id = t
.create_chat_with_contact("bob", "bob@example.org")
.await
.id;
chat_id
.set_ephemeral_timer(&t, Timer::Enabled { duration: 60 })
.await?;
// download message from bob partially, this must not change the ephemeral timer
receive_imf_from_inbox(
&t,
"first@example.org",
b"From: Bob <bob@example.org>\n\
To: Alice <alice@example.org>\n\
Chat-Version: 1.0\n\
Subject: subject\n\
Message-ID: <first@example.org>\n\
Date: Sun, 14 Nov 2021 00:10:00 +0000\
Content-Type: text/plain",
false,
Some(100000),
)
.await?;
assert_eq!(
chat_id.get_ephemeral_timer(&t).await?,
Timer::Enabled { duration: 60 }
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_status_update_expands_to_nothing() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat_id = alice.create_chat(&bob).await.id;
let file = alice.get_blobdir().join("minimal.xdc");
tokio::fs::write(&file, include_bytes!("../test-data/webxdc/minimal.xdc")).await?;
let mut instance = Message::new(Viewtype::File);
instance.set_file_and_deduplicate(&alice, &file, None, None)?;
let _sent1 = alice.send_msg(chat_id, &mut instance).await;
alice
.send_webxdc_status_update(instance.id, r#"{"payload":7}"#)
.await?;
alice.flush_status_updates().await?;
let sent2 = alice.pop_sent_msg().await;
let sent2_rfc724_mid = sent2.load_from_db().await.rfc724_mid;
// not downloading the status update results in an placeholder
receive_imf_from_inbox(
&bob,
&sent2_rfc724_mid,
sent2.payload().as_bytes(),
false,
Some(sent2.payload().len() as u32),
)
.await?;
let msg = bob.get_last_msg().await;
let chat_id = msg.chat_id;
assert_eq!(
get_chat_msgs(&bob, chat_id).await?.len(),
E2EE_INFO_MSGS + 1
);
assert_eq!(msg.download_state(), DownloadState::Available);
// downloading the status update afterwards expands to nothing and moves the placeholder to trash-chat
// (usually status updates are too small for not being downloaded directly)
receive_imf_from_inbox(
&bob,
&sent2_rfc724_mid,
sent2.payload().as_bytes(),
false,
None,
)
.await?;
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), E2EE_INFO_MSGS);
assert!(
Message::load_from_db_optional(&bob, msg.id)
.await?
.is_none()
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mdn_expands_to_nothing() -> Result<()> {
let bob = TestContext::new_bob().await;
let raw = b"Subject: Message opened\n\
Date: Mon, 10 Jan 2020 00:00:00 +0000\n\
Chat-Version: 1.0\n\
Message-ID: <bar@example.org>\n\
To: Alice <alice@example.org>\n\
From: Bob <bob@example.org>\n\
Content-Type: multipart/report; report-type=disposition-notification;\n\t\
boundary=\"kJBbU58X1xeWNHgBtTbMk80M5qnV4N\"\n\
\n\
\n\
--kJBbU58X1xeWNHgBtTbMk80M5qnV4N\n\
Content-Type: text/plain; charset=utf-8\n\
\n\
bla\n\
\n\
\n\
--kJBbU58X1xeWNHgBtTbMk80M5qnV4N\n\
Content-Type: message/disposition-notification\n\
\n\
Reporting-UA: Delta Chat 1.88.0\n\
Original-Recipient: rfc822;bob@example.org\n\
Final-Recipient: rfc822;bob@example.org\n\
Original-Message-ID: <foo@example.org>\n\
Disposition: manual-action/MDN-sent-automatically; displayed\n\
\n\
\n\
--kJBbU58X1xeWNHgBtTbMk80M5qnV4N--\n\
";
// not downloading the mdn results in an placeholder
receive_imf_from_inbox(&bob, "bar@example.org", raw, false, Some(raw.len() as u32)).await?;
let msg = bob.get_last_msg().await;
let chat_id = msg.chat_id;
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), 1);
assert_eq!(msg.download_state(), DownloadState::Available);
// downloading the mdn afterwards expands to nothing and deletes the placeholder directly
// (usually mdn are too small for not being downloaded directly)
receive_imf_from_inbox(&bob, "bar@example.org", raw, false, None).await?;
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), 0);
assert!(
Message::load_from_db_optional(&bob, msg.id)
.await?
.is_none()
);
Ok(())
}
/// Tests that fully downloading the message
/// works even if the Message-ID already exists
/// in the database assigned to the trash chat.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_partial_download_trashed() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let imf_raw = b"From: Bob <bob@example.org>\n\
To: Alice <alice@example.org>\n\
Chat-Version: 1.0\n\
Subject: subject\n\
Message-ID: <first@example.org>\n\
Date: Sun, 14 Nov 2021 00:10:00 +0000\
Content-Type: text/plain";
// Download message from Bob partially.
let partial_received_msg =
receive_imf_from_inbox(alice, "first@example.org", imf_raw, false, Some(100000))
.await?
.unwrap();
assert_eq!(partial_received_msg.msg_ids.len(), 1);
// Delete the received message.
// Not it is still in the database,
// but in the trash chat.
delete_msgs(alice, &[partial_received_msg.msg_ids[0]]).await?;
// Fully download message after deletion.
let full_received_msg =
receive_imf_from_inbox(alice, "first@example.org", imf_raw, false, None).await?;
// The message does not reappear.
// However, `receive_imf` should not fail.
assert!(full_received_msg.is_none());
Ok(())
}
}

View File

@@ -0,0 +1,259 @@
use anyhow::{Context as _, Result};
use num_traits::ToPrimitive;
use serde::{Deserialize, Serialize};
use crate::context::Context;
use crate::log::warn;
use crate::message::Message;
use crate::message::Viewtype;
use crate::param::{Param, Params};
/// Metadata contained in Pre-Message that describes the Post-Message.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PostMsgMetadata {
/// size of the attachment in bytes
pub(crate) size: u64,
/// Real viewtype of message
pub(crate) viewtype: Viewtype,
/// the original file name
pub(crate) filename: String,
/// Width and height of the image or video
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) wh: Option<(i32, i32)>,
/// Duration of audio file or video in milliseconds
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) duration: Option<i32>,
}
impl PostMsgMetadata {
/// Returns `PostMsgMetadata` for messages with file attachment and `None` otherwise.
pub(crate) async fn from_msg(context: &Context, message: &Message) -> Result<Option<Self>> {
if !message.viewtype.has_file() {
return Ok(None);
}
let size = message
.get_filebytes(context)
.await?
.context("Unexpected: file has no size")?;
let filename = message
.param
.get(Param::Filename)
.unwrap_or_default()
.to_owned();
let filename = match message.viewtype {
Viewtype::Webxdc => message
.get_webxdc_info(context)
.await
.map(|info| info.name)
.unwrap_or_else(|_| filename),
_ => filename,
};
let wh = {
match (
message.param.get_int(Param::Width),
message.param.get_int(Param::Height),
) {
(None, None) => None,
(Some(width), Some(height)) => Some((width, height)),
wh => {
warn!(
context,
"Message {} misses width or height: {:?}.", message.id, wh
);
None
}
}
};
let duration = message.param.get_int(Param::Duration);
Ok(Some(Self {
size,
filename,
viewtype: message.viewtype,
wh,
duration,
}))
}
pub(crate) fn to_header_value(&self) -> Result<String> {
Ok(serde_json::to_string(&self)?)
}
pub(crate) fn try_from_header_value(value: &str) -> Result<Self> {
Ok(serde_json::from_str(value)?)
}
}
impl Params {
/// Applies data from post_msg_metadata to Params
pub(crate) fn apply_post_msg_metadata(
&mut self,
post_msg_metadata: &PostMsgMetadata,
) -> &mut Self {
self.set(Param::PostMessageFileBytes, post_msg_metadata.size);
if !post_msg_metadata.filename.is_empty() {
self.set(Param::Filename, &post_msg_metadata.filename);
}
self.set_i64(
Param::PostMessageViewtype,
post_msg_metadata.viewtype.to_i64().unwrap_or_default(),
);
if let Some((width, height)) = post_msg_metadata.wh {
self.set(Param::Width, width);
self.set(Param::Height, height);
}
if let Some(duration) = post_msg_metadata.duration {
self.set(Param::Duration, duration);
}
self
}
}
#[cfg(test)]
mod tests {
use anyhow::Result;
use pretty_assertions::assert_eq;
use crate::{
message::{Message, Viewtype},
test_utils::{TestContextManager, create_test_image},
};
use super::PostMsgMetadata;
/// Build from message with file attachment
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_build_from_file_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let mut file_msg = Message::new(Viewtype::File);
file_msg.set_file_from_bytes(alice, "test.bin", &vec![0u8; 1_000_000], None)?;
let post_msg_metadata = PostMsgMetadata::from_msg(alice, &file_msg).await?;
assert_eq!(
post_msg_metadata,
Some(PostMsgMetadata {
size: 1_000_000,
viewtype: Viewtype::File,
filename: "test.bin".to_string(),
wh: None,
duration: None,
})
);
Ok(())
}
/// Build from message with image attachment
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_build_from_image_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let mut image_msg = Message::new(Viewtype::Image);
let (width, height) = (1080, 1920);
let test_img = create_test_image(width, height)?;
image_msg.set_file_from_bytes(alice, "vacation.png", &test_img, None)?;
// this is usually done while sending,
// but we don't send it here, so we need to call it ourself
image_msg.try_calc_and_set_dimensions(alice).await?;
let post_msg_metadata = PostMsgMetadata::from_msg(alice, &image_msg).await?;
assert_eq!(
post_msg_metadata,
Some(PostMsgMetadata {
size: 1816098,
viewtype: Viewtype::Image,
filename: "vacation.png".to_string(),
wh: Some((width as i32, height as i32)),
duration: None,
})
);
Ok(())
}
/// Test that serialisation results in expected format
#[test]
fn test_serialize_to_header() -> Result<()> {
assert_eq!(
PostMsgMetadata {
size: 1_000_000,
viewtype: Viewtype::File,
filename: "test.bin".to_string(),
wh: None,
duration: None,
}
.to_header_value()?,
"{\"size\":1000000,\"viewtype\":\"File\",\"filename\":\"test.bin\"}"
);
assert_eq!(
PostMsgMetadata {
size: 5_342_765,
viewtype: Viewtype::Image,
filename: "vacation.png".to_string(),
wh: Some((1080, 1920)),
duration: None,
}
.to_header_value()?,
"{\"size\":5342765,\"viewtype\":\"Image\",\"filename\":\"vacation.png\",\"wh\":[1080,1920]}"
);
assert_eq!(
PostMsgMetadata {
size: 5_000,
viewtype: Viewtype::Audio,
filename: "audio-DD-MM-YY.ogg".to_string(),
wh: None,
duration: Some(152_310),
}
.to_header_value()?,
"{\"size\":5000,\"viewtype\":\"Audio\",\"filename\":\"audio-DD-MM-YY.ogg\",\"duration\":152310}"
);
Ok(())
}
/// Test that deserialisation from expected format works
/// This test will become important for compatibility between versions in the future
#[test]
fn test_deserialize_from_header() -> Result<()> {
assert_eq!(
serde_json::from_str::<PostMsgMetadata>(
"{\"size\":1000000,\"viewtype\":\"File\",\"filename\":\"test.bin\",\"wh\":null,\"duration\":null}"
)?,
PostMsgMetadata {
size: 1_000_000,
viewtype: Viewtype::File,
filename: "test.bin".to_string(),
wh: None,
duration: None,
}
);
assert_eq!(
serde_json::from_str::<PostMsgMetadata>(
"{\"size\":5342765,\"viewtype\":\"Image\",\"filename\":\"vacation.png\",\"wh\":[1080,1920]}"
)?,
PostMsgMetadata {
size: 5_342_765,
viewtype: Viewtype::Image,
filename: "vacation.png".to_string(),
wh: Some((1080, 1920)),
duration: None,
}
);
assert_eq!(
serde_json::from_str::<PostMsgMetadata>(
"{\"size\":5000,\"viewtype\":\"Audio\",\"filename\":\"audio-DD-MM-YY.ogg\",\"duration\":152310}"
)?,
PostMsgMetadata {
size: 5_000,
viewtype: Viewtype::Audio,
filename: "audio-DD-MM-YY.ogg".to_string(),
wh: None,
duration: Some(152_310),
}
);
Ok(())
}
}

View File

@@ -474,8 +474,10 @@ pub(crate) async fn delete_expired_messages(context: &Context, now: i64) -> Resu
// If you change which information is preserved here, also change `MsgId::trash()`
// and other places it references.
let mut del_msg_stmt = transaction.prepare(
"INSERT OR REPLACE INTO msgs (id, rfc724_mid, timestamp, chat_id)
SELECT ?1, rfc724_mid, timestamp, ? FROM msgs WHERE id=?1",
"
INSERT OR REPLACE INTO msgs (id, rfc724_mid, pre_rfc724_mid, timestamp, chat_id)
SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ? FROM msgs WHERE id=?1
",
)?;
let mut del_location_stmt =
transaction.prepare("DELETE FROM locations WHERE independent=1 AND id=?")?;
@@ -591,6 +593,7 @@ async fn next_expiration_timestamp(context: &Context) -> Option<i64> {
.min()
}
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiver<()>) {
loop {
let ephemeral_timestamp = next_expiration_timestamp(context).await;
@@ -648,6 +651,7 @@ pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiv
}
/// Schedules expired IMAP messages for deletion.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn delete_expired_imap_messages(context: &Context) -> Result<()> {
let now = time();
@@ -663,25 +667,19 @@ pub(crate) async fn delete_expired_imap_messages(context: &Context) -> Result<()
now - max(delete_server_after, MIN_DELETE_SERVER_AFTER),
),
};
let target = context.get_delete_msgs_target().await?;
context
.sql
.execute(
"UPDATE imap
SET target=?
SET target=''
WHERE rfc724_mid IN (
SELECT rfc724_mid FROM msgs
WHERE ((download_state = 0 AND timestamp < ?) OR
(download_state != 0 AND timestamp < ?) OR
(ephemeral_timestamp != 0 AND ephemeral_timestamp <= ?))
)",
(
&target,
threshold_timestamp,
threshold_timestamp_extended,
now,
),
(threshold_timestamp, threshold_timestamp_extended, now),
)
.await?;

View File

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

View File

@@ -417,12 +417,12 @@ pub enum EventType {
chat_id: ChatId,
},
/// One or more transports has changed.
/// One or more transports has changed or another transport is primary now.
///
/// UI should update the list.
///
/// This event is emitted when transport
/// synchronization messages arrives,
/// This event is emitted when a transport
/// synchronization message modifies transports,
/// but not when the UI modifies the transport list by itself.
TransportsModified,

View File

@@ -60,6 +60,9 @@ pub enum HeaderDef {
ChatGroupName,
ChatGroupNameChanged,
ChatGroupNameTimestamp,
ChatGroupDescription,
ChatGroupDescriptionChanged,
ChatGroupDescriptionTimestamp,
ChatVerified,
ChatGroupAvatar,
ChatUserAvatar,
@@ -103,6 +106,21 @@ pub enum HeaderDef {
/// used to encrypt and decrypt messages.
/// This secret is sent to a new member in the member-addition message.
ChatBroadcastSecret,
/// A message with a large attachment is split into two messages:
/// A pre-message, which contains everything but the attachment,
/// and a Post-Message.
/// The Pre-Message gets a `Chat-Post-Message-Id` header
/// referencing the Post-Message's rfc724_mid.
ChatPostMessageId,
/// Announces Post-Message metadata in a Pre-Message.
/// Contains a serialized `PostMsgMetadata` struct.
ChatPostMessageMetadata,
/// This message is preceded by a Pre-Message
/// and thus this message can be skipped while fetching messages.
/// This is an unprotected header.
ChatIsPostMessage,
/// [Autocrypt](https://autocrypt.org/) header.
Autocrypt,
@@ -148,6 +166,9 @@ pub enum HeaderDef {
impl HeaderDef {
/// Returns the corresponding header string.
///
/// Format is lower-kebab-case for easy comparisons.
/// This method is used in message receiving and testing.
pub fn get_headername(&self) -> &'static str {
self.into()
}

View File

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

View File

@@ -16,19 +16,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, Chattype, DC_VERSION_STR, ShowEmails};
use crate::contact::{Contact, ContactId, Modifier, Origin};
use crate::constants::{self, Blocked, DC_VERSION_STR, ShowEmails};
use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
@@ -52,12 +53,10 @@ use crate::transport::{
pub(crate) mod capabilities;
mod client;
mod idle;
pub mod scan_folders;
pub mod select_folder;
pub(crate) mod session;
use client::{Client, determine_capabilities};
use mailparse::SingleInfo;
use session::Session;
pub(crate) const GENERATED_PREFIX: &str = "GEN_";
@@ -67,7 +66,6 @@ const RFC724MID_UID: &str = "(UID BODY.PEEK[HEADER.FIELDS (\
X-MICROSOFT-ORIGINAL-MESSAGE-ID\
)])";
const BODY_FULL: &str = "(FLAGS BODY.PEEK[])";
const BODY_PARTIAL: &str = "(FLAGS RFC822.SIZE BODY.PEEK[HEADER])";
#[derive(Debug)]
pub(crate) struct Imap {
@@ -135,16 +133,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,
}
@@ -185,7 +182,7 @@ impl FolderMeaning {
FolderMeaning::Spam => None,
FolderMeaning::Inbox => Some(Config::ConfiguredInboxFolder),
FolderMeaning::Mvbox => Some(Config::ConfiguredMvboxFolder),
FolderMeaning::Trash => Some(Config::ConfiguredTrashFolder),
FolderMeaning::Trash => None,
FolderMeaning::Virtual => None,
}
}
@@ -211,6 +208,7 @@ impl<T: Iterator<Item = (i64, u32, String)>> Iterator for UidGrouper<T> {
// Tuple of folder, row IDs, and UID range as a string.
type Item = (String, Vec<i64>, String);
#[expect(clippy::arithmetic_side_effects)]
fn next(&mut self) -> Option<Self::Item> {
let (_, _, folder) = self.inner.peek().cloned()?;
@@ -359,10 +357,10 @@ impl Imap {
context,
self.proxy_config.clone(),
self.strict_tls,
connection_candidate,
&connection_candidate,
)
.await
.context("IMAP failed to connect")
.with_context(|| format!("IMAP failed to connect to {connection_candidate}"))
{
Ok(client) => client,
Err(err) => {
@@ -501,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)
@@ -552,6 +544,7 @@ impl Imap {
/// Fetches new messages.
///
/// Returns true if at least one message was fetched.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn fetch_new_messages(
&mut self,
context: &Context,
@@ -565,9 +558,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 {
@@ -593,6 +585,7 @@ impl Imap {
}
/// Returns number of messages processed and whether the function should be called again.
#[expect(clippy::arithmetic_side_effects)]
async fn fetch_new_msg_batch(
&mut self,
context: &Context,
@@ -615,11 +608,16 @@ impl Imap {
.context("prefetch")?;
let read_cnt = msgs.len();
let download_limit = context.download_limit().await?;
let mut uids_fetch = Vec::<(u32, bool /* partially? */)>::with_capacity(msgs.len() + 1);
let mut uids_fetch: Vec<u32> = Vec::new();
let mut available_post_msgs: Vec<String> = Vec::new();
let mut download_later: Vec<String> = Vec::new();
let mut uid_message_ids = BTreeMap::new();
let mut largest_uid_skipped = None;
let delete_target = context.get_delete_msgs_target().await?;
let download_limit: Option<u32> = context
.get_config_parsed(Config::DownloadLimit)
.await?
.filter(|&l| 0 < l);
// Store the info about IMAP messages in the database.
for (uid, ref fetch_response) in msgs {
@@ -632,6 +630,9 @@ impl Imap {
};
let message_id = prefetch_get_message_id(&headers);
let size = fetch_response
.size
.context("imap fetch response does not contain size")?;
// Determine the target folder where the message should be moved to.
//
@@ -661,7 +662,7 @@ impl Imap {
let _target;
let target = if delete {
&delete_target
""
} else {
_target = target_folder(context, folder, folder_meaning, &headers).await?;
&_target
@@ -706,14 +707,27 @@ impl Imap {
)
.await.context("prefetch_should_download")?
{
match download_limit {
Some(download_limit) => uids_fetch.push((
uid,
fetch_response.size.unwrap_or_default() > download_limit,
)),
None => uids_fetch.push((uid, false)),
}
uid_message_ids.insert(uid, message_id);
if headers
.get_header_value(HeaderDef::ChatIsPostMessage)
.is_some()
{
info!(context, "{message_id:?} is a post-message.");
available_post_msgs.push(message_id.clone());
if download_limit.is_none_or(|download_limit| size <= download_limit) {
download_later.push(message_id.clone());
}
largest_uid_skipped = Some(uid);
} else {
info!(context, "{message_id:?} is not a post-message.");
if download_limit.is_none_or(|download_limit| size <= download_limit) {
uids_fetch.push(uid);
uid_message_ids.insert(uid, message_id);
} else {
download_later.push(message_id.clone());
largest_uid_skipped = Some(uid);
}
};
} else {
largest_uid_skipped = Some(uid);
}
@@ -747,29 +761,10 @@ impl Imap {
};
let actually_download_messages_future = async {
let sender = sender;
let mut uids_fetch_in_batch = Vec::with_capacity(max(uids_fetch.len(), 1));
let mut fetch_partially = false;
uids_fetch.push((0, !uids_fetch.last().unwrap_or(&(0, false)).1));
for (uid, fp) in uids_fetch {
if fp != fetch_partially {
session
.fetch_many_msgs(
context,
folder,
uids_fetch_in_batch.split_off(0),
&uid_message_ids,
fetch_partially,
sender.clone(),
)
.await
.context("fetch_many_msgs")?;
fetch_partially = fp;
}
uids_fetch_in_batch.push(uid);
}
anyhow::Ok(())
session
.fetch_many_msgs(context, folder, uids_fetch, &uid_message_ids, sender)
.await
.context("fetch_many_msgs")
};
let (largest_uid_fetched, fetch_res) =
@@ -804,33 +799,36 @@ impl Imap {
chat::mark_old_messages_as_noticed(context, received_msgs).await?;
if fetch_res.is_ok() {
info!(
context,
"available_post_msgs: {}, download_later: {}.",
available_post_msgs.len(),
download_later.len(),
);
let trans_fn = |t: &mut rusqlite::Transaction| {
let mut stmt = t.prepare("INSERT OR IGNORE INTO available_post_msgs VALUES (?)")?;
for rfc724_mid in available_post_msgs {
stmt.execute((rfc724_mid,))
.context("INSERT OR IGNORE INTO available_post_msgs")?;
}
let mut stmt =
t.prepare("INSERT OR IGNORE INTO download (rfc724_mid, msg_id) VALUES (?,0)")?;
for rfc724_mid in download_later {
stmt.execute((rfc724_mid,))
.context("INSERT OR IGNORE INTO download")?;
}
Ok(())
};
context.sql.transaction(trans_fn).await?;
}
// Now fail if fetching failed, so we will
// establish a new session if this one is broken.
fetch_res?;
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 {
@@ -869,10 +867,7 @@ impl Session {
// Collect pairs of UID and Message-ID.
let mut msgs = BTreeMap::new();
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
let transport_id = self.transport_id();
if folder_exists {
let mut list = self
@@ -994,17 +989,6 @@ impl Session {
return Ok(());
}
Err(err) => {
if context.should_delete_to_trash().await? {
error!(
context,
"Cannot move messages {} to {}, no fallback to COPY/DELETE because \
delete_to_trash is set. Error: {:#}",
set,
target,
err,
);
return Err(err.into());
}
warn!(
context,
"Cannot move messages, fallback to COPY/DELETE {} to {}: {}",
@@ -1018,19 +1002,11 @@ impl Session {
// Server does not support MOVE or MOVE failed.
// Copy messages to the destination folder if needed and mark records for deletion.
let copy = !context.is_trash(target).await?;
if copy {
info!(
context,
"Server does not support MOVE, fallback to COPY/DELETE {} to {}", set, target
);
self.uid_copy(&set, &target).await?;
} else {
error!(
context,
"Server does not support MOVE, fallback to DELETE {} to {}", set, target,
);
}
info!(
context,
"Server does not support MOVE, fallback to COPY/DELETE {} to {}", set, target
);
self.uid_copy(&set, &target).await?;
context
.sql
.transaction(|transaction| {
@@ -1042,11 +1018,9 @@ impl Session {
})
.await
.context("Cannot plan deletion of messages")?;
if copy {
context.emit_event(EventType::ImapMessageMoved(format!(
"IMAP messages {set} copied to {target}"
)));
}
context.emit_event(EventType::ImapMessageMoved(format!(
"IMAP messages {set} copied to {target}"
)));
Ok(())
}
@@ -1078,10 +1052,7 @@ impl Session {
// MOVE/DELETE operations. This does not result in multiple SELECT commands
// being sent because `select_folder()` does nothing if the folder is already
// selected.
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
ensure!(folder_exists, "No folder {folder}");
// Empty target folder name means messages should be deleted.
@@ -1112,13 +1083,20 @@ 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? {
return Ok(());
}
let transport_id = self.transport_id();
let rows = context
.sql
.query_map_vec(
"SELECT imap.id, uid, folder FROM imap, imap_markseen
WHERE imap.id = imap_markseen.id AND target = folder
WHERE imap.id = imap_markseen.id
AND imap.transport_id=?
AND target = folder
ORDER BY folder, uid",
[],
(transport_id,),
|row| {
let rowid: i64 = row.get(0)?;
let uid: u32 = row.get(1)?;
@@ -1129,8 +1107,7 @@ impl Session {
.await?;
for (folder, rowid_set, uid_set) in UidGrouper::from(rows) {
let create = false;
let folder_exists = match self.select_with_uidvalidity(context, &folder, create).await {
let folder_exists = match self.select_with_uidvalidity(context, &folder).await {
Err(err) => {
warn!(
context,
@@ -1180,9 +1157,12 @@ impl Session {
return Ok(());
}
let create = false;
if context.get_config_bool(Config::TeamProfile).await? {
return Ok(());
}
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.select_with_uidvalidity(context, folder)
.await
.context("Failed to select folder")?;
if !folder_exists {
@@ -1274,41 +1254,6 @@ impl Session {
Ok(())
}
/// Gets the from, to and bcc addresses from all existing outgoing emails.
pub async fn get_all_recipients(&mut self, context: &Context) -> Result<Vec<SingleInfo>> {
let mut uids: Vec<_> = self
.uid_search(get_imap_self_sent_search_command(context).await?)
.await?
.into_iter()
.collect();
uids.sort_unstable();
let mut result = Vec::new();
for (_, uid_set) in build_sequence_sets(&uids)? {
let mut list = self
.uid_fetch(uid_set, "(UID BODY.PEEK[HEADER.FIELDS (FROM TO CC BCC)])")
.await
.context("IMAP Could not fetch")?;
while let Some(msg) = list.try_next().await? {
match get_fetch_headers(&msg) {
Ok(headers) => {
if let Some(from) = mimeparser::get_from(&headers)
&& context.is_self_addr(&from.addr).await?
{
result.extend(mimeparser::get_recipients(&headers));
}
}
Err(err) => {
warn!(context, "{}", err);
continue;
}
};
}
}
Ok(result)
}
/// Fetches a list of messages by server UID.
///
/// Sends pairs of UID and info about each downloaded message to the provided channel.
@@ -1323,13 +1268,13 @@ impl Session {
///
/// If the message is incorrect or there is a failure to write a message to the database,
/// it is skipped and the error is logged.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn fetch_many_msgs(
&mut self,
context: &Context,
folder: &str,
request_uids: Vec<u32>,
uid_message_ids: &BTreeMap<u32, String>,
fetch_partially: bool,
received_msgs_channel: Sender<(u32, Option<ReceivedMsg>)>,
) -> Result<()> {
if request_uids.is_empty() {
@@ -1337,25 +1282,10 @@ impl Session {
}
for (request_uids, set) in build_sequence_sets(&request_uids)? {
info!(
context,
"Starting a {} FETCH of message set \"{}\".",
if fetch_partially { "partial" } else { "full" },
set
);
let mut fetch_responses = self
.uid_fetch(
&set,
if fetch_partially {
BODY_PARTIAL
} else {
BODY_FULL
},
)
.await
.with_context(|| {
format!("fetching messages {} from folder \"{}\"", &set, folder)
})?;
info!(context, "Starting UID FETCH of message set \"{}\".", set);
let mut fetch_responses = self.uid_fetch(&set, BODY_FULL).await.with_context(|| {
format!("fetching messages {} from folder \"{}\"", &set, folder)
})?;
// Map from UIDs to unprocessed FETCH results. We put unprocessed FETCH results here
// when we want to process other messages first.
@@ -1412,11 +1342,7 @@ impl Session {
count += 1;
let is_deleted = fetch_response.flags().any(|flag| flag == Flag::Deleted);
let (body, partial) = if fetch_partially {
(fetch_response.header(), fetch_response.size) // `BODY.PEEK[HEADER]` goes to header() ...
} else {
(fetch_response.body(), None) // ... while `BODY.PEEK[]` goes to body() - and includes header()
};
let body = fetch_response.body();
if is_deleted {
info!(context, "Not processing deleted msg {}.", request_uid);
@@ -1450,7 +1376,7 @@ impl Session {
context,
"Passing message UID {} to receive_imf().", request_uid
);
let res = receive_imf_inner(context, rfc724_mid, body, is_seen, partial).await;
let res = receive_imf_inner(context, rfc724_mid, body, is_seen).await;
let received_msg = match res {
Err(err) => {
warn!(context, "receive_imf error: {err:#}.");
@@ -1507,6 +1433,7 @@ impl Session {
/// We get [`/shared/comment`](https://www.rfc-editor.org/rfc/rfc5464#section-6.2.1)
/// and [`/shared/admin`](https://www.rfc-editor.org/rfc/rfc5464#section-6.2.2)
/// metadata.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn update_metadata(&mut self, context: &Context) -> Result<()> {
let mut lock = context.metadata.write().await;
@@ -1533,7 +1460,7 @@ impl Session {
if m.entry == "/shared/vendor/deltachat/turn"
&& let Some(value) = m.value
{
match create_ice_servers_from_metadata(context, &value).await {
match create_ice_servers_from_metadata(&value).await {
Ok((parsed_timestamp, parsed_ice_servers)) => {
old_metadata.ice_servers_expiration_timestamp = parsed_timestamp;
old_metadata.ice_servers = parsed_ice_servers;
@@ -1550,7 +1477,7 @@ impl Session {
info!(context, "Will use fallback ICE servers.");
// Set expiration timestamp 7 days in the future so we don't request it again.
old_metadata.ice_servers_expiration_timestamp = time() + 3600 * 24 * 7;
old_metadata.ice_servers = create_fallback_ice_servers(context).await?;
old_metadata.ice_servers = create_fallback_ice_servers();
}
return Ok(());
}
@@ -1597,7 +1524,7 @@ impl Session {
}
"/shared/vendor/deltachat/turn" => {
if let Some(value) = m.value {
match create_ice_servers_from_metadata(context, &value).await {
match create_ice_servers_from_metadata(&value).await {
Ok((parsed_timestamp, parsed_ice_servers)) => {
ice_servers_expiration_timestamp = parsed_timestamp;
ice_servers = Some(parsed_ice_servers);
@@ -1616,7 +1543,7 @@ impl Session {
} else {
// Set expiration timestamp 7 days in the future so we don't request it again.
ice_servers_expiration_timestamp = time() + 3600 * 24 * 7;
create_fallback_ice_servers(context).await?
create_fallback_ice_servers()
};
*lock = Some(ServerMetadata {
@@ -1748,17 +1675,16 @@ impl Session {
/// Attempts to configure mvbox.
///
/// Tries to find any folder examining `folders` in the order they go. If none is found, tries
/// to create any folder in the same order. This method does not use LIST command to ensure that
/// Tries to find any folder examining `folders` in the order they go.
/// This method does not use LIST command to ensure that
/// configuration works even if mailbox lookup is forbidden via Access Control List (see
/// <https://datatracker.ietf.org/doc/html/rfc4314>).
///
/// Returns first found or created folder name.
/// Returns first found folder name.
async fn configure_mvbox<'a>(
&mut self,
context: &Context,
folders: &[&'a str],
create_mvbox: bool,
) -> Result<Option<&'a str>> {
// Close currently selected folder if needed.
// We are going to select folders using low-level EXAMINE operations below.
@@ -1775,34 +1701,12 @@ impl Session {
self.close().await?;
// Before moving emails to the mvbox we need to remember its UIDVALIDITY, otherwise
// emails moved before that wouldn't be fetched but considered "old" instead.
let create = false;
let folder_exists = self
.select_with_uidvalidity(context, folder, create)
.await?;
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
ensure!(folder_exists, "No MVBOX folder {:?}??", &folder);
return Ok(Some(folder));
}
}
if !create_mvbox {
return Ok(None);
}
// Some servers require namespace-style folder names like "INBOX.DeltaChat", so we try all
// the variants here.
for folder in folders {
match self
.select_with_uidvalidity(context, folder, create_mvbox)
.await
{
Ok(_) => {
info!(context, "MVBOX-folder {} created.", folder);
return Ok(Some(folder));
}
Err(err) => {
warn!(context, "Cannot create MVBOX-folder {:?}: {}", folder, err);
}
}
}
Ok(None)
}
}
@@ -1812,7 +1716,6 @@ impl Imap {
&mut self,
context: &Context,
session: &mut Session,
create_mvbox: bool,
) -> Result<()> {
let mut folders = session
.list(Some(""), Some("*"))
@@ -1853,7 +1756,7 @@ impl Imap {
let fallback_folder = format!("INBOX{delimiter}DeltaChat");
let mvbox_folder = session
.configure_mvbox(context, &["DeltaChat", &fallback_folder], create_mvbox)
.configure_mvbox(context, &["DeltaChat", &fallback_folder])
.await
.context("failed to configure mvbox")?;
@@ -2202,21 +2105,6 @@ pub(crate) fn create_message_id() -> String {
format!("{}{}", GENERATED_PREFIX, create_id())
}
/// Returns chat by prefetched headers.
async fn prefetch_get_chat(
context: &Context,
headers: &[mailparse::MailHeader<'_>],
) -> Result<Option<chat::Chat>> {
let parent = get_prefetch_parent_message(context, headers).await?;
if let Some(parent) = &parent {
return Ok(Some(
chat::Chat::load_from_db(context, parent.get_chat_id()).await?,
));
}
Ok(None)
}
/// Determines whether the message should be downloaded based on prefetched headers.
pub(crate) async fn prefetch_should_download(
context: &Context,
@@ -2224,26 +2112,18 @@ pub(crate) async fn prefetch_should_download(
message_id: &str,
mut flags: impl Iterator<Item = Flag<'_>>,
) -> Result<bool> {
if message::rfc724_mid_exists(context, message_id)
.await?
.is_some()
{
markseen_on_imap_table(context, message_id).await?;
if message::rfc724_mid_download_tried(context, message_id).await? {
if let Some(from) = mimeparser::get_from(headers)
&& context.is_self_addr(&from.addr).await?
{
markseen_on_imap_table(context, message_id).await?;
}
return Ok(false);
}
// We do not know the Message-ID or the Message-ID is missing (in this case, we create one in
// the further process).
if let Some(chat) = prefetch_get_chat(context, headers).await?
&& chat.typ == Chattype::Group
&& !chat.id.is_special()
{
// This might be a group command, like removing a group member.
// We really need to fetch this to avoid inconsistent group state.
return Ok(true);
}
let maybe_ndn = if let Some(from) = headers.get_header_value(HeaderDef::From_) {
let from = from.to_ascii_lowercase();
from.contains("mailer-daemon") || from.contains("mail-daemon")
@@ -2277,11 +2157,10 @@ pub(crate) async fn prefetch_should_download(
let accepted_contact = origin.is_known();
let is_reply_to_chat_message = get_prefetch_parent_message(context, headers)
.await?
.map(|parent| match parent.is_dc_message {
.is_some_and(|parent| match parent.is_dc_message {
MessengerMessage::No => false,
MessengerMessage::Yes | MessengerMessage::Reply => true,
})
.unwrap_or_default();
});
let show_emails =
ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?).unwrap_or_default();
@@ -2472,18 +2351,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
@@ -2502,6 +2369,7 @@ async fn should_ignore_folder(
/// Builds a list of sequence/uid sets. The returned sets have each no more than around 1000
/// characters because according to <https://tools.ietf.org/html/rfc2683#section-3.2.1.5>
/// command lines should not be much more than 1000 chars (servers should allow at least 8000 chars)
#[expect(clippy::arithmetic_side_effects)]
fn build_sequence_sets(uids: &[u32]) -> Result<Vec<(Vec<u32>, String)>> {
// first, try to find consecutive ranges:
let mut ranges: Vec<UidRange> = vec![];
@@ -2556,65 +2424,23 @@ impl std::fmt::Display for UidRange {
}
}
}
async fn add_all_recipients_as_contacts(
context: &Context,
session: &mut Session,
folder: Config,
) -> Result<()> {
let mailbox = if let Some(m) = context.get_config(folder).await? {
m
} else {
info!(
context,
"Folder {} is not configured, skipping fetching contacts from it.", folder
);
return Ok(());
};
let create = false;
let folder_exists = session
.select_with_uidvalidity(context, &mailbox, create)
.await
.with_context(|| format!("could not select {mailbox}"))?;
if !folder_exists {
return Ok(());
pub(crate) async fn get_watched_folder_configs(context: &Context) -> Result<Vec<Config>> {
let mut res = vec![Config::ConfiguredInboxFolder];
if context.should_watch_mvbox().await? {
res.push(Config::ConfiguredMvboxFolder);
}
Ok(res)
}
let recipients = session
.get_all_recipients(context)
.await
.context("could not get recipients")?;
let mut any_modified = false;
for recipient in recipients {
let recipient_addr = match ContactAddress::new(&recipient.addr) {
Err(err) => {
warn!(
context,
"Could not add contact for recipient with address {:?}: {:#}",
recipient.addr,
err
);
continue;
}
Ok(recipient_addr) => recipient_addr,
};
let (_, modified) = Contact::add_or_lookup(
context,
&recipient.display_name.unwrap_or_default(),
&recipient_addr,
Origin::OutgoingTo,
)
.await?;
if modified != Modifier::None {
any_modified = true;
pub(crate) async fn get_watched_folders(context: &Context) -> Result<Vec<String>> {
let mut res = Vec::new();
for folder_config in get_watched_folder_configs(context).await? {
if let Some(folder) = context.get_config(folder_config).await? {
res.push(folder);
}
}
if any_modified {
context.emit_event(EventType::ContactsChanged(None));
}
Ok(())
Ok(res)
}
#[cfg(test)]

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
use super::*;
use crate::contact::Contact;
use crate::test_utils::TestContext;
use crate::transport::add_pseudo_transport;
#[test]
fn test_get_folder_meaning_by_name() {
@@ -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)

View File

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

View File

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

View File

@@ -5,11 +5,9 @@ use anyhow::{Context as _, Result};
use async_imap::Session as ImapSession;
use async_imap::types::Mailbox;
use futures::TryStreamExt;
use tokio::sync::Mutex;
use crate::imap::capabilities::Capabilities;
use crate::net::session::SessionStream;
use crate::tools;
/// Prefetch:
/// - Message-ID to check if we already have the message.
@@ -17,6 +15,7 @@ use crate::tools;
/// - Chat-Version to check if a message is a chat message
/// - Autocrypt-Setup-Message to check if a message is an autocrypt setup message,
/// not necessarily sent by Delta Chat.
/// - Chat-Is-Post-Message to skip it in background fetch or when it is > `DownloadLimit`.
const PREFETCH_FLAGS: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (\
MESSAGE-ID \
DATE \
@@ -24,6 +23,7 @@ const PREFETCH_FLAGS: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIE
FROM \
IN-REPLY-TO REFERENCES \
CHAT-VERSION \
CHAT-IS-POST-MESSAGE \
AUTO-SUBMITTED \
AUTOCRYPT-SETUP-MESSAGE\
)])";
@@ -44,8 +44,6 @@ pub(crate) struct Session {
pub selected_folder_needs_expunge: bool,
pub(crate) last_full_folder_scan: Mutex<Option<tools::Time>>,
/// True if currently selected folder has new messages.
///
/// Should be false if no folder is currently selected.
@@ -82,7 +80,6 @@ impl Session {
selected_folder: None,
selected_mailbox: None,
selected_folder_needs_expunge: false,
last_full_folder_scan: Mutex::new(None),
new_mail: false,
resync_request_sender,
}
@@ -130,6 +127,7 @@ impl Session {
/// Prefetch `n_uids` messages starting from `uid_next`. Returns a list of fetch results in the
/// order of ascending delivery time to the server (INTERNALDATE).
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn prefetch(
&mut self,
uid_next: u32,

View File

@@ -19,7 +19,7 @@ use crate::config::Config;
use crate::context::Context;
use crate::e2ee;
use crate::events::EventType;
use crate::key::{self, DcKey, DcSecretKey, SignedPublicKey, SignedSecretKey};
use crate::key::{self, DcKey, SignedPublicKey, SignedSecretKey};
use crate::log::{LogExt, warn};
use crate::pgp;
use crate::qr::DCBACKUP_VERSION;
@@ -142,7 +142,7 @@ pub async fn has_backup(_context: &Context, dir_name: &Path) -> Result<String> {
async fn set_self_key(context: &Context, armored: &str) -> Result<()> {
let private_key = SignedSecretKey::from_asc(armored)?;
let public_key = private_key.split_public_key()?;
let public_key = private_key.to_public_key();
let keypair = pgp::KeyPair {
public: public_key,
@@ -153,7 +153,7 @@ async fn set_self_key(context: &Context, armored: &str) -> Result<()> {
info!(
context,
"stored self key: {:?}",
keypair.secret.public_key().key_id()
keypair.secret.public_key().legacy_key_id()
);
Ok(())
}
@@ -293,6 +293,7 @@ impl<R> AsyncRead for ProgressReader<R>
where
R: AsyncRead,
{
#[expect(clippy::arithmetic_side_effects)]
fn poll_read(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
@@ -379,17 +380,6 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
if res.is_ok() {
res = check_backup_version(context).await;
}
if res.is_ok() {
// All recent backups have `bcc_self` set to "1" before export.
//
// Setting `bcc_self` to "1" on export was introduced on 2024-12-17
// in commit 21664125d798021be75f47d5b0d5006d338b4531
//
// We additionally try to set `bcc_self` to "1" after import here
// for compatibility with older backups,
// but eventually this code can be removed.
res = context.set_config(Config::BccSelf, Some("1")).await;
}
fs::remove_file(unpacked_database)
.await
.context("cannot remove unpacked database")
@@ -449,6 +439,7 @@ fn get_next_backup_path(
/// Exports the database to a separate file with the given passphrase.
///
/// Set passphrase to empty string to export the database unencrypted.
#[expect(clippy::arithmetic_side_effects)]
async fn export_backup(context: &Context, dir: &Path, passphrase: String) -> Result<()> {
// get a fine backup file name (the name includes the date so that multiple backup instances are possible)
let now = time();
@@ -522,6 +513,7 @@ impl<W> AsyncWrite for ProgressWriter<W>
where
W: AsyncWrite,
{
#[expect(clippy::arithmetic_side_effects)]
fn poll_write(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
@@ -601,6 +593,7 @@ async fn import_secret_key(context: &Context, path: &Path) -> Result<()> {
/// containing secret keys are imported and the last successfully
/// imported which does not contain "legacy" in its filename
/// is set as the default.
#[expect(clippy::arithmetic_side_effects)]
async fn import_self_keys(context: &Context, path: &Path) -> Result<()> {
let attr = tokio::fs::metadata(path).await?;
@@ -654,6 +647,7 @@ async fn import_self_keys(context: &Context, path: &Path) -> Result<()> {
Ok(())
}
#[expect(clippy::arithmetic_side_effects)]
async fn export_self_keys(context: &Context, dir: &Path) -> Result<()> {
let mut export_errors = 0;
@@ -800,7 +794,7 @@ async fn check_backup_version(context: &Context) -> Result<()> {
let version = (context.sql.get_raw_config_int("backup_version").await?).unwrap_or(2);
ensure!(
version <= DCBACKUP_VERSION,
"Backup too new, please update Delta Chat"
"This profile is from a newer version of Delta Chat. Please update Delta Chat and try again (profile version is v{version}, the latest supported is v{DCBACKUP_VERSION})"
);
Ok(())
}

View File

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

View File

@@ -189,10 +189,11 @@ impl BackupProvider {
let blobdir = BlobDirContents::new(&context).await?;
let mut file_size = 0;
file_size += dbfile.metadata()?.len();
let mut file_size = dbfile.metadata()?.len();
for blob in blobdir.iter() {
file_size += blob.to_abs_path().metadata()?.len()
file_size = file_size
.checked_add(blob.to_abs_path().metadata()?.len())
.context("File size overflow")?;
}
send_stream.write_all(&file_size.to_be_bytes()).await?;

View File

@@ -21,7 +21,7 @@ pub async fn store_self_keypair(context: &Context, keypair: &KeyPair) -> Result<
}
pub async fn parse_and_get_text(context: &Context, imf_raw: &[u8]) -> Result<String> {
let mime_parser = MimeMessage::from_bytes(context, imf_raw, None).await?;
let mime_parser = MimeMessage::from_bytes(context, imf_raw).await?;
Ok(mime_parser.parts.into_iter().next().unwrap().msg)
}

View File

@@ -10,7 +10,7 @@ use deltachat_contact_tools::EmailAddress;
use pgp::composed::Deserializable;
pub use pgp::composed::{SignedPublicKey, SignedSecretKey};
use pgp::ser::Serialize;
use pgp::types::{KeyDetails, KeyId, Password};
use pgp::types::{KeyDetails, KeyId};
use tokio::runtime::Handle;
use crate::context::Context;
@@ -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.
@@ -274,7 +264,7 @@ impl DcKey for SignedPublicKey {
}
fn key_id(&self) -> KeyId {
KeyDetails::key_id(self)
KeyDetails::legacy_key_id(self)
}
}
@@ -301,30 +291,7 @@ impl DcKey for SignedSecretKey {
}
fn key_id(&self) -> KeyId {
KeyDetails::key_id(&**self)
}
}
/// Deltachat extension trait for secret keys.
///
/// Provides some convenience wrappers only applicable to [SignedSecretKey].
pub(crate) trait DcSecretKey {
/// Create a public key from a private one.
fn split_public_key(&self) -> Result<SignedPublicKey>;
}
impl DcSecretKey for SignedSecretKey {
fn split_public_key(&self) -> Result<SignedPublicKey> {
self.verify()?;
let unsigned_pubkey = self.public_key();
let mut rng = rand_old::thread_rng();
let signed_pubkey = unsigned_pubkey.sign(
&mut rng,
&self.primary_key,
self.primary_key.public_key(),
&Password::empty(),
)?;
Ok(signed_pubkey)
KeyDetails::legacy_key_id(&**self)
}
}
@@ -436,7 +403,7 @@ pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) ->
/// Use import/export APIs instead.
pub async fn preconfigure_keypair(context: &Context, secret_data: &str) -> Result<()> {
let secret = SignedSecretKey::from_asc(secret_data)?;
let public = secret.split_public_key()?;
let public = secret.to_public_key();
let keypair = KeyPair { public, secret };
store_self_keypair(context, &keypair).await?;
Ok(())
@@ -712,12 +679,6 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
assert_eq!(res0.unwrap(), res1.unwrap());
}
#[test]
fn test_split_key() {
let pubkey = KEYPAIR.secret.split_public_key().unwrap();
assert_eq!(pubkey.primary_key, KEYPAIR.public.primary_key);
}
/// Tests that setting a default key second time is not allowed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_save_self_key_twice() {

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,9 @@ use std::str;
use anyhow::{Context as _, Result, ensure, format_err};
use deltachat_contact_tools::{VcardContact, parse_vcard};
use deltachat_derive::{FromSql, ToSql};
use humansize::BINARY;
use humansize::format_size;
use num_traits::FromPrimitive;
use serde::{Deserialize, Serialize};
use tokio::{fs, io};
@@ -84,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)?;
@@ -128,10 +129,12 @@ impl MsgId {
.sql
.execute(
// If you change which information is preserved here, also change
// `delete_expired_messages()` and which information `receive_imf::add_parts()`
// still adds to the db if chat_id is TRASH.
"INSERT OR REPLACE INTO msgs (id, rfc724_mid, timestamp, chat_id, deleted)
SELECT ?1, rfc724_mid, timestamp, ?, ? FROM msgs WHERE id=?1",
// `ChatId::delete_ex()`, `delete_expired_messages()` and which information
// `receive_imf::add_parts()` still adds to the db if chat_id is TRASH.
"
INSERT OR REPLACE INTO msgs (id, rfc724_mid, pre_rfc724_mid, timestamp, chat_id, deleted)
SELECT ?1, rfc724_mid, pre_rfc724_mid, timestamp, ?, ? FROM msgs WHERE id=?1
",
(self, DC_CHAT_ID_TRASH, on_server),
)
.await?;
@@ -198,6 +201,7 @@ impl MsgId {
}
/// Returns detailed message information in a multi-line text form.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_info(self, context: &Context) -> Result<String> {
let msg = Message::load_from_db(context, self).await?;
@@ -207,10 +211,9 @@ impl MsgId {
ret += &format!("Sent: {fts}");
let from_contact = Contact::get_by_id(context, msg.from_id).await?;
let name = from_contact.get_name_n_addr();
let name = from_contact.get_display_name();
if let Some(override_sender_name) = msg.get_override_sender_name() {
let addr = from_contact.get_addr();
ret += &format!(" by ~{override_sender_name} ({addr})");
ret += &format!(" by ~{override_sender_name}");
} else {
ret += &format!(" by {name}");
}
@@ -258,7 +261,7 @@ impl MsgId {
let name = Contact::get_by_id(context, contact_id)
.await
.map(|contact| contact.get_name_n_addr())
.map(|contact| contact.get_display_name().to_owned())
.unwrap_or_default();
ret += &format!(" by {name}");
@@ -430,6 +433,10 @@ pub struct Message {
pub(crate) ephemeral_timer: EphemeralTimer,
pub(crate) ephemeral_timestamp: i64,
pub(crate) text: String,
/// Text that is added to the end of Message.text
///
/// Currently used for adding the download information on pre-messages
pub(crate) additional_text: String,
/// Message subject.
///
@@ -438,12 +445,15 @@ pub struct Message {
/// `Message-ID` header value.
pub(crate) rfc724_mid: String,
/// `Message-ID` header value of the pre-message, if any.
pub(crate) pre_rfc724_mid: String,
/// `In-Reply-To` header value.
pub(crate) in_reply_to: Option<String>,
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>,
@@ -488,42 +498,42 @@ impl Message {
!id.is_special(),
"Can not load special message ID {id} from DB"
);
let msg = context
let mut msg = context
.sql
.query_row_optional(
concat!(
"SELECT",
" m.id AS id,",
" rfc724_mid AS 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")?;
@@ -550,6 +560,7 @@ impl Message {
let msg = Message {
id: row.get("id")?,
rfc724_mid: row.get::<_, String>("rfc724mid")?,
pre_rfc724_mid: row.get::<_, String>("pre_rfc724mid")?,
in_reply_to: row
.get::<_, Option<String>>("mime_in_reply_to")?
.and_then(|in_reply_to| parse_message_id(&in_reply_to).ok()),
@@ -570,10 +581,12 @@ impl Message {
original_msg_id: row.get("original_msg_id")?,
mime_modified: row.get("mime_modified")?,
text,
additional_text: String::new(),
subject: row.get("subject")?,
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(),
@@ -584,9 +597,48 @@ impl Message {
.await
.with_context(|| format!("failed to load message {id} from the database"))?;
if let Some(msg) = &mut msg {
msg.additional_text =
Self::get_additional_text(context, msg.download_state, &msg.param).await?;
}
Ok(msg)
}
/// Returns additional text which is appended to the message's text field
/// when it is loaded from the database.
/// Currently this is used to add infomation to pre-messages of what the download will be and how large it is
async fn get_additional_text(
context: &Context,
download_state: DownloadState,
param: &Params,
) -> Result<String> {
if download_state != DownloadState::Done {
let file_size = param
.get(Param::PostMessageFileBytes)
.and_then(|s| s.parse().ok())
.map(|file_size: usize| format_size(file_size, BINARY))
.unwrap_or("?".to_owned());
let viewtype = param
.get_i64(Param::PostMessageViewtype)
.and_then(Viewtype::from_i64)
.unwrap_or(Viewtype::Unknown);
let file_name = param
.get(Param::Filename)
.map(sanitize_filename)
.unwrap_or("?".to_owned());
return match viewtype {
Viewtype::File => Ok(format!(" [{file_name} {file_size}]")),
_ => {
let translated_viewtype = viewtype.to_locale_string(context).await;
Ok(format!(" [{translated_viewtype} {file_size}]"))
}
};
}
Ok(String::new())
}
/// Returns the MIME type of an attached file if it exists.
///
/// If the MIME type is not known, the function guesses the MIME type
@@ -768,8 +820,12 @@ impl Message {
}
/// Returns the text of the message.
///
/// Currently this includes `additional_text`, but this may change in future, when the UIs show
/// the necessary info themselves.
#[expect(clippy::arithmetic_side_effects)]
pub fn get_text(&self) -> String {
self.text.clone()
self.text.clone() + &self.additional_text
}
/// Returns message subject.
@@ -791,7 +847,16 @@ impl Message {
}
/// Returns the size of the file in bytes, if applicable.
/// If message is a pre-message, then this returns the size of the file to be downloaded.
pub async fn get_filebytes(&self, context: &Context) -> Result<Option<u64>> {
if self.download_state != DownloadState::Done
&& let Some(file_size) = self
.param
.get(Param::PostMessageFileBytes)
.and_then(|s| s.parse().ok())
{
return Ok(Some(file_size));
}
if let Some(path) = self.param.get_file_path(context)? {
Ok(Some(get_filebytes(context, &path).await.with_context(
|| format!("failed to get {} size in bytes", path.display()),
@@ -801,6 +866,19 @@ impl Message {
}
}
/// If message is a Pre-Message,
/// then this returns the viewtype it will have when it is downloaded.
#[cfg(test)]
pub(crate) fn get_post_message_viewtype(&self) -> Option<Viewtype> {
if self.download_state != DownloadState::Done {
return self
.param
.get_i64(Param::PostMessageViewtype)
.and_then(Viewtype::from_i64);
}
None
}
/// Returns width of associated image or video file.
pub fn get_width(&self) -> i32 {
self.param.get_int(Param::Width).unwrap_or_default()
@@ -850,11 +928,10 @@ impl Message {
let contact = if self.from_id != ContactId::SELF {
match chat.typ {
Chattype::Group
| Chattype::OutBroadcast
| Chattype::InBroadcast
| Chattype::Mailinglist => Some(Contact::get_by_id(context, self.from_id).await?),
Chattype::Single => None,
Chattype::Group | Chattype::Mailinglist => {
Some(Contact::get_by_id(context, self.from_id).await?)
}
Chattype::Single | Chattype::OutBroadcast | Chattype::InBroadcast => None,
}
} else {
None
@@ -889,6 +966,7 @@ impl Message {
///
/// A message has a deviating timestamp when it is sent on
/// another day as received/sorted by.
#[expect(clippy::arithmetic_side_effects)]
pub fn has_deviating_timestamp(&self) -> bool {
let cnv_to_local = gm2local_offset();
let sort_timestamp = self.get_sort_timestamp() + cnv_to_local;
@@ -905,7 +983,7 @@ impl Message {
/// Returns true if the message is a forwarded message.
pub fn is_forwarded(&self) -> bool {
0 != self.param.get_int(Param::Forwarded).unwrap_or_default()
self.param.get_int(Param::Forwarded).is_some()
}
/// Returns true if the message is edited.
@@ -930,6 +1008,7 @@ impl Message {
pub async fn get_info_contact_id(&self, context: &Context) -> Result<Option<ContactId>> {
match self.param.get_cmd() {
SystemMessage::GroupNameChanged
| SystemMessage::GroupDescriptionChanged
| SystemMessage::GroupImageChanged
| SystemMessage::EphemeralTimerChanged => {
if self.from_id != ContactId::INFO {
@@ -1431,6 +1510,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)
@@ -1677,13 +1766,21 @@ pub async fn delete_msgs_ex(
modified_chat_ids.insert(msg.chat_id);
deleted_rfc724_mid.push(msg.rfc724_mid.clone());
let target = context.get_delete_msgs_target().await?;
let update_db = |trans: &mut rusqlite::Transaction| {
trans.execute(
"UPDATE imap SET target=? WHERE rfc724_mid=?",
(target, msg.rfc724_mid),
)?;
let mut stmt = trans.prepare("UPDATE imap SET target='' WHERE rfc724_mid=?")?;
stmt.execute((&msg.rfc724_mid,))?;
if !msg.pre_rfc724_mid.is_empty() {
stmt.execute((&msg.pre_rfc724_mid,))?;
}
trans.execute("DELETE FROM smtp WHERE msg_id=?", (msg_id,))?;
trans.execute(
"DELETE FROM download WHERE rfc724_mid=?",
(&msg.rfc724_mid,),
)?;
trans.execute(
"DELETE FROM available_post_msgs WHERE rfc724_mid=?",
(&msg.rfc724_mid,),
)?;
Ok(())
};
if let Err(e) = context.sql.transaction(update_db).await {
@@ -1752,11 +1849,11 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
"SELECT
m.chat_id AS chat_id,
m.state AS state,
m.download_state as download_state,
m.ephemeral_timer AS ephemeral_timer,
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
@@ -1765,10 +1862,10 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
|row| {
let chat_id: ChatId = row.get("chat_id")?;
let state: MessageState = row.get("state")?;
let download_state: DownloadState = row.get("download_state")?;
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")?;
@@ -1777,10 +1874,10 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
id,
chat_id,
state,
download_state,
param,
from_id,
rfc724_mid,
hidden,
visibility,
blocked.unwrap_or_default(),
),
@@ -1810,31 +1907,25 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
id,
curr_chat_id,
curr_state,
curr_download_state,
curr_param,
curr_from_id,
curr_rfc724_mid,
curr_hidden,
curr_visibility,
curr_blocked,
),
_curr_ephemeral_timer,
) in msgs
{
if curr_download_state != DownloadState::Done {
if curr_state == MessageState::InFresh {
// Don't mark partially downloaded messages as seen or send a read receipt since
// they are not really seen by the user.
update_msg_state(context, id, MessageState::InNoticed).await?;
updated_chat_ids.insert(curr_chat_id);
}
} else if curr_state == MessageState::InFresh || curr_state == MessageState::InNoticed {
if curr_state == MessageState::InFresh || curr_state == MessageState::InNoticed {
update_msg_state(context, id, MessageState::InSeen).await?;
info!(context, "Seen message {}.", id);
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
@@ -1842,25 +1933,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 {
@@ -2021,6 +2122,7 @@ pub async fn get_request_msg_cnt(context: &Context) -> usize {
/// Returns the number of messages that are older than the given number of seconds.
/// This includes e-mails downloaded due to the `show_emails` option.
/// Messages in the "saved messages" folder are not counted as they will not be deleted automatically.
#[expect(clippy::arithmetic_side_effects)]
pub async fn estimate_deletion_cnt(
context: &Context,
from_server: bool,
@@ -2098,7 +2200,7 @@ pub(crate) async fn rfc724_mid_exists_ex(
.query_row_optional(
&("SELECT id, timestamp_sent, MIN(".to_string()
+ expr
+ ") FROM msgs WHERE rfc724_mid=?
+ ") FROM msgs WHERE rfc724_mid=?1 OR pre_rfc724_mid=?1
HAVING COUNT(*) > 0 -- Prevent MIN(expr) from returning NULL when there are no rows.
ORDER BY timestamp_sent DESC"),
(rfc724_mid,),
@@ -2113,6 +2215,32 @@ pub(crate) async fn rfc724_mid_exists_ex(
Ok(res)
}
/// Returns `true` iff there is a message
/// with the given `rfc724_mid`
/// and a download state other than `DownloadState::Available`,
/// i.e. it was already tried to download the message or it's sent locally.
pub(crate) async fn rfc724_mid_download_tried(context: &Context, rfc724_mid: &str) -> Result<bool> {
let rfc724_mid = rfc724_mid.trim_start_matches('<').trim_end_matches('>');
if rfc724_mid.is_empty() {
warn!(
context,
"Empty rfc724_mid passed to rfc724_mid_download_tried"
);
return Ok(false);
}
let res = context
.sql
.exists(
"SELECT COUNT(*) FROM msgs
WHERE rfc724_mid=? AND download_state<>?",
(rfc724_mid, DownloadState::Available),
)
.await?;
Ok(res)
}
/// Given a list of Message-IDs, returns the most relevant message found in the database.
///
/// Relevance here is `(download_state == Done, index)`, where `index` is an index of Message-ID in

View File

@@ -326,79 +326,7 @@ async fn test_markseen_msgs() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markseen_not_downloaded_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.set_config(Config::DownloadLimit, Some("1")).await?;
let bob = &tcm.bob().await;
let bob_chat_id = bob.create_chat(alice).await.id;
alice.create_chat(bob).await; // Make sure the chat is accepted.
tcm.section("Bob sends a large message to Alice");
let file_bytes = include_bytes!("../../test-data/image/screenshot.png");
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)?;
let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await;
tcm.section("Alice receives a large message from Bob");
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert!(!msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert_eq!(msg.state, MessageState::InFresh);
markseen_msgs(alice, vec![msg.id]).await?;
// A not downloaded message can be seen only if it's seen on another device.
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
// Marking the message as seen again is a no op.
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::InProgress)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::Failure)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::Undecipherable)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
assert!(
!alice
.sql
.exists("SELECT COUNT(*) FROM smtp_mdns", ())
.await?
);
alice.set_config(Config::DownloadLimit, None).await?;
// Let's assume that Alice and Bob resolved the problem with encryption.
let old_msg = msg;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, old_msg.chat_id);
assert_eq!(msg.download_state, DownloadState::Done);
assert!(msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert!(msg.get_showpadlock());
// The message state mustn't be downgraded to `InFresh`.
assert_eq!(msg.state, MessageState::InNoticed);
markseen_msgs(alice, vec![msg.id]).await?;
let msg = Message::load_from_db(alice, msg.id).await?;
assert_eq!(msg.state, MessageState::InSeen);
assert_eq!(
alice
.sql
.count("SELECT COUNT(*) FROM smtp_mdns", ())
.await?,
1
);
Ok(())
}
/// Message has been seen on another device when fully downloaded. `state` should be updated.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_msg_seen_on_imap_when_downloaded() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -411,20 +339,17 @@ async fn test_msg_seen_on_imap_when_downloaded() -> Result<()> {
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)?;
let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await;
let msg = alice.recv_msg(&sent_msg).await;
let pre_msg = bob.pop_sent_msg().await;
let msg = alice.recv_msg(&pre_msg).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert_eq!(msg.state, MessageState::InFresh);
alice.set_config(Config::DownloadLimit, None).await?;
let seen = true;
let rcvd_msg = receive_imf(alice, sent_msg.payload().as_bytes(), seen)
.await
.unwrap()
.unwrap();
assert_eq!(rcvd_msg.chat_id, msg.chat_id);
let msg = Message::load_from_db(alice, *rcvd_msg.msg_ids.last().unwrap())
.await
.await?
.unwrap();
assert_eq!(rcvd_msg.chat_id, DC_CHAT_ID_TRASH);
let msg = Message::load_from_db(alice, msg.id).await?;
assert_eq!(msg.download_state, DownloadState::Done);
assert!(msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert!(msg.get_showpadlock());
@@ -432,6 +357,60 @@ async fn test_msg_seen_on_imap_when_downloaded() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_pre_and_post_msgs_deleted() -> Result<()> {
let reorder = false;
test_pre_and_post_msgs_deleted_ex(reorder).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_reordered_pre_and_post_msgs_deleted() -> Result<()> {
let reorder = true;
test_pre_and_post_msgs_deleted_ex(reorder).await
}
async fn test_pre_and_post_msgs_deleted_ex(reorder: bool) -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = alice.create_group_with_members("", &[bob]).await;
let file_bytes = include_bytes!("../../test-data/image/screenshot.gif");
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(alice, "a.jpg", file_bytes, None)?;
let full_msg = alice.send_msg(alice_chat_id, &mut msg).await;
let pre_msg = alice.pop_sent_msg().await;
let rfc724_mid_pre = bob.parse_msg(&pre_msg).await.get_rfc724_mid().unwrap();
let msg = if reorder {
let msg = bob.recv_msg(&full_msg).await;
bob.recv_msg_trash(&pre_msg).await;
Message::load_from_db(bob, msg.id).await?
} else {
let msg = bob.recv_msg(&pre_msg).await;
bob.recv_msg_trash(&full_msg).await;
msg
};
assert_ne!(rfc724_mid_pre, msg.rfc724_mid);
for (rfc724_mid, uid) in [(&rfc724_mid_pre, 1), (&msg.rfc724_mid, 2)] {
bob.sql
.execute(
"INSERT INTO imap (transport_id, rfc724_mid, folder, uid, target, uidvalidity) VALUES (1, ?, 'INBOX', ?, 'INBOX', 12345)",
(rfc724_mid, uid),
)
.await?;
}
delete_msgs(bob, &[msg.id]).await?;
assert_eq!(
bob.sql
.count("SELECT COUNT(*) FROM imap WHERE target!=''", ())
.await?,
0
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_state() -> Result<()> {
let alice = TestContext::new_alice().await;

View File

@@ -21,6 +21,7 @@ use crate::constants::{ASM_SUBJECT, BROADCAST_INCOMPATIBILITY_MSG};
use crate::constants::{Chattype, DC_FROM_HANDSHAKE};
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::download::PostMsgMetadata;
use crate::e2ee::EncryptHelper;
use crate::ensure_and_debug_assert;
use crate::ephemeral::Timer as EphemeralTimer;
@@ -59,6 +60,17 @@ pub enum Loaded {
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum PreMessageMode {
/// adds the Chat-Is-Post-Message header in unprotected part
Post,
/// adds the Chat-Post-Message-ID header to protected part
/// also adds metadata and explicitly excludes attachment
Pre { post_msg_rfc724_mid: String },
/// Atomic ("normal") message.
None,
}
/// Helper to construct mime messages.
#[derive(Debug, Clone)]
pub struct MimeFactory {
@@ -94,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)>>,
@@ -146,6 +159,9 @@ pub struct MimeFactory {
/// This field is used to sustain the topic id of webxdcs needed for peer channels.
webxdc_topic: Option<TopicId>,
/// Pre-message / post-message / atomic message.
pre_message_mode: PreMessageMode,
}
/// Result of rendering a message, ready to be submitted to a send job.
@@ -179,6 +195,7 @@ fn new_address_with_name(name: &str, address: String) -> Address<'static> {
}
impl MimeFactory {
#[expect(clippy::arithmetic_side_effects)]
pub async fn from_msg(context: &Context, msg: Message) -> Result<MimeFactory> {
let now = time();
let chat = Chat::load_from_db(context, msg.chat_id).await?;
@@ -219,7 +236,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() {
@@ -232,6 +248,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)
@@ -291,13 +338,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 {
@@ -411,8 +451,16 @@ impl MimeFactory {
},
)
.await?;
let recipient_ids: Vec<_> = recipient_ids.into_iter().collect();
ContactId::scaleup_origin(context, &recipient_ids, Origin::OutgoingTo).await?;
let recipient_ids: Vec<_> = recipient_ids
.into_iter()
.filter(|id| *id != ContactId::SELF)
.collect();
if recipient_ids.len() == 1
&& msg.param.get_cmd() != SystemMessage::MemberRemovedFromGroup
&& chat.typ != Chattype::OutBroadcast
{
ContactId::scaleup_origin(context, &recipient_ids, Origin::OutgoingTo).await?;
}
if !msg.is_system_message()
&& msg.param.get_int(Param::Reaction).unwrap_or_default() == 0
@@ -500,6 +548,7 @@ impl MimeFactory {
sync_ids_to_delete: None,
attach_selfavatar,
webxdc_topic,
pre_message_mode: PreMessageMode::None,
};
Ok(factory)
}
@@ -515,7 +564,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 {
@@ -548,6 +599,7 @@ impl MimeFactory {
sync_ids_to_delete: None,
attach_selfavatar: false,
webxdc_topic: None,
pre_message_mode: PreMessageMode::None,
};
Ok(res)
@@ -612,7 +664,7 @@ impl MimeFactory {
if msg
.param
.get_bool(Param::AttachGroupImage)
.get_bool(Param::AttachChatAvatarAndDescription)
.unwrap_or_default()
{
return chat.param.get(Param::ProfileImage).map(Into::into);
@@ -675,6 +727,7 @@ impl MimeFactory {
/// Consumes a `MimeFactory` and renders it into a message which is then stored in
/// `smtp`-table to be used by the SMTP loop
#[expect(clippy::arithmetic_side_effects)]
pub async fn render(mut self, context: &Context) -> Result<RenderedEmail> {
let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new();
@@ -779,7 +832,10 @@ impl MimeFactory {
headers.push(("Date", mail_builder::headers::raw::Raw::new(date).into()));
let rfc724_mid = match &self.loaded {
Loaded::Message { msg, .. } => msg.rfc724_mid.clone(),
Loaded::Message { msg, .. } => match &self.pre_message_mode {
PreMessageMode::Pre { .. } => create_outgoing_rfc724_mid(),
_ => msg.rfc724_mid.clone(),
},
Loaded::Mdn { .. } => create_outgoing_rfc724_mid(),
};
headers.push((
@@ -893,7 +949,7 @@ impl MimeFactory {
));
}
let is_encrypted = self.encryption_pubkeys.is_some();
let is_encrypted = self.will_be_encrypted();
// Add ephemeral timer for non-MDN messages.
// For MDNs it does not matter because they are not visible
@@ -978,6 +1034,23 @@ impl MimeFactory {
"MIME-Version",
mail_builder::headers::raw::Raw::new("1.0").into(),
));
if self.pre_message_mode == PreMessageMode::Post {
unprotected_headers.push((
"Chat-Is-Post-Message",
mail_builder::headers::raw::Raw::new("1").into(),
));
} else if let PreMessageMode::Pre {
post_msg_rfc724_mid,
} = &self.pre_message_mode
{
protected_headers.push((
"Chat-Post-Message-ID",
mail_builder::headers::message_id::MessageId::new(post_msg_rfc724_mid.clone())
.into(),
));
}
for header @ (original_header_name, _header_value) in &headers {
let header_name = original_header_name.to_lowercase();
if header_name == "message-id" {
@@ -1119,6 +1192,10 @@ impl MimeFactory {
for (addr, key) in &encryption_pubkeys {
let fingerprint = key.dc_fingerprint().hex();
let cmd = msg.param.get_cmd();
if self.pre_message_mode == PreMessageMode::Post {
continue;
}
let should_do_gossip = cmd == SystemMessage::MemberAddedToGroup
|| cmd == SystemMessage::SecurejoinMessage
|| multiple_recipients && {
@@ -1594,6 +1671,12 @@ impl MimeFactory {
mail_builder::headers::text::Text::new(old_name).into(),
));
}
SystemMessage::GroupDescriptionChanged => {
headers.push((
"Chat-Group-Description-Changed",
mail_builder::headers::text::Text::new("").into(),
));
}
SystemMessage::GroupImageChanged => {
headers.push((
"Chat-Content",
@@ -1608,6 +1691,26 @@ impl MimeFactory {
}
_ => {}
}
if command == SystemMessage::GroupDescriptionChanged
|| command == SystemMessage::MemberAddedToGroup
|| msg
.param
.get_bool(Param::AttachChatAvatarAndDescription)
.unwrap_or_default()
{
let description = chat::get_chat_description(context, chat.id).await?;
headers.push((
"Chat-Group-Description",
mail_builder::headers::text::Text::new(description.clone()).into(),
));
if let Some(ts) = chat.param.get_i64(Param::GroupDescriptionTimestamp) {
headers.push((
"Chat-Group-Description-Timestamp",
mail_builder::headers::text::Text::new(ts.to_string()).into(),
));
}
}
}
match command {
@@ -1765,7 +1868,7 @@ impl MimeFactory {
mail_builder::headers::raw::Raw::new(b_encode(answer)).into(),
));
}
if let Some(has_video) = msg.param.get(Param::CallHasVideoInitially) {
if let Some(has_video) = msg.param.get(Param::WebrtcHasVideoInitially) {
headers.push((
"Chat-Webrtc-Has-Video-Initially",
mail_builder::headers::raw::Raw::new(b_encode(has_video)).into(),
@@ -1837,19 +1940,23 @@ impl MimeFactory {
let footer = if is_reaction { "" } else { &self.selfstatus };
let message_text = format!(
"{}{}{}{}{}{}",
fwdhint.unwrap_or_default(),
quoted_text.unwrap_or_default(),
escape_message_footer_marks(final_text),
if !final_text.is_empty() && !footer.is_empty() {
"\r\n\r\n"
} else {
""
},
if !footer.is_empty() { "-- \r\n" } else { "" },
footer
);
let message_text = if self.pre_message_mode == PreMessageMode::Post {
"".to_string()
} else {
format!(
"{}{}{}{}{}{}",
fwdhint.unwrap_or_default(),
quoted_text.unwrap_or_default(),
escape_message_footer_marks(final_text),
if !final_text.is_empty() && !footer.is_empty() {
"\r\n\r\n"
} else {
""
},
if !footer.is_empty() { "-- \r\n" } else { "" },
footer
)
};
let mut main_part = MimePart::new("text/plain", message_text);
if is_reaction {
@@ -1861,15 +1968,19 @@ impl MimeFactory {
let mut parts = Vec::new();
// add HTML-part, this is needed only if a HTML-message from a non-delta-client is forwarded;
// for simplificity and to avoid conversion errors, we're generating the HTML-part from the original message.
if msg.has_html() {
let html = if let Some(orig_msg_id) = msg.param.get_int(Param::Forwarded) {
let html = if let Some(html) = msg.param.get(Param::SendHtml) {
Some(html.to_string())
} else if let Some(orig_msg_id) = msg.param.get_int(Param::Forwarded)
&& orig_msg_id != 0
{
// Legacy forwarded messages may not have `Param::SendHtml` set. Let's hope the
// original message exists.
MsgId::new(orig_msg_id.try_into()?)
.get_html(context)
.await?
} else {
msg.param.get(Param::SendHtml).map(|s| s.to_string())
None
};
if let Some(html) = html {
main_part = MimePart::new(
@@ -1881,8 +1992,19 @@ impl MimeFactory {
// add attachment part
if msg.viewtype.has_file() {
let file_part = build_body_file(context, &msg).await?;
parts.push(file_part);
if let PreMessageMode::Pre { .. } = self.pre_message_mode {
let Some(metadata) = PostMsgMetadata::from_msg(context, &msg).await? else {
bail!("Failed to generate metadata for pre-message")
};
headers.push((
HeaderDef::ChatPostMessageMetadata.into(),
mail_builder::headers::raw::Raw::new(metadata.to_header_value()?).into(),
));
} else {
let file_part = build_body_file(context, &msg).await?;
parts.push(file_part);
}
}
if let Some(msg_kml_part) = self.get_message_kml_part() {
@@ -1927,6 +2049,8 @@ impl MimeFactory {
}
}
self.attach_selfavatar =
self.attach_selfavatar && self.pre_message_mode != PreMessageMode::Post;
if self.attach_selfavatar {
match context.get_config(Config::Selfavatar).await? {
Some(path) => match build_avatar_file(context, &path).await {
@@ -1947,6 +2071,7 @@ impl MimeFactory {
}
/// Render an MDN
#[expect(clippy::arithmetic_side_effects)]
fn render_mdn(&mut self) -> Result<MimePart<'static>> {
// RFC 6522, this also requires the `report-type` parameter which is equal
// to the MIME subtype of the second body part of the multipart/report
@@ -1996,6 +2121,20 @@ impl MimeFactory {
Ok(message)
}
pub fn will_be_encrypted(&self) -> bool {
self.encryption_pubkeys.is_some()
}
pub fn set_as_post_message(&mut self) {
self.pre_message_mode = PreMessageMode::Post;
}
pub fn set_as_pre_message_for(&mut self, post_message: &RenderedEmail) {
self.pre_message_mode = PreMessageMode::Pre {
post_msg_rfc724_mid: post_message.rfc724_mid.clone(),
};
}
}
fn hidden_recipients() -> Address<'static> {

View File

@@ -559,7 +559,7 @@ async fn test_render_reply() {
"1.0"
);
let _mime_msg = MimeMessage::from_bytes(context, rendered_msg.message.as_bytes(), None)
let _mime_msg = MimeMessage::from_bytes(context, rendered_msg.message.as_bytes())
.await
.unwrap();
}
@@ -757,7 +757,7 @@ async fn test_protected_headers_directive() -> Result<()> {
assert!(msg.get_showpadlock());
assert!(sent.payload.contains("\r\nSubject: [...]\r\n"));
let mime = MimeMessage::from_bytes(&alice, sent.payload.as_bytes(), None).await?;
let mime = MimeMessage::from_bytes(&alice, sent.payload.as_bytes()).await?;
let mut payload = str::from_utf8(&mime.decoded_data)?.splitn(2, "\r\n\r\n");
let part = payload.next().unwrap();
assert_eq!(
@@ -781,7 +781,7 @@ async fn test_hp_outer_headers() -> Result<()> {
.await?;
chat::send_text_msg(t, chat_id, "hi!".to_string()).await?;
let sent_msg = t.pop_sent_msg().await;
let msg = MimeMessage::from_bytes(t, sent_msg.payload.as_bytes(), None).await?;
let msg = MimeMessage::from_bytes(t, sent_msg.payload.as_bytes()).await?;
assert_eq!(msg.header_exists(HeaderDef::HpOuter), std_hp_composing);
for hdr in ["Date", "From", "Message-ID"] {
assert_eq!(
@@ -811,7 +811,7 @@ async fn test_dont_remove_self() -> Result<()> {
.await;
println!("{}", sent.payload);
let mime_message = MimeMessage::from_bytes(alice, sent.payload.as_bytes(), None)
let mime_message = MimeMessage::from_bytes(alice, sent.payload.as_bytes())
.await
.unwrap();
assert!(!mime_message.header_exists(HeaderDef::ChatGroupPastMembers));

View File

@@ -23,6 +23,7 @@ use crate::contact::ContactId;
use crate::context::Context;
use crate::decrypt::{try_decrypt, validate_detached_signature};
use crate::dehtml::dehtml;
use crate::download::PostMsgMetadata;
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
use crate::key::{self, DcKey, Fingerprint, SignedPublicKey, load_self_secret_keyring};
@@ -88,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.
@@ -147,6 +149,25 @@ pub(crate) struct MimeMessage {
/// Sender timestamp in secs since epoch. Allowed to be in the future due to unsynchronized
/// clocks, but not too much.
pub(crate) timestamp_sent: i64,
pub(crate) pre_message: PreMessageMode,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum PreMessageMode {
/// This is a post-message.
/// It replaces its pre-message attachment if it exists already,
/// and if the pre-message does not exist, it is treated as a normal message.
Post,
/// This is a Pre-Message,
/// it adds a message preview for a Post-Message
/// and it is ignored if the Post-Message was downloaded already
Pre {
post_msg_rfc724_mid: String,
metadata: Option<PostMsgMetadata>,
},
/// Atomic ("normal") message.
None,
}
#[derive(Debug, PartialEq)]
@@ -165,10 +186,10 @@ pub enum SystemMessage {
#[default]
Unknown = 0,
/// Group name changed.
/// Group or broadcast channel name changed.
GroupNameChanged = 2,
/// Group avatar changed.
/// Group or broadcast channel avatar changed.
GroupImageChanged = 3,
/// Member was added to the group.
@@ -233,6 +254,9 @@ pub enum SystemMessage {
/// Message indicating that a call was ended.
CallEnded = 67,
/// Group or broadcast channel description changed.
GroupDescriptionChanged = 70,
}
const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup";
@@ -240,12 +264,10 @@ const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup";
impl MimeMessage {
/// Parse a mime message.
///
/// If `partial` is set, it contains the full message size in bytes.
pub(crate) async fn from_bytes(
context: &Context,
body: &[u8],
partial: Option<u32>,
) -> Result<Self> {
/// This method has some side-effects,
/// such as saving blobs and saving found public keys to the database.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn from_bytes(context: &Context, body: &[u8]) -> Result<Self> {
let mail = mailparse::parse_mail(body)?;
let timestamp_rcvd = smeared_time(context);
@@ -302,7 +324,7 @@ impl MimeMessage {
);
(part, part.ctype.mimetype.parse::<Mime>()?)
} else {
// If it's a partially fetched message, there are no subparts.
// Not a valid signed message, handle it as plaintext.
(&mail, mimetype)
}
} else {
@@ -352,6 +374,16 @@ impl MimeMessage {
let mut aheader_values = mail.headers.get_all_values(HeaderDef::Autocrypt.into());
let mut pre_message = if mail
.headers
.get_header_value(HeaderDef::ChatIsPostMessage)
.is_some()
{
PreMessageMode::Post
} else {
PreMessageMode::None
};
let mail_raw; // Memory location for a possible decrypted message.
let decrypted_msg; // Decrypted signed OpenPGP message.
let secrets: Vec<String> = context
@@ -468,8 +500,7 @@ impl MimeMessage {
// We don't decompress messages compressed multiple times.
None
}
Some(pgp::composed::Message::SignedOnePass { reader, .. }) => reader.signature(),
Some(pgp::composed::Message::Signed { reader, .. }) => Some(reader.signature()),
Some(pgp::composed::Message::Signed { reader, .. }) => reader.signature(0),
Some(pgp::composed::Message::Encrypted { .. }) => {
// The message is already decrypted once.
None
@@ -502,12 +533,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
});
@@ -580,6 +615,43 @@ impl MimeMessage {
signatures.clear();
}
if let (Ok(mail), true) = (mail, is_encrypted)
&& let Some(post_msg_rfc724_mid) =
mail.headers.get_header_value(HeaderDef::ChatPostMessageId)
{
let post_msg_rfc724_mid = parse_message_id(&post_msg_rfc724_mid)?;
let metadata = if let Some(value) = mail
.headers
.get_header_value(HeaderDef::ChatPostMessageMetadata)
{
match PostMsgMetadata::try_from_header_value(&value) {
Ok(metadata) => Some(metadata),
Err(error) => {
error!(
context,
"Failed to parse metadata header in pre-message for {post_msg_rfc724_mid}: {error:#}."
);
None
}
}
} else {
warn!(
context,
"Expected pre-message for {post_msg_rfc724_mid} to have metadata header."
);
None
};
pre_message = PreMessageMode::Pre {
post_msg_rfc724_mid,
metadata,
};
}
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,
@@ -595,7 +667,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,
@@ -615,33 +687,27 @@ impl MimeMessage {
is_bot: None,
timestamp_rcvd,
timestamp_sent,
pre_message,
};
match partial {
Some(org_bytes) => {
parser
.create_stub_from_partial_download(context, org_bytes)
.await?;
match mail {
Ok(mail) => {
parser.parse_mime_recursive(context, mail, false).await?;
}
None => match mail {
Ok(mail) => {
parser.parse_mime_recursive(context, mail, false).await?;
}
Err(err) => {
let txt = "[This message cannot be decrypted.\n\n• It might already help to simply reply to this message and ask the sender to send the message again.\n\n• If you just re-installed Delta Chat then it is best if you re-setup Delta Chat now and choose \"Add as second device\" or import a backup.]";
Err(err) => {
let txt = "[This message cannot be decrypted.\n\n• It might already help to simply reply to this message and ask the sender to send the message again.\n\n• If you just re-installed Delta Chat then it is best if you re-setup Delta Chat now and choose \"Add as second device\" or import a backup.]";
let part = Part {
typ: Viewtype::Text,
msg_raw: Some(txt.to_string()),
msg: txt.to_string(),
// Don't change the error prefix for now,
// receive_imf.rs:lookup_chat_by_reply() checks it.
error: Some(format!("Decrypting failed: {err:#}")),
..Default::default()
};
parser.do_add_single_part(part);
}
},
let part = Part {
typ: Viewtype::Text,
msg_raw: Some(txt.to_string()),
msg: txt.to_string(),
// Don't change the error prefix for now,
// receive_imf.rs:lookup_chat_by_reply() checks it.
error: Some(format!("Decrypting failed: {err:#}")),
..Default::default()
};
parser.do_add_single_part(part);
}
};
let is_location_only = parser.location_kml.is_some() && parser.parts.is_empty();
@@ -663,6 +729,7 @@ impl MimeMessage {
Ok(parser)
}
#[expect(clippy::arithmetic_side_effects)]
fn get_timestamp_sent(
hdrs: &[mailparse::MailHeader<'_>],
default: i64,
@@ -711,6 +778,11 @@ impl MimeMessage {
self.is_system_message = SystemMessage::MemberAddedToGroup;
} else if self.get_header(HeaderDef::ChatGroupNameChanged).is_some() {
self.is_system_message = SystemMessage::GroupNameChanged;
} else if self
.get_header(HeaderDef::ChatGroupDescriptionChanged)
.is_some()
{
self.is_system_message = SystemMessage::GroupDescriptionChanged;
}
}
@@ -751,7 +823,7 @@ impl MimeMessage {
part.param.set(Param::WebrtcAccepted, accepted);
}
if let Some(has_video) = has_video {
part.param.set(Param::CallHasVideoInitially, has_video);
part.param.set(Param::WebrtcHasVideoInitially, has_video);
}
}
}
@@ -935,6 +1007,7 @@ impl MimeMessage {
Ok(())
}
#[expect(clippy::arithmetic_side_effects)]
fn avatar_action_from_header(
&mut self,
context: &Context,
@@ -1436,6 +1509,7 @@ impl MimeMessage {
}
#[expect(clippy::too_many_arguments)]
#[expect(clippy::arithmetic_side_effects)]
async fn do_add_single_file_part(
&mut self,
context: &Context,
@@ -1573,7 +1647,7 @@ impl MimeMessage {
}
Ok(key) => key,
};
if let Err(err) = key.verify() {
if let Err(err) = key.verify_bindings() {
warn!(context, "Attached PGP key verification failed: {err:#}.");
return Ok(false);
}
@@ -1987,6 +2061,7 @@ impl MimeMessage {
/// Returns parsed `Chat-Group-Member-Timestamps` header contents.
///
/// Returns `None` if there is no such header.
#[expect(clippy::arithmetic_side_effects)]
pub fn chat_group_member_timestamps(&self) -> Option<Vec<i64>> {
let now = time() + constants::TIMESTAMP_SENT_TOLERANCE;
self.get_header(HeaderDef::ChatGroupMemberTimestamps)
@@ -2111,9 +2186,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)
@@ -2420,31 +2495,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")?;

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},
@@ -25,58 +27,54 @@ impl AvatarAction {
async fn test_mimeparser_fromheader() {
let ctx = TestContext::new_alice().await;
let mimemsg = MimeMessage::from_bytes(&ctx, b"From: g@c.de\n\nhi", None)
let mimemsg = MimeMessage::from_bytes(&ctx, b"From: g@c.de\n\nhi")
.await
.unwrap();
let contact = mimemsg.from;
assert_eq!(contact.addr, "g@c.de");
assert_eq!(contact.display_name, None);
let mimemsg = MimeMessage::from_bytes(&ctx, b"From: g@c.de \n\nhi", None)
let mimemsg = MimeMessage::from_bytes(&ctx, b"From: g@c.de \n\nhi")
.await
.unwrap();
let contact = mimemsg.from;
assert_eq!(contact.addr, "g@c.de");
assert_eq!(contact.display_name, None);
let mimemsg = MimeMessage::from_bytes(&ctx, b"From: <g@c.de>\n\nhi", None)
let mimemsg = MimeMessage::from_bytes(&ctx, b"From: <g@c.de>\n\nhi")
.await
.unwrap();
let contact = mimemsg.from;
assert_eq!(contact.addr, "g@c.de");
assert_eq!(contact.display_name, None);
let mimemsg = MimeMessage::from_bytes(&ctx, b"From: Goetz C <g@c.de>\n\nhi", None)
let mimemsg = MimeMessage::from_bytes(&ctx, b"From: Goetz C <g@c.de>\n\nhi")
.await
.unwrap();
let contact = mimemsg.from;
assert_eq!(contact.addr, "g@c.de");
assert_eq!(contact.display_name, Some("Goetz C".to_string()));
let mimemsg = MimeMessage::from_bytes(&ctx, b"From: \"Goetz C\" <g@c.de>\n\nhi", None)
let mimemsg = MimeMessage::from_bytes(&ctx, b"From: \"Goetz C\" <g@c.de>\n\nhi")
.await
.unwrap();
let contact = mimemsg.from;
assert_eq!(contact.addr, "g@c.de");
assert_eq!(contact.display_name, Some("Goetz C".to_string()));
let mimemsg =
MimeMessage::from_bytes(&ctx, b"From: =?utf-8?q?G=C3=B6tz?= C <g@c.de>\n\nhi", None)
.await
.unwrap();
let mimemsg = MimeMessage::from_bytes(&ctx, b"From: =?utf-8?q?G=C3=B6tz?= C <g@c.de>\n\nhi")
.await
.unwrap();
let contact = mimemsg.from;
assert_eq!(contact.addr, "g@c.de");
assert_eq!(contact.display_name, Some("Götz C".to_string()));
// although RFC 2047 says, encoded-words shall not appear inside quoted-string,
// this combination is used in the wild eg. by MailMate
let mimemsg = MimeMessage::from_bytes(
&ctx,
b"From: \"=?utf-8?q?G=C3=B6tz?= C\" <g@c.de>\n\nhi",
None,
)
.await
.unwrap();
let mimemsg =
MimeMessage::from_bytes(&ctx, b"From: \"=?utf-8?q?G=C3=B6tz?= C\" <g@c.de>\n\nhi")
.await
.unwrap();
let contact = mimemsg.from;
assert_eq!(contact.addr, "g@c.de");
assert_eq!(contact.display_name, Some("Götz C".to_string()));
@@ -86,7 +84,7 @@ async fn test_mimeparser_fromheader() {
async fn test_mimeparser_crash() {
let context = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/issue_523.txt");
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
@@ -98,7 +96,7 @@ async fn test_mimeparser_crash() {
async fn test_get_rfc724_mid_exists() {
let context = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/mail_with_message_id.txt");
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
@@ -112,7 +110,7 @@ async fn test_get_rfc724_mid_exists() {
async fn test_get_rfc724_mid_not_exists() {
let context = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/issue_523.txt");
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(mimeparser.get_rfc724_mid(), None);
@@ -324,7 +322,7 @@ async fn test_mailparse_0_16_0_panic() {
// There should be an error, but no panic.
assert!(
MimeMessage::from_bytes(&context.ctx, &raw[..], None)
MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.is_err()
);
@@ -341,7 +339,7 @@ async fn test_parse_first_addr() {
test1\n\
";
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..], None).await;
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..]).await;
assert!(mimeparser.is_err());
}
@@ -356,7 +354,7 @@ async fn test_get_parent_timestamp() {
\n\
Some reply\n\
";
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(
@@ -402,7 +400,7 @@ async fn test_mimeparser_with_context() {
--==break==--\n\
\n";
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
@@ -438,26 +436,26 @@ async fn test_mimeparser_with_avatars() {
let t = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/mail_attach_txt.eml");
let mimeparser = MimeMessage::from_bytes(&t, &raw[..], None).await.unwrap();
let mimeparser = MimeMessage::from_bytes(&t, &raw[..]).await.unwrap();
assert_eq!(mimeparser.user_avatar, None);
assert_eq!(mimeparser.group_avatar, None);
let raw = include_bytes!("../../test-data/message/mail_with_user_avatar.eml");
let mimeparser = MimeMessage::from_bytes(&t, &raw[..], None).await.unwrap();
let mimeparser = MimeMessage::from_bytes(&t, &raw[..]).await.unwrap();
assert_eq!(mimeparser.parts.len(), 1);
assert_eq!(mimeparser.parts[0].typ, Viewtype::Text);
assert!(mimeparser.user_avatar.unwrap().is_change());
assert_eq!(mimeparser.group_avatar, None);
let raw = include_bytes!("../../test-data/message/mail_with_user_avatar_deleted.eml");
let mimeparser = MimeMessage::from_bytes(&t, &raw[..], None).await.unwrap();
let mimeparser = MimeMessage::from_bytes(&t, &raw[..]).await.unwrap();
assert_eq!(mimeparser.parts.len(), 1);
assert_eq!(mimeparser.parts[0].typ, Viewtype::Text);
assert_eq!(mimeparser.user_avatar, Some(AvatarAction::Delete));
assert_eq!(mimeparser.group_avatar, None);
let raw = include_bytes!("../../test-data/message/mail_with_user_and_group_avatars.eml");
let mimeparser = MimeMessage::from_bytes(&t, &raw[..], None).await.unwrap();
let mimeparser = MimeMessage::from_bytes(&t, &raw[..]).await.unwrap();
assert_eq!(mimeparser.parts.len(), 1);
assert_eq!(mimeparser.parts[0].typ, Viewtype::Text);
assert!(mimeparser.user_avatar.unwrap().is_change());
@@ -467,9 +465,7 @@ async fn test_mimeparser_with_avatars() {
let raw = include_bytes!("../../test-data/message/mail_with_user_and_group_avatars.eml");
let raw = String::from_utf8_lossy(raw).to_string();
let raw = raw.replace("Chat-User-Avatar:", "Xhat-Xser-Xvatar:");
let mimeparser = MimeMessage::from_bytes(&t, raw.as_bytes(), None)
.await
.unwrap();
let mimeparser = MimeMessage::from_bytes(&t, raw.as_bytes()).await.unwrap();
assert_eq!(mimeparser.parts.len(), 1);
assert_eq!(mimeparser.parts[0].typ, Viewtype::Image);
assert_eq!(mimeparser.user_avatar, None);
@@ -485,7 +481,7 @@ async fn test_mimeparser_with_videochat() {
let t = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/videochat_invitation.eml");
let mimeparser = MimeMessage::from_bytes(&t, &raw[..], None).await.unwrap();
let mimeparser = MimeMessage::from_bytes(&t, &raw[..]).await.unwrap();
assert_eq!(mimeparser.parts.len(), 1);
assert_eq!(mimeparser.parts[0].typ, Viewtype::Text);
assert_eq!(mimeparser.parts[0].param.get(Param::WebrtcRoom), None);
@@ -528,7 +524,7 @@ Content-Disposition: attachment; filename=\"message.kml\"\n\
--==break==--\n\
;";
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(
@@ -578,7 +574,7 @@ Disposition: manual-action/MDN-sent-automatically; displayed\n\
--kJBbU58X1xeWNHgBtTbMk80M5qnV4N--\n\
";
let message = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let message = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(
@@ -659,7 +655,7 @@ Disposition: manual-action/MDN-sent-automatically; displayed\n\
--outer--\n\
";
let message = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let message = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(
@@ -706,7 +702,7 @@ Additional-Message-IDs: <foo@example.com> <foo@example.net>\n\
--kJBbU58X1xeWNHgBtTbMk80M5qnV4N--\n\
";
let message = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let message = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(
@@ -753,7 +749,7 @@ MDYyMDYxNTE1RTlDOEE4Cj4+CnN0YXJ0eHJlZgo4Mjc4CiUlRU9GCg==
------=_Part_25_46172632.1581201680436--
"#;
let message = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let message = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(
@@ -797,7 +793,7 @@ MDYyMDYxNTE1RTlDOEE4Cj4+CnN0YXJ0eHJlZgo4Mjc4CiUlRU9GCg==
------=_Part_25_46172632.1581201680436--
"#;
let message = MimeMessage::from_bytes(&t, &raw[..], None).await.unwrap();
let message = MimeMessage::from_bytes(&t, &raw[..]).await.unwrap();
assert_eq!(message.parts.len(), 1);
assert_eq!(message.parts[0].typ, Viewtype::File);
@@ -839,7 +835,7 @@ iVBORw0KGgoAAAANSUhEUgAAACAAAAAeCAAAAABoYUP1AAAAAXNSR0IArs4c6QAAAo1JREFUKJFdkdFu
----11019878869865180--
"#;
let message = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let message = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(message.get_subject(), Some("example".to_string()));
@@ -903,7 +899,7 @@ iVBORw0KGgoAAAANSUhEUgAAACAAAAAeCAAAAABoYUP1AAAAAXNSR0IArs4c6QAAAo1JREFUKJFdkdFu
--------------779C1631600DF3DB8C02E53A--"#;
let message = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let message = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(message.get_subject(), Some("Test subject".to_string()));
@@ -966,7 +962,7 @@ iVBORw0KGgoAAAANSUhEUgAAACAAAAAeCAAAAABoYUP1AAAAAXNSR0IArs4c6QAAAo1JREFUKJFdkdFu
------=_NextPart_000_0003_01D622B3.CA753E60--
"#;
let message = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let message = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(
@@ -1064,7 +1060,7 @@ From: alice <alice@example.org>
Reply
"##;
let message = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let message = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(
@@ -1096,7 +1092,7 @@ From: alice <alice@example.org>
> Just a quote.
"##;
let message = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let message = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(
@@ -1130,7 +1126,7 @@ On 2020-10-25, Bob wrote:
> A quote.
"##;
let message = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let message = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(message.get_subject(), Some("Re: top posting".to_string()));
@@ -1148,7 +1144,7 @@ On 2020-10-25, Bob wrote:
async fn test_attachment_quote() {
let context = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/quote_attach.eml");
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
@@ -1166,7 +1162,7 @@ async fn test_attachment_quote() {
async fn test_quote_div() {
let t = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/gmx-quote.eml");
let mimeparser = MimeMessage::from_bytes(&t, raw, None).await.unwrap();
let mimeparser = MimeMessage::from_bytes(&t, raw).await.unwrap();
assert_eq!(mimeparser.parts[0].msg, "YIPPEEEEEE\n\nMulti-line");
assert_eq!(mimeparser.parts[0].param.get(Param::Quote).unwrap(), "Now?");
}
@@ -1176,7 +1172,7 @@ async fn test_allinkl_blockquote() {
// all-inkl.com puts quotes into `<blockquote> </blockquote>`.
let t = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/allinkl-quote.eml");
let mimeparser = MimeMessage::from_bytes(&t, raw, None).await.unwrap();
let mimeparser = MimeMessage::from_bytes(&t, raw).await.unwrap();
assert!(mimeparser.parts[0].msg.starts_with("It's 1.0."));
assert_eq!(
mimeparser.parts[0].param.get(Param::Quote).unwrap(),
@@ -1217,7 +1213,7 @@ async fn test_add_subj_to_multimedia_msg() {
async fn test_mime_modified_plain() {
let t = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/text_plain_unspecified.eml");
let mimeparser = MimeMessage::from_bytes(&t.ctx, raw, None).await.unwrap();
let mimeparser = MimeMessage::from_bytes(&t.ctx, raw).await.unwrap();
assert!(!mimeparser.is_mime_modified);
assert_eq!(
mimeparser.parts[0].msg,
@@ -1229,7 +1225,7 @@ async fn test_mime_modified_plain() {
async fn test_mime_modified_alt_plain_html() {
let t = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/text_alt_plain_html.eml");
let mimeparser = MimeMessage::from_bytes(&t.ctx, raw, None).await.unwrap();
let mimeparser = MimeMessage::from_bytes(&t.ctx, raw).await.unwrap();
assert!(mimeparser.is_mime_modified);
assert_eq!(
mimeparser.parts[0].msg,
@@ -1241,7 +1237,7 @@ async fn test_mime_modified_alt_plain_html() {
async fn test_mime_modified_alt_plain() {
let t = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/text_alt_plain.eml");
let mimeparser = MimeMessage::from_bytes(&t.ctx, raw, None).await.unwrap();
let mimeparser = MimeMessage::from_bytes(&t.ctx, raw).await.unwrap();
assert!(!mimeparser.is_mime_modified);
assert_eq!(
mimeparser.parts[0].msg,
@@ -1256,7 +1252,7 @@ async fn test_mime_modified_alt_plain() {
async fn test_mime_modified_alt_html() {
let t = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/text_alt_html.eml");
let mimeparser = MimeMessage::from_bytes(&t.ctx, raw, None).await.unwrap();
let mimeparser = MimeMessage::from_bytes(&t.ctx, raw).await.unwrap();
assert!(mimeparser.is_mime_modified);
assert_eq!(
mimeparser.parts[0].msg,
@@ -1268,7 +1264,7 @@ async fn test_mime_modified_alt_html() {
async fn test_mime_modified_html() {
let t = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/text_html.eml");
let mimeparser = MimeMessage::from_bytes(&t.ctx, raw, None).await.unwrap();
let mimeparser = MimeMessage::from_bytes(&t.ctx, raw).await.unwrap();
assert!(mimeparser.is_mime_modified);
assert_eq!(
mimeparser.parts[0].msg,
@@ -1288,7 +1284,7 @@ async fn test_mime_modified_large_plain() -> Result<()> {
assert!(long_txt.len() > DC_DESIRED_TEXT_LEN);
{
let mimemsg = MimeMessage::from_bytes(&t, long_txt.as_ref(), None).await?;
let mimemsg = MimeMessage::from_bytes(&t, long_txt.as_ref()).await?;
assert!(mimemsg.is_mime_modified);
assert!(
mimemsg.parts[0].msg.matches("just repeated").count()
@@ -1321,7 +1317,7 @@ async fn test_mime_modified_large_plain() -> Result<()> {
t.set_config(Config::Bot, Some("1")).await?;
{
let mimemsg = MimeMessage::from_bytes(&t, long_txt.as_ref(), None).await?;
let mimemsg = MimeMessage::from_bytes(&t, long_txt.as_ref()).await?;
assert!(!mimemsg.is_mime_modified);
assert_eq!(
format!("{}\n", mimemsg.parts[0].msg),
@@ -1368,7 +1364,7 @@ async fn test_x_microsoft_original_message_id() {
MIME-Version: 1.0\n\
\n\
Does it work with outlook now?\n\
", None)
")
.await
.unwrap();
assert_eq!(
@@ -1418,7 +1414,7 @@ async fn test_extra_imf_headers() -> Result<()> {
"Message-ID:",
"Chat-Forty-Two: 42\r\nForty-Two: 42\r\nMessage-ID:",
);
let msg = MimeMessage::from_bytes(t, payload.as_bytes(), None).await?;
let msg = MimeMessage::from_bytes(t, payload.as_bytes()).await?;
assert!(msg.headers.contains_key("chat-version"));
assert!(!msg.headers.contains_key("chat-forty-two"));
assert_ne!(msg.headers.contains_key("forty-two"), std_hp_composing);
@@ -1426,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;
@@ -1582,7 +1612,7 @@ async fn test_ms_exchange_mdn() -> Result<()> {
// 1. Test mimeparser directly
let mdn =
include_bytes!("../../test-data/message/ms_exchange_report_disposition_notification.eml");
let mimeparser = MimeMessage::from_bytes(&t.ctx, mdn, None).await?;
let mimeparser = MimeMessage::from_bytes(&t.ctx, mdn).await?;
assert_eq!(mimeparser.mdn_reports.len(), 1);
assert_eq!(
mimeparser.mdn_reports[0].original_message_id.as_deref(),
@@ -1608,7 +1638,6 @@ async fn test_receive_eml() -> Result<()> {
let mime_message = MimeMessage::from_bytes(
&alice,
include_bytes!("../../test-data/message/attached-eml.eml"),
None,
)
.await?;
@@ -1651,7 +1680,6 @@ Content-Disposition: reaction\n\
\n\
\u{1F44D}"
.as_bytes(),
None,
)
.await?;
@@ -1673,7 +1701,7 @@ async fn test_jpeg_as_application_octet_stream() -> Result<()> {
let context = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/jpeg-as-application-octet-stream.eml");
let msg = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let msg = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(msg.parts.len(), 1);
@@ -1691,7 +1719,7 @@ async fn test_schleuder() -> Result<()> {
let context = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/schleuder.eml");
let msg = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let msg = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(msg.parts.len(), 2);
@@ -1711,7 +1739,7 @@ async fn test_tlsrpt() -> Result<()> {
let context = TestContext::new_alice().await;
let raw = include_bytes!("../../test-data/message/tlsrpt.eml");
let msg = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let msg = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(msg.parts.len(), 1);
@@ -1744,7 +1772,6 @@ async fn test_time_in_future() -> Result<()> {
Content-Type: text/plain; charset=utf-8\n\
\n\
Hi",
None,
)
.await?;
@@ -1806,7 +1833,7 @@ Content-Type: text/plain; charset=utf-8
/help
"#;
let message = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let message = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(message.get_subject(), Some("Some subject".to_string()));
@@ -1847,7 +1874,7 @@ async fn test_take_last_header() {
Hello\n\
";
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..], None)
let mimeparser = MimeMessage::from_bytes(&context.ctx, &raw[..])
.await
.unwrap();
assert_eq!(
@@ -1900,9 +1927,7 @@ It DOES end with a linebreak.\r
\r
This is the epilogue. It is also to be ignored.";
let mimeparser = MimeMessage::from_bytes(&context, &raw[..], None)
.await
.unwrap();
let mimeparser = MimeMessage::from_bytes(&context, &raw[..]).await.unwrap();
assert_eq!(mimeparser.parts.len(), 2);
@@ -1948,7 +1973,7 @@ Message with a correct Message-ID hidden header
--luTiGu6GBoVLCvTkzVtmZmwsmhkNMw--
"#;
let message = MimeMessage::from_bytes(t, &raw[..], None).await.unwrap();
let message = MimeMessage::from_bytes(t, &raw[..]).await.unwrap();
assert_eq!(message.get_rfc724_mid().unwrap(), "foo@example.org");
}
@@ -2126,9 +2151,7 @@ Third alternative.
--boundary--
"#;
let message = MimeMessage::from_bytes(context, &raw[..], None)
.await
.unwrap();
let message = MimeMessage::from_bytes(context, &raw[..]).await.unwrap();
assert_eq!(message.parts.len(), 1);
assert_eq!(message.parts[0].typ, Viewtype::Text);
assert_eq!(message.parts[0].msg, "Third alternative.");

View File

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

View File

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

View File

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

View File

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

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