Compare commits

...

74 Commits

Author SHA1 Message Date
link2xt
17701b78d6 chore(release): prepare for 1.137.3 2024-04-18 02:33:34 +00:00
link2xt
ff0d506c95 ci: allow older versions of Sphinx than 7.2.6
Version 7.2.6 does not supported by older Python.
2024-04-18 02:32:37 +00:00
link2xt
8ff3f08c2f test: make EventTracker.clear_events() reliable 2024-04-18 02:32:37 +00:00
link2xt
7a32bcc1f4 test: fix flaky chatlist_events test test_update_after_ephemeral_messages
Previously test did not trigger
deletion of ephemeral messages
and worked because clear_events() did not
remove just emitted events from `send_text_msg`.
2024-04-18 02:32:37 +00:00
link2xt
65822e53e6 build(python): pin sphinx version 2024-04-17 11:54:45 +00:00
Simon Laux
ac508a9e9c replace tokio sleep with SystemTime::shift 2024-04-17 10:29:53 +00:00
Simon Laux
225112a8fe fix test events::chatlist_events::test_chatlist_events::test_secure_join_group 2024-04-17 10:29:53 +00:00
Hocuri
5d34b225b7 Split off functional contact tools into its own crate (#5444)
I would like to implement
https://github.com/deltachat/deltachat-core-rust/issues/5422 in its own
crate, but it will depend on some functions that are in the `deltachat`
crate.

So, this PR extracts these functions into its own crate so that I can
add https://github.com/deltachat/deltachat-core-rust/issues/5422 into
the new crate.
2024-04-16 19:01:25 +02:00
link2xt
6ca6a439bd test(node): increase 'static tests' timeout to 5 minutes
It keeps timing out with the default timeout of 2 s on macOS runners.

Also fix comment in the integration test which
said that timeout is 1 minute but sets it to 3 minutes.
Set this timeout to 5 minutes as well.
2024-04-16 10:08:00 +00:00
Simon Laux
f9465f7512 api: add ChatListChanged and ChatListItemChanged events (#4476) 2024-04-15 22:35:19 +00:00
link2xt
489eae5d66 fix: format error context in Message::load_from_db 2024-04-14 20:40:27 +00:00
link2xt
b6c6a63a39 refactor: do not check for is_trash() in get_last_reaction_if_newer_than()
`Message::load_from_db_optional` does not return trashed messages anymore.
2024-04-14 18:46:40 +00:00
link2xt
c069190b68 api: don't load trashed messages with Message::load_from_db
API now pretends that trashed messages don't exist.
This way callers don't have to check if loaded message
belongs to trash chat.
If message may be trashed by the time it is attempted to be loaded,
callers should use Message::load_from_db_optional.

Most changes are around receive_status_update() function
because previously it relied on loading trashed status update
messages immediately after adding them to the database.
2024-04-14 17:43:41 +00:00
Hocuri
94ac2b1097 ci: Run doc tests with cargo test --workspace --doc (#5459)
Nextest doesn't run doc tests, so we need to run them with `cargo test
--workspace --doc`.

See https://github.com/nextest-rs/nextest/issues/16

Follow-up for #5457
2024-04-14 18:54:20 +02:00
link2xt
6080a52024 chore(cargo): update brotli from 4 to 5 2024-04-13 18:27:26 +00:00
link2xt
0aea7d1e02 fix: do not create ad-hoc groups from partial downloads 2024-04-12 01:19:16 +00:00
link2xt
08cbc54c00 fix: assign messages to chats based on not fully downloaded references 2024-04-12 01:19:16 +00:00
link2xt
9731ec419e build(nix): use stable Rust in flake.nix devshell
This way nightly clippy warnings are not generated
when devshell is used.

Nighly Rust is also not cached, e.g. rust-analyzer has to be rebuilt
if version from fenix is used.
2024-04-11 22:25:06 +00:00
iequidoo
e9cfcd9d1b fix: Don't try to do fetch_move_delete() if Trash is needed but not yet configured
This fixes things for Gmail f.e. Before, `Imap::fetch_move_delete()` was called before looking for
Trash and returned an error because of that failing the whole `fetch_idle()` which prevented
configuring Trash in turn.
2024-04-10 21:06:43 -03:00
link2xt
d39cbcdc8d ci: use cargo-nextest instead of cargo-test 2024-04-10 23:57:43 +00:00
link2xt
fbbefe6b49 chore: fix nightly clippy warnings 2024-04-10 15:41:48 +00:00
Sebastian Klähn
bab311730c ci: typos in ci files (#5453) 2024-04-10 15:20:48 +02:00
Sebastian Klähn
b47cad7e68 refactore: use clone_from() (#5451)
`a.clone_from(&b)` is equivalent to `a = b.clone()` in functionality,
but can be overridden to reuse the resources of a to avoid unnecessary
allocations.
2024-04-10 15:01:11 +02:00
link2xt
a3b62b9743 fix(deltachat-rpc-client): construct Thread with target keyword argument
`run` argument does not exist.

Also add `daemon=True`.
2024-04-09 05:55:38 +00:00
link2xt
9aa4c0e56b refactor(deltachat-rpc-client): use list, set and tuple instead of typing
`typing.List` is deprecated according to https://docs.python.org/3/library/typing.html#typing.List
Similar for `Set` and `Dict`.

`from __future__ import annotations` is for compatibility with Python 3.7.
2024-04-09 00:56:10 +00:00
Lothar
27d2b12e8d Adapt target install path if env var CARGO_BUILD_TARGET is set
When the env var CARGO_BUILD_TARGET is set, cargo will crossbuild for the given arch triplet. In this case, the targets will not be put into target/release/, but target/$CARGO_BUILD_TARGET/release/. Add this subdirectory, if neccessary.
2024-04-08 20:07:20 +00:00
link2xt
c1148e4117 chore(cargo): update env_logger 2024-04-08 19:44:35 +00:00
B. Petersen
295f7a291b api!: remove reactions ffi; all implementations use jsonrpc 2024-04-08 19:11:16 +00:00
link2xt
2be28f1311 test: move reaction tests to JSON-RPC 2024-04-08 19:11:16 +00:00
link2xt
2e42243de8 feat: port direct_imap.py into deltachat-rpc-client 2024-04-08 19:11:16 +00:00
link2xt
00f2585d8c api(deltachat-rpc-client): add ACFactory.get_accepted_chat() 2024-04-08 19:11:16 +00:00
link2xt
0b73f9cebd api(deltachat-rpc-client): add Account.bring_online() 2024-04-08 19:11:16 +00:00
link2xt
f5e8a04fd0 api(deltachat-rpc-client): return Message from Message.send_reaction() 2024-04-08 19:11:16 +00:00
link2xt
6721df7d57 api(deltachat-rpc-client): add Account.wait_for_reactions_changed() 2024-04-08 19:11:16 +00:00
link2xt
18d98d643b api(deltachat-rpc-client): add Chat.send_file() 2024-04-08 19:11:16 +00:00
link2xt
62758658ed api(deltachat-rpc-client): add Message.wait_until_delivered() 2024-04-08 19:11:16 +00:00
link2xt
03bb751a9b api(deltachat-rpc-client): add Account.create_chat() 2024-04-08 19:11:16 +00:00
link2xt
3ebb1ea95f chore(cargo): require tokio 1.37 and chrono 0.4.37
Make deps.rs happy about RUSTSEC-2020-0159 and RUSTSEC-2023-0001
2024-04-08 16:39:30 +00:00
iequidoo
c1d251010f fix: Keep webxdc instance for delete_device_after period after a status update (#5365)
If `delete_device_after` is configured, that period should be counted for webxdc instances from the
last status update, otherwise nothing prevents from deleting them. Use `msgs.timestamp_rcvd` to
store the last status update timestamp, it anyway isn't used for anything except displaying a
detailed message info. Also, as `ephemeral::select_expired_messages()` now also checks
`timestamp_rcvd`, we have an improvement that a message is guaranteed not to be deleted for the
`delete_device_after` period since its receipt. Before only the sort timestamp was checked which is
derived from the "sent" timestamp.
2024-04-07 22:08:48 -03:00
iequidoo
7e5959e495 test: display_chat(): Don't add day markers
Otherwise golden_test_chat() fails when run around midnight.
2024-04-07 21:03:33 -03:00
iequidoo
823da56f2d fix: Add tolerance to MemberListTimestamp (#5366)
Let's add a 1-minute tolerance to `Params::MemberListTimestamp`.

This adds to the group membership consistency algo the following properties:
- If remote group membership changes were made by two members in parallel, both of them are applied,
  no matter in which order the messages are received.
- If we remove a member locally, only explicit remote member additions/removals made in parallel are
  allowed, but not the synchronisation of the member list from "To". Before, if somebody managed to
  reply earlier than receiving our removal of a member, we added it back which doesn't look good.
2024-04-07 21:03:33 -03:00
link2xt
5bcc44ca9b chore: use ruff check instead of ruff
`ruff` without `check` is deprecated.
2024-04-07 19:57:05 +00:00
link2xt
4304e3f0be chore(cargo): require tokio 1.37.0
Make deps.rs happy about RUSTSEC-2023-0001
2024-04-07 19:18:44 +00:00
link2xt
e2e3abdf03 chore(cargo): update base64 to 0.22 2024-04-07 19:16:44 +00:00
link2xt
dcea188b62 chore(cargo): require smallvec 1.13.2
Make deps.rs happy about RUSTSEC-2021-0003
2024-04-07 19:12:34 +00:00
link2xt
5cf725a378 chore(cargo): require kamadak-exif 0.5.3
Make deps.rs happy about RUSTSEC-2021-0143
2024-04-07 19:09:58 +00:00
link2xt
2bf0ea9d91 docs: add deps.rs badge 2024-04-07 19:06:42 +00:00
B. Petersen
1df936aeac add 'Ubuntu Touch' to the list of 'frontend projects' 2024-04-07 16:20:45 +00:00
link2xt
9ab2c6df16 fix(deltachat-jsonrpc): block in inner_get_backup_qr
This change avoids the race between
`provide_backup` changing the state from NoProvider to Pending
and a call to `get_backup_qr` or `get_backup_qr_svg`.
With this change `get_backup_qr` and `get_backup_qr_svg`
always block until QR code is available,
even if `provide_backup` was not called yet.
2024-04-07 14:20:39 +00:00
link2xt
cf11741a8c refactor: do not ignore Contact::get_by_id errors in get_encrinfo 2024-04-07 10:12:17 +00:00
iequidoo
b6a12e3914 fix: Fix emitting ContactsChanged events on "recently seen" status change (#5377)
- Always emit `ContactsChanged` from `contact::update_last_seen()` if a contact was seen recently
  just for simplicity and symmetry with `RecentlySeenLoop::run()` which also may emit several events
  for single contact.
- Fix sleep time calculation in `RecentlySeenLoop::run()` -- `now` must be updated on every
  iteration, before the initial value was used every time which led to progressively long sleeps.
2024-04-06 18:21:12 -03:00
B. Petersen
b753440a68 fix: Message::get_summary() must not return reaction summary 2024-04-06 20:16:18 +02:00
B. Petersen
39abc8344c add a test for Message::get_summary() 2024-04-06 20:16:18 +02:00
link2xt
65c9e72bf4 test: test withdrawing group join QR codes 2024-04-05 22:34:02 +00:00
link2xt
ea4d954c77 fix: do not emit MSGS_CHANGED event for outgoing hidden messages
This includes synchronization messages.
2024-04-05 22:34:02 +00:00
link2xt
43523a96a2 api(deltachat-rpc-client): add check_qr and set_config_from_qr APIs 2024-04-05 22:34:02 +00:00
link2xt
2e2fa9e74f chore: update lockfile in /fuzz 2024-04-05 19:44:51 +00:00
B. Petersen
e43ffb20a1 chore(release): prepare for 1.137.2 2024-04-05 14:21:15 +00:00
link2xt
2f0f247e70 refactor: use Rust 1.77.0 support for recursion in async functions 2024-04-04 17:01:15 +00:00
Simon Laux
5bda4f0c26 update node constants for #5387 (#5429) 2024-04-04 15:31:02 +02:00
iequidoo
d39c8a3a19 refactor: is_probably_private_reply: Remove reaction-specific code
Instead, look up the 1:1 chat in `receive_imf::add_parts()`. This is a more generic approach to fix
assigning outgoing reactions to 1:1 chats in the multi-device setup. Although currently both
approaches give the same result, this way we can even implement a "react privately"
functionality. Maybe it sounds useless, but it seems better to have less reaction-specific code.
2024-04-03 21:29:27 -03:00
link2xt
e465415039 fix: do not ignore Message::load_from_db errors 2024-04-04 01:44:50 +02:00
B. Petersen
5cef77b8e6 fix: do not show empty summary if message reacted to is deleted
we checked for tombstones already using `is_trash()`,
however, we've overseen that tombstones get deleted at some point :)

therefore, just do not treat loading failures of the weak msg_id as errors -
usually, they are not - and if, just the normal summary is shown.
in theory, we could check for existance explicitly before tryong load_from_db,
however, that would be additional code (and maybe another database call)
and not worth the effort.

anyways, this commit also adds an explicit test
for physical deletion after housekeeping.
2024-04-04 01:44:50 +02:00
dependabot[bot]
60e733c30c chore(cargo): bump fast-socks5 from 0.9.5 to 0.9.6
Bumps [fast-socks5](https://github.com/dizda/fast-socks5) from 0.9.5 to 0.9.6.
- [Release notes](https://github.com/dizda/fast-socks5/releases)
- [Commits](https://github.com/dizda/fast-socks5/commits/v0.9.6)

---
updated-dependencies:
- dependency-name: fast-socks5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-03 21:51:57 +00:00
dependabot[bot]
8b98816eb9 Merge pull request #5404 from deltachat/dependabot/cargo/rustyline-14.0.0 2024-04-03 21:19:38 +00:00
link2xt
50165b3e35 chore: upgrade image from 0.24.9 to 0.25.1 2024-04-03 20:59:06 +00:00
link2xt
0be8b5a5c4 chore: upgrade h2 from 0.4.3 to 0.4.4
This upgrade contains a fix for RUSTSEC-2024-0332
2024-04-03 20:34:14 +00:00
B. Petersen
451bb6e9db add tests for get_summary_text_without_prefix() 2024-04-03 20:55:22 +02:00
B. Petersen
83196d4cb5 add get_summary_text_without_prefix()
use get_summary_text_without_prefix() to get raw summaries without prefixes,
this is needed for reaction summaries,
where we also do not show the name, so we do not want to show "Forwarded" as well.
2024-04-03 20:55:22 +02:00
B. Petersen
0003e55ad5 test reactions for forwarded messages 2024-04-03 20:55:22 +02:00
link2xt
02014eda6c chore: update from brotli 3.4.0 to brotli 4.0.0 2024-04-03 17:25:28 +00:00
link2xt
f1c6cd69e9 chore: update chrono from 0.4.34 to 0.4.37 2024-04-03 16:49:19 +00:00
bjoern
ace281ff6c feat: show reactions in summaries (#5387)
shows the last reaction in chatlist's summaries if there is no
newer message.

the reason to show reactions in the summary, is to make them a _little_
more visible when one is not in the chat. esp. in not-so-chatty or in
one-to-ones chats this becomes handy: imaging a question and someone
"answers" with "thumbs up" ... 

otoh, reactions are still tuned down on purpose: no notifications, chats
are opend as usual, the chatlist is not sorted by reactions and also the
date in the summary refer to the last message - i thought quite a bit
about that, this seems to be good compromise and will raise the fewest
questions. it is somehow clear to the users that reactions are not the
same as a real message. also, it is comparable easy to implement - no
UI changes required :)

all that is very close to what whatsapp is doing (figured that out by
quite some testing ... to cite @adbenitez: if in doubt, we can blame
whatsapp :)

technically, i first wanted to go for the "big solution" and add two
more columns, chat_id and timestamp, however, it seemed a bit bloated if
we really only need the last one. therefore, i just added the last
reaction information to the chat's param, which seems more performant
but also easier to code :)
2024-04-03 08:50:05 +00:00
dependabot[bot]
c9edd525e0 chore(cargo): bump rustyline from 13.0.0 to 14.0.0
Bumps [rustyline](https://github.com/kkawakam/rustyline) from 13.0.0 to 14.0.0.
- [Release notes](https://github.com/kkawakam/rustyline/releases)
- [Changelog](https://github.com/kkawakam/rustyline/blob/master/History.md)
- [Commits](https://github.com/kkawakam/rustyline/compare/v13.0.0...v14.0.0)

---
updated-dependencies:
- dependency-name: rustyline
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-03 06:12:01 +00:00
95 changed files with 3525 additions and 1349 deletions

View File

@@ -24,7 +24,7 @@ jobs:
name: Lint Rust
runs-on: ubuntu-latest
env:
RUSTUP_TOOLCHAIN: 1.77.0
RUSTUP_TOOLCHAIN: 1.77.1
steps:
- uses: actions/checkout@v4
with:
@@ -83,15 +83,15 @@ jobs:
matrix:
include:
- os: ubuntu-latest
rust: 1.77.0
rust: 1.77.1
- os: windows-latest
rust: 1.77.0
rust: 1.77.1
- os: macos-latest
rust: 1.77.0
rust: 1.77.1
# Minimum Supported Rust Version = 1.70.0
# Minimum Supported Rust Version = 1.77.0
- os: ubuntu-latest
rust: 1.70.0
rust: 1.77.0
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
@@ -105,10 +105,20 @@ jobs:
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@v2
- name: Install nextest
uses: taiki-e/install-action@v2
with:
tool: nextest
- name: Tests
env:
RUST_BACKTRACE: 1
run: cargo test --workspace
run: cargo nextest run --workspace
- name: Doc-Tests
env:
RUST_BACKTRACE: 1
run: cargo test --workspace --doc
- name: Test cargo vendor
run: cargo vendor

View File

@@ -1,4 +1,4 @@
name: Build & Deploy Documentation on rs.delta.chat, c.delta.chat, py.delta.chat
name: Build & deploy documentation on rs.delta.chat, c.delta.chat, and py.delta.chat
on:
push:
@@ -56,7 +56,7 @@ jobs:
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build C documentation
run: nix build .#docs
- name: Upload to py.delta.chat
- name: Upload to c.delta.chat
run: |
mkdir -p "$HOME/.ssh"
echo "${{ secrets.CODESPEAK_KEY }}" > "$HOME/.ssh/key"

View File

@@ -1,5 +1,5 @@
# GitHub Actions workflow
# to build `deltachat_fii` crate documentation
# to build `deltachat_ffi` crate documentation
# and upload it to <https://cffi.delta.chat/>
name: Build & Deploy Documentation on cffi.delta.chat

View File

@@ -1,5 +1,99 @@
# Changelog
## [1.137.3] - 2024-04-16
### API-Changes
- [**breaking**] Remove reactions ffi; all implementations use jsonrpc.
- Don't load trashed messages with `Message::load_from_db`.
- Add `ChatListChanged` and `ChatListItemChanged` events ([#4476](https://github.com/deltachat/deltachat-core-rust/pull/4476)).
- deltachat-rpc-client: Add `check_qr` and `set_config_from_qr` APIs.
- deltachat-rpc-client: Add `Account.create_chat()`.
- deltachat-rpc-client: Add `Message.wait_until_delivered()`.
- deltachat-rpc-client: Add `Chat.send_file()`.
- deltachat-rpc-client: Add `Account.wait_for_reactions_changed()`.
- deltachat-rpc-client: Return Message from `Message.send_reaction()`.
- deltachat-rpc-client: Add `Account.bring_online()`.
- deltachat-rpc-client: Add `ACFactory.get_accepted_chat()`.
### Features / Changes
- Port `direct_imap.py` into deltachat-rpc-client.
### Fixes
- Do not emit `MSGS_CHANGED` event for outgoing hidden messages.
- `Message::get_summary()` must not return reaction summary.
- Fix emitting `ContactsChanged` events on "recently seen" status change ([#5377](https://github.com/deltachat/deltachat-core-rust/pull/5377)).
- deltachat-jsonrpc: block in `inner_get_backup_qr`.
- Add tolerance to `MemberListTimestamp` ([#5366](https://github.com/deltachat/deltachat-core-rust/pull/5366)).
- Keep webxdc instance for `delete_device_after` period after a status update ([#5365](https://github.com/deltachat/deltachat-core-rust/pull/5365)).
- Don't try to do `fetch_move_delete()` if Trash is needed but not yet configured.
- Assign messages to chats based on not fully downloaded references.
- Do not create ad-hoc groups from partial downloads.
- deltachat-rpc-client: construct Thread with `target` keyword argument.
- Format error context in `Message::load_from_db`.
### Build system
- cmake: adapt target install path if env var `CARGO_BUILD_TARGET` is set.
- nix: Use stable Rust in flake.nix devshell.
### CI
- Use cargo-nextest instead of cargo-test.
- Run doc tests with cargo test --workspace --doc ([#5459](https://github.com/deltachat/deltachat-core-rust/pull/5459)).
- Typos in CI files ([#5453](https://github.com/deltachat/deltachat-core-rust/pull/5453)).
### Documentation
- Add <https://deps.rs> badge.
- Add 'Ubuntu Touch' to the list of 'frontend projects'
### Refactor
- Do not ignore `Contact::get_by_id` errors in `get_encrinfo`.
- deltachat-rpc-client: Use `list`, `set` and `tuple` instead of `typing`.
- Use `clone_from()` ([#5451](https://github.com/deltachat/deltachat-core-rust/pull/5451)).
- Do not check for `is_trash()` in `get_last_reaction_if_newer_than()`.
- Split off functional contact tools into its own crate ([#5444](https://github.com/deltachat/deltachat-core-rust/pull/5444))
- Fix nightly clippy warnings.
### Tests
- Test withdrawing group join QR codes.
- `display_chat()`: Don't add day markers.
- Move reaction tests to JSON-RPC.
- node: Increase 'static tests' timeout to 5 minutes.
## [1.137.2] - 2024-04-05
### API-Changes
- [**breaking**] Increase Minimum Supported Rust Version to 1.77.0.
### Features / Changes
- Show reactions in summaries ([#5387](https://github.com/deltachat/deltachat-core-rust/pull/5387)).
### Tests
- Test reactions for forwarded messages
### Refactor
- `is_probably_private_reply`: Remove reaction-specific code.
- Use Rust 1.77.0 support for recursion in async functions.
### Miscellaneous Tasks
- cargo: Bump rustyline from 13.0.0 to 14.0.0.
- Update chrono from 0.4.34 to 0.4.37.
- Update from brotli 3.4.0 to brotli 4.0.0.
- Upgrade `h2` from 0.4.3 to 0.4.4.
- Upgrade `image` from 0.24.9 to 0.25.1.
- cargo: Bump fast-socks5 from 0.9.5 to 0.9.6.
## [1.137.1] - 2024-04-03
### CI
@@ -3843,3 +3937,5 @@ https://github.com/deltachat/deltachat-core-rust/pulls?q=is%3Apr+is%3Aclosed
[1.136.6]: https://github.com/deltachat/deltachat-core-rust/compare/v1.136.5...v1.136.6
[1.137.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.136.6...v1.137.0
[1.137.1]: https://github.com/deltachat/deltachat-core-rust/compare/v1.137.0...v1.137.1
[1.137.2]: https://github.com/deltachat/deltachat-core-rust/compare/v1.137.1...v1.137.2
[1.137.3]: https://github.com/deltachat/deltachat-core-rust/compare/v1.137.2...v1.137.3

View File

@@ -12,6 +12,12 @@ else()
set(DYNAMIC_EXT "dll")
endif()
if(DEFINED ENV{CARGO_BUILD_TARGET})
set(ARCH_DIR "$ENV{CARGO_BUILD_TARGET}")
else()
set(ARCH_DIR "./")
endif()
add_custom_command(
OUTPUT
"${CMAKE_BINARY_DIR}/target/release/libdeltachat.a"
@@ -35,6 +41,6 @@ add_custom_target(
)
install(FILES "deltachat-ffi/deltachat.h" DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(FILES "${CMAKE_BINARY_DIR}/target/release/libdeltachat.a" DESTINATION ${CMAKE_INSTALL_LIBDIR})
install(FILES "${CMAKE_BINARY_DIR}/target/release/libdeltachat.${DYNAMIC_EXT}" DESTINATION ${CMAKE_INSTALL_LIBDIR})
install(FILES "${CMAKE_BINARY_DIR}/target/release/pkgconfig/deltachat.pc" DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig)
install(FILES "${CMAKE_BINARY_DIR}/target/${ARCH_DIR}/release/libdeltachat.a" DESTINATION ${CMAKE_INSTALL_LIBDIR})
install(FILES "${CMAKE_BINARY_DIR}/target/${ARCH_DIR}/release/libdeltachat.${DYNAMIC_EXT}" DESTINATION ${CMAKE_INSTALL_LIBDIR})
install(FILES "${CMAKE_BINARY_DIR}/target/${ARCH_DIR}/release/pkgconfig/deltachat.pc" DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig)

184
Cargo.lock generated
View File

@@ -113,12 +113,54 @@ dependencies = [
"winapi",
]
[[package]]
name = "anstream"
version = "0.6.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc"
[[package]]
name = "anstyle-parse"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7"
dependencies = [
"anstyle",
"windows-sys 0.52.0",
]
[[package]]
name = "anyhow"
version = "1.0.81"
@@ -422,6 +464,12 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51"
[[package]]
name = "base64ct"
version = "1.6.0"
@@ -507,9 +555,9 @@ dependencies = [
[[package]]
name = "brotli"
version = "3.4.0"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "516074a47ef4bce09577a3b379392300159ce5b1ba2e501ff1c819950066100f"
checksum = "19483b140a7ac7174d34b5a581b406c64f84da5409d3e09cf4fff604f9270e67"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -518,9 +566,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
version = "2.5.1"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f"
checksum = "e6221fe77a248b9117d431ad93761222e1cf8ff282d9d1d5d9f53d6299a1cf76"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -646,6 +694,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "charset"
version = "0.1.3"
@@ -658,9 +712,9 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.34"
version = "0.4.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bc015644b92d5890fab7489e49d21f879d5c990186827d42ec511919404f38b"
checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e"
dependencies = [
"android-tzdata",
"iana-time-zone",
@@ -753,6 +807,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
[[package]]
name = "concurrent-queue"
version = "2.4.0"
@@ -1085,7 +1145,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "1.137.1"
version = "1.137.3"
dependencies = [
"ansi_term",
"anyhow",
@@ -1095,10 +1155,11 @@ dependencies = [
"async-smtp",
"async_zip",
"backtrace",
"base64 0.21.7",
"base64 0.22.0",
"brotli",
"chrono",
"criterion",
"deltachat-contact-tools",
"deltachat-time",
"deltachat_derive",
"email",
@@ -1164,16 +1225,26 @@ dependencies = [
"uuid",
]
[[package]]
name = "deltachat-contact-tools"
version = "0.1.0"
dependencies = [
"anyhow",
"once_cell",
"regex",
"rusqlite",
]
[[package]]
name = "deltachat-jsonrpc"
version = "1.137.1"
version = "1.137.3"
dependencies = [
"anyhow",
"async-channel 2.2.0",
"axum",
"base64 0.21.7",
"base64 0.22.0",
"deltachat",
"env_logger",
"env_logger 0.11.3",
"futures",
"log",
"num-traits",
@@ -1190,7 +1261,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "1.137.1"
version = "1.137.3"
dependencies = [
"ansi_term",
"anyhow",
@@ -1205,12 +1276,12 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "1.137.1"
version = "1.137.3"
dependencies = [
"anyhow",
"deltachat",
"deltachat-jsonrpc",
"env_logger",
"env_logger 0.11.3",
"futures-lite",
"log",
"serde",
@@ -1234,7 +1305,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "1.137.1"
version = "1.137.3"
dependencies = [
"anyhow",
"deltachat",
@@ -1763,6 +1834,16 @@ dependencies = [
"syn 2.0.57",
]
[[package]]
name = "env_filter"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a009aa4810eb158359dda09d0c87378e4bbb89b5a801f016885a4707ba24f7ea"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.10.2"
@@ -1776,6 +1857,19 @@ dependencies = [
"termcolor",
]
[[package]]
name = "env_logger"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b35839ba51819680ba087cd351788c9a3c476841207e0b8cee0b04722343b9"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"humantime",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.1"
@@ -1848,9 +1942,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fast-socks5"
version = "0.9.5"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbcc731f3c17a5053e07e6a2290918da75cd8b9b1217b419721f715674ac520c"
checksum = "f89f36d4ee12370d30d57b16c7e190950a1a916e7dbbb5fd5a412f5ef913fe84"
dependencies = [
"anyhow",
"async-trait",
@@ -2166,9 +2260,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.3"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51ee2dd2e4f378392eeff5d51618cd9a63166a2513846bbc55f21cfacd9199d4"
checksum = "816ec7294445779408f36fe57bc5b7fc1cf59664059096c65f905c1c61f58069"
dependencies = [
"bytes",
"fnv",
@@ -2505,17 +2599,29 @@ dependencies = [
[[package]]
name = "image"
version = "0.24.9"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
checksum = "fd54d660e773627692c524beaad361aca785a4f9f5730ce91f42aabe5bce3d11"
dependencies = [
"bytemuck",
"byteorder",
"color_quant",
"gif",
"jpeg-decoder",
"image-webp",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a84a25dcae3ac487bc24ef280f9e20c79c9b1a3e5e32cbed3041d1c514aa87c"
dependencies = [
"byteorder",
"thiserror",
]
[[package]]
@@ -2640,12 +2746,6 @@ version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c"
[[package]]
name = "jpeg-decoder"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
[[package]]
name = "js-sys"
version = "0.3.69"
@@ -2972,12 +3072,13 @@ dependencies = [
[[package]]
name = "nix"
version = "0.27.1"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.4.2",
"cfg-if",
"cfg_aliases",
"libc",
]
@@ -3586,7 +3687,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c"
dependencies = [
"env_logger",
"env_logger 0.10.2",
"log",
]
@@ -4225,9 +4326,9 @@ checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
[[package]]
name = "rustyline"
version = "13.0.0"
version = "14.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02a2d683a4ac90aeef5b1013933f6d977bd37d51ff3f4dad829d4931a7e6be86"
checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63"
dependencies = [
"bitflags 2.4.2",
"cfg-if",
@@ -4242,7 +4343,7 @@ dependencies = [
"unicode-segmentation",
"unicode-width",
"utf8parse",
"winapi",
"windows-sys 0.52.0",
]
[[package]]
@@ -5900,3 +6001,18 @@ dependencies = [
"quote",
"syn 2.0.57",
]
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-jpeg"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec866b44a2a1fd6133d363f073ca1b179f438f99e7e5bfb1e33f7181facfe448"
dependencies = [
"zune-core",
]

View File

@@ -1,9 +1,9 @@
[package]
name = "deltachat"
version = "1.137.1"
version = "1.137.3"
edition = "2021"
license = "MPL-2.0"
rust-version = "1.70"
rust-version = "1.77"
repository = "https://github.com/deltachat/deltachat-core-rust"
[profile.dev]
@@ -34,19 +34,20 @@ strip = true
[dependencies]
deltachat_derive = { path = "./deltachat_derive" }
deltachat-time = { path = "./deltachat-time" }
deltachat-contact-tools = { path = "./deltachat-contact-tools" }
format-flowed = { path = "./format-flowed" }
ratelimit = { path = "./deltachat-ratelimit" }
anyhow = "1"
anyhow = { workspace = true }
async-channel = "2.0.0"
async-imap = { version = "0.9.7", default-features = false, features = ["runtime-tokio"] }
async-native-tls = { version = "0.5", default-features = false, features = ["runtime-tokio"] }
async-smtp = { version = "0.9", default-features = false, features = ["runtime-tokio"] }
async_zip = { version = "0.0.12", default-features = false, features = ["deflate", "fs"] }
backtrace = "0.3"
base64 = "0.21"
brotli = { version = "3.4", default-features=false, features = ["std"] }
chrono = { version = "0.4", default-features=false, features = ["clock", "std"] }
base64 = "0.22"
brotli = { version = "5", default-features=false, features = ["std"] }
chrono = { version = "0.4.37", default-features=false, features = ["clock", "std"] }
email = { git = "https://github.com/deltachat/rust-email", branch = "master" }
encoded-words = { git = "https://github.com/async-email/encoded-words", branch = "master" }
escaper = "0.1"
@@ -57,9 +58,9 @@ futures-lite = "2.3.0"
hex = "0.4.0"
hickory-resolver = "0.24"
humansize = "2"
image = { version = "0.24.9", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
image = { version = "0.25.1", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
iroh = { version = "0.4.2", default-features = false }
kamadak-exif = "0.5"
kamadak-exif = "0.5.3"
lettre_email = { git = "https://github.com/deltachat/lettre", branch = "master" }
libc = "0.2"
mailparse = "0.14"
@@ -67,7 +68,7 @@ mime = "0.3.17"
num_cpus = "1.16"
num-derive = "0.4"
num-traits = "0.2"
once_cell = "1.18.0"
once_cell = { workspace = true }
percent-encoding = "2.3"
parking_lot = "0.12"
pgp = { version = "0.11", default-features = false }
@@ -77,22 +78,22 @@ qrcodegen = "1.7.0"
quick-xml = "0.31"
quoted_printable = "0.5"
rand = "0.8"
regex = "1.10"
regex = { workspace = true }
reqwest = { version = "0.12.2", features = ["json"] }
rusqlite = { version = "0.31", features = ["sqlcipher"] }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rust-hsluv = "0.1"
sanitize-filename = "0.5"
serde_json = "1"
serde = { version = "1.0", features = ["derive"] }
sha-1 = "0.10"
sha2 = "0.10"
smallvec = "1"
smallvec = "1.13.2"
strum = "0.26"
strum_macros = "0.26"
tagger = "4.3.4"
textwrap = "0.16.1"
thiserror = "1"
tokio = { version = "1", features = ["fs", "rt-multi-thread", "macros"] }
tokio = { version = "1.37.0", features = ["fs", "rt-multi-thread", "macros"] }
tokio-io-timeout = "1.2.0"
tokio-stream = { version = "0.1.15", features = ["fs"] }
tokio-tar = { version = "0.3" } # TODO: integrate tokio into async-tar
@@ -119,7 +120,7 @@ pretty_env_logger = "0.5"
proptest = { version = "1", default-features = false, features = ["std"] }
tempfile = "3"
testdir = "0.9.0"
tokio = { version = "1", features = ["parking_lot", "rt-multi-thread", "macros"] }
tokio = { version = "1.37.0", features = ["parking_lot", "rt-multi-thread", "macros"] }
pretty_assertions = "1.3.0"
[workspace]
@@ -132,6 +133,7 @@ members = [
"deltachat-repl",
"deltachat-time",
"format-flowed",
"deltachat-contact-tools",
]
[[bench]]
@@ -162,6 +164,12 @@ harness = false
name = "send_events"
harness = false
[workspace.dependencies]
anyhow = "1"
once_cell = "1.18.0"
regex = "1.10"
rusqlite = { version = "0.31" }
[features]
default = ["vendored"]
internals = []

View File

@@ -6,6 +6,9 @@
<a href="https://github.com/deltachat/deltachat-core-rust/actions/workflows/ci.yml">
<img alt="Rust CI" src="https://github.com/deltachat/deltachat-core-rust/actions/workflows/ci.yml/badge.svg">
</a>
<a href="https://deps.rs/repo/github/deltachat/deltachat-core-rust">
<img alt="dependency status" src="https://deps.rs/repo/github/deltachat/deltachat-core-rust/status.svg">
</a>
</p>
<p align="center">
@@ -192,6 +195,7 @@ or its language bindings:
- [Desktop](https://github.com/deltachat/deltachat-desktop)
- [Pidgin](https://code.ur.gs/lupine/purple-plugin-delta/)
- [Telepathy](https://code.ur.gs/lupine/telepathy-padfoot/)
- [Ubuntu Touch](https://codeberg.org/lk108/deltatouch)
- several **Bots**
[^1]: Out of date / unmaintained, if you like those languages feel free to start maintaining them. If you have questions we'll help you, please ask in the issues.

View File

@@ -0,0 +1,18 @@
[package]
name = "deltachat-contact-tools"
version = "0.1.0"
edition = "2021"
description = "Contact-related tools, like parsing vcards and sanitizing name and address"
license = "MPL-2.0"
# TODO maybe it should be called "deltachat-text-utils" or similar?
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = { workspace = true }
once_cell = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true } # Needed in order to `impl rusqlite::types::ToSql for EmailAddress`. Could easily be put behind a feature.
[dev-dependencies]
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.

View File

@@ -0,0 +1,280 @@
//! Contact-related tools, like parsing vcards and sanitizing name and address
#![forbid(unsafe_code)]
#![warn(
unused,
clippy::correctness,
missing_debug_implementations,
missing_docs,
clippy::all,
clippy::wildcard_imports,
clippy::needless_borrow,
clippy::cast_lossless,
clippy::unused_async,
clippy::explicit_iter_loop,
clippy::explicit_into_iter_loop,
clippy::cloned_instead_of_copied
)]
#![cfg_attr(not(test), warn(clippy::indexing_slicing))]
#![allow(
clippy::match_bool,
clippy::mixed_read_write_in_expression,
clippy::bool_assert_comparison,
clippy::manual_split_once,
clippy::format_push_string,
clippy::bool_to_int_with_if
)]
use std::fmt;
use std::ops::Deref;
use anyhow::bail;
use anyhow::Result;
use once_cell::sync::Lazy;
use regex::Regex;
/// Valid contact address.
#[derive(Debug, Clone)]
pub struct ContactAddress(String);
impl Deref for ContactAddress {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl AsRef<str> for ContactAddress {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for ContactAddress {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl ContactAddress {
/// Constructs a new contact address from string,
/// normalizing and validating it.
pub fn new(s: &str) -> Result<Self> {
let addr = addr_normalize(s);
if !may_be_valid_addr(&addr) {
bail!("invalid address {:?}", s);
}
Ok(Self(addr.to_string()))
}
}
/// Allow converting [`ContactAddress`] to an SQLite type.
impl rusqlite::types::ToSql for ContactAddress {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput> {
let val = rusqlite::types::Value::Text(self.0.to_string());
let out = rusqlite::types::ToSqlOutput::Owned(val);
Ok(out)
}
}
/// Make the name and address
pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) {
static ADDR_WITH_NAME_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new("(.*)<(.*)>").unwrap());
if let Some(captures) = ADDR_WITH_NAME_REGEX.captures(addr.as_ref()) {
(
if name.is_empty() {
strip_rtlo_characters(
&captures
.get(1)
.map_or("".to_string(), |m| normalize_name(m.as_str())),
)
} else {
strip_rtlo_characters(name)
},
captures
.get(2)
.map_or("".to_string(), |m| m.as_str().to_string()),
)
} else {
(strip_rtlo_characters(name), addr.to_string())
}
}
/// Normalize a name.
///
/// - Remove quotes (come from some bad MUA implementations)
/// - Trims the resulting string
///
/// Typically, this function is not needed as it is called implicitly by `Contact::add_address_book`.
pub fn normalize_name(full_name: &str) -> String {
let full_name = full_name.trim();
if full_name.is_empty() {
return full_name.into();
}
match full_name.as_bytes() {
[b'\'', .., b'\''] | [b'\"', .., b'\"'] | [b'<', .., b'>'] => full_name
.get(1..full_name.len() - 1)
.map_or("".to_string(), |s| s.trim().to_string()),
_ => full_name.to_string(),
}
}
const RTLO_CHARACTERS: [char; 5] = ['\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}'];
/// This method strips all occurrences of the RTLO Unicode character.
/// [Why is this needed](https://github.com/deltachat/deltachat-core-rust/issues/3479)?
pub fn strip_rtlo_characters(input_str: &str) -> String {
input_str.replace(|char| RTLO_CHARACTERS.contains(&char), "")
}
/// Returns false if addr is an invalid address, otherwise true.
pub fn may_be_valid_addr(addr: &str) -> bool {
let res = EmailAddress::new(addr);
res.is_ok()
}
/// Returns address lowercased,
/// with whitespace trimmed and `mailto:` prefix removed.
pub fn addr_normalize(addr: &str) -> String {
let norm = addr.trim().to_lowercase();
if norm.starts_with("mailto:") {
norm.get(7..).unwrap_or(&norm).to_string()
} else {
norm
}
}
/// Compares two email addresses, normalizing them beforehand.
pub fn addr_cmp(addr1: &str, addr2: &str) -> bool {
let norm1 = addr_normalize(addr1);
let norm2 = addr_normalize(addr2);
norm1 == norm2
}
///
/// Represents an email address, right now just the `name@domain` portion.
///
/// # Example
///
/// ```
/// use deltachat_contact_tools::EmailAddress;
/// let email = match EmailAddress::new("someone@example.com") {
/// Ok(addr) => addr,
/// Err(e) => panic!("Error parsing address, error was {}", e),
/// };
/// assert_eq!(&email.local, "someone");
/// assert_eq!(&email.domain, "example.com");
/// assert_eq!(email.to_string(), "someone@example.com");
/// ```
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct EmailAddress {
/// Local part of the email address.
pub local: String,
/// Email address domain.
pub domain: String,
}
impl fmt::Display for EmailAddress {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}@{}", self.local, self.domain)
}
}
impl EmailAddress {
/// Performs a dead-simple parse of an email address.
pub fn new(input: &str) -> Result<EmailAddress> {
if input.is_empty() {
bail!("empty string is not valid");
}
let parts: Vec<&str> = input.rsplitn(2, '@').collect();
if input
.chars()
.any(|c| c.is_whitespace() || c == '<' || c == '>')
{
bail!("Email {:?} must not contain whitespaces, '>' or '<'", input);
}
match &parts[..] {
[domain, local] => {
if local.is_empty() {
bail!("empty string is not valid for local part in {:?}", input);
}
if domain.is_empty() {
bail!("missing domain after '@' in {:?}", input);
}
if domain.ends_with('.') {
bail!("Domain {domain:?} should not contain the dot in the end");
}
Ok(EmailAddress {
local: (*local).to_string(),
domain: (*domain).to_string(),
})
}
_ => bail!("Email {:?} must contain '@' character", input),
}
}
}
impl rusqlite::types::ToSql for EmailAddress {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput> {
let val = rusqlite::types::Value::Text(self.to_string());
let out = rusqlite::types::ToSqlOutput::Owned(val);
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_contact_address() -> Result<()> {
let alice_addr = "alice@example.org";
let contact_address = ContactAddress::new(alice_addr)?;
assert_eq!(contact_address.as_ref(), alice_addr);
let invalid_addr = "<> foobar";
assert!(ContactAddress::new(invalid_addr).is_err());
Ok(())
}
#[test]
fn test_emailaddress_parse() {
assert_eq!(EmailAddress::new("").is_ok(), false);
assert_eq!(
EmailAddress::new("user@domain.tld").unwrap(),
EmailAddress {
local: "user".into(),
domain: "domain.tld".into(),
}
);
assert_eq!(
EmailAddress::new("user@localhost").unwrap(),
EmailAddress {
local: "user".into(),
domain: "localhost".into()
}
);
assert_eq!(EmailAddress::new("uuu").is_ok(), false);
assert_eq!(EmailAddress::new("dd.tt").is_ok(), false);
assert!(EmailAddress::new("tt.dd@uu").is_ok());
assert!(EmailAddress::new("u@d").is_ok());
assert!(EmailAddress::new("u@d.").is_err());
assert!(EmailAddress::new("u@d.t").is_ok());
assert_eq!(
EmailAddress::new("u@d.tt").unwrap(),
EmailAddress {
local: "u".into(),
domain: "d.tt".into(),
}
);
assert!(EmailAddress::new("u@tt").is_ok());
assert_eq!(EmailAddress::new("@d.tt").is_ok(), false);
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "1.137.1"
version = "1.137.3"
description = "Deltachat FFI"
edition = "2018"
readme = "README.md"
@@ -20,7 +20,7 @@ libc = "0.2"
human-panic = { version = "1", default-features = false }
num-traits = "0.2"
serde_json = "1.0"
tokio = { version = "1", features = ["rt-multi-thread"] }
tokio = { version = "1.37.0", features = ["rt-multi-thread"] }
anyhow = "1"
thiserror = "1"
rand = "0.8"

View File

@@ -17,7 +17,6 @@ typedef struct _dc_array dc_array_t;
typedef struct _dc_chatlist dc_chatlist_t;
typedef struct _dc_chat dc_chat_t;
typedef struct _dc_msg dc_msg_t;
typedef struct _dc_reactions dc_reactions_t;
typedef struct _dc_contact dc_contact_t;
typedef struct _dc_lot dc_lot_t;
typedef struct _dc_provider dc_provider_t;
@@ -1117,36 +1116,6 @@ uint32_t dc_send_text_msg (dc_context_t* context, uint32_t ch
uint32_t dc_send_videochat_invitation (dc_context_t* context, uint32_t chat_id);
/**
* Send a reaction to message.
*
* Reaction is a string of emojis separated by spaces. Reaction to a
* single message can be sent multiple times. The last reaction
* received overrides all previously received reactions. It is
* possible to remove all reactions by sending an empty string.
*
* @deprecated 2023-11-27, use jsonrpc method `send_reaction` instead
* @memberof dc_context_t
* @param context The context object.
* @param msg_id ID of the message you react to.
* @param reaction A string consisting of emojis separated by spaces.
* @return The ID of the message sent out or 0 for errors.
*/
uint32_t dc_send_reaction (dc_context_t* context, uint32_t msg_id, char *reaction);
/**
* Get a structure with reactions to the message.
*
* @deprecated 2023-11-27, use jsonrpc method `get_message_reactions` instead
* @memberof dc_context_t
* @param context The context object.
* @param msg_id The message ID to get reactions for.
* @return A structure with all reactions to the message.
*/
dc_reactions_t* dc_get_msg_reactions (dc_context_t *context, int msg_id);
/**
* A webxdc instance sends a status update to its other members.
*
@@ -5320,52 +5289,6 @@ uint32_t dc_lot_get_id (const dc_lot_t* lot);
int64_t dc_lot_get_timestamp (const dc_lot_t* lot);
/**
* @class dc_reactions_t
* @deprecated 2023-11-27, use jsonrpc method `get_message_reactions` instead
*
* An object representing all reactions for a single message.
*/
/**
* Returns array of contacts which reacted to the given message.
*
* @deprecated 2023-11-27, use jsonrpc method `get_message_reactions` instead
* @memberof dc_reactions_t
* @param reactions The object containing message reactions.
* @return array of contact IDs. Use dc_array_get_cnt() to get array length and
* dc_array_get_id() to get the IDs. Should be freed using `dc_array_unref()` after usage.
*/
dc_array_t* dc_reactions_get_contacts(dc_reactions_t* reactions);
/**
* Returns a string containing space-separated reactions of a single contact.
*
* @deprecated 2023-11-27, use jsonrpc method `get_message_reactions` instead
* @memberof dc_reactions_t
* @param reactions The object containing message reactions.
* @param contact_id ID of the contact.
* @return Space-separated list of emoji sequences, which could be empty.
* Returned string should not be modified and should be freed
* with dc_str_unref() after usage.
*/
char* dc_reactions_get_by_contact_id(dc_reactions_t* reactions, uint32_t contact_id);
/**
* Frees an object containing message reactions.
*
* Reactions objects are created by dc_get_msg_reactions().
*
* @deprecated 2023-11-27
* @memberof dc_reactions_t
* @param reactions The object to free.
* If NULL is given, nothing is done.
*/
void dc_reactions_unref (dc_reactions_t* reactions);
/**
* @defgroup DC_MSG DC_MSG
*
@@ -6290,7 +6213,24 @@ void dc_event_unref(dc_event_t* event);
* This event is only emitted by the account manager
*/
#define DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE 2200
#define DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE 2200
/**
* Inform that set of chats or the order of the chats in the chatlist has changed.
*
* Sometimes this is emitted together with `DC_EVENT_CHATLIST_ITEM_CHANGED`.
*/
#define DC_EVENT_CHATLIST_CHANGED 2300
/**
* Inform that all or a single chat list item changed and needs to be rerendered
* If `chat_id` is set to 0, then all currently visible chats need to be rerendered, and all not-visible items need to be cleared from cache if the UI has a cache.
*
* @param data1 (int) chat_id chat id of chatlist item to be rerendered, if chat_id = 0 all (cached & visible) items need to be rerendered
*/
#define DC_EVENT_CHATLIST_ITEM_CHANGED 2301
/**
* @}
@@ -7296,6 +7236,22 @@ void dc_event_unref(dc_event_t* event);
/// `%1$s` will be replaced by the provider's domain.
#define DC_STR_INVALID_UNENCRYPTED_MAIL 174
/// "You reacted %1$s to '%2$s'"
///
/// `%1$s` will be replaced by the reaction, usually an emoji
/// `%2$s` will be replaced by the summary of the message the reaction refers to
///
/// Used in summaries.
#define DC_STR_YOU_REACTED 176
/// "%1$s reacted %2$s to '%3$s'"
///
/// `%1$s` will be replaced by the name the contact who reacted
/// `%2$s` will be replaced by the reaction, usually an emoji
/// `%3$s` will be replaced by the summary of the message the reaction refers to
///
/// Used in summaries.
#define DC_STR_REACTED_BY 177
/**
* @}

View File

@@ -32,7 +32,6 @@ use deltachat::imex::BackupProvider;
use deltachat::key::preconfigure_keypair;
use deltachat::message::MsgId;
use deltachat::qr_code_generator::{generate_backup_qr, get_securejoin_qr_svg};
use deltachat::reaction::{get_msg_reactions, send_reaction, Reactions};
use deltachat::stock_str::StockMessage;
use deltachat::webxdc::StatusUpdateSerial;
use deltachat::*;
@@ -68,8 +67,6 @@ const DC_GCM_INFO_ONLY: u32 = 0x02;
/// Struct representing the deltachat context.
pub type dc_context_t = Context;
pub type dc_reactions_t = Reactions;
static RT: Lazy<Runtime> = Lazy::new(|| Runtime::new().expect("unable to create tokio runtime"));
fn block_on<T>(fut: T) -> T::Output
@@ -567,6 +564,8 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
EventType::WebxdcStatusUpdate { .. } => 2120,
EventType::WebxdcInstanceDeleted { .. } => 2121,
EventType::AccountsBackgroundFetchDone => 2200,
EventType::ChatlistChanged => 2300,
EventType::ChatlistItemChanged { .. } => 2301,
}
}
@@ -596,6 +595,7 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
| EventType::IncomingMsgBunch { .. }
| EventType::ErrorSelfNotInGroup(_)
| EventType::AccountsBackgroundFetchDone => 0,
EventType::ChatlistChanged => 0,
EventType::MsgsChanged { chat_id, .. }
| EventType::ReactionsChanged { chat_id, .. }
| EventType::IncomingMsg { chat_id, .. }
@@ -620,6 +620,9 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
}
EventType::WebxdcStatusUpdate { msg_id, .. } => msg_id.to_u32() as libc::c_int,
EventType::WebxdcInstanceDeleted { msg_id, .. } => msg_id.to_u32() as libc::c_int,
EventType::ChatlistItemChanged { chat_id } => {
chat_id.unwrap_or_default().to_u32() as libc::c_int
}
}
}
@@ -656,6 +659,8 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::IncomingMsgBunch { .. }
| EventType::SelfavatarChanged
| EventType::AccountsBackgroundFetchDone
| EventType::ChatlistChanged
| EventType::ChatlistItemChanged { .. }
| EventType::ConfigSynced { .. } => 0,
EventType::ChatModified(_) => 0,
EventType::MsgsChanged { msg_id, .. }
@@ -720,7 +725,9 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
| EventType::WebxdcInstanceDeleted { .. }
| EventType::AccountsBackgroundFetchDone
| EventType::ChatEphemeralTimerModified { .. }
| EventType::IncomingMsgBunch { .. } => ptr::null_mut(),
| EventType::IncomingMsgBunch { .. }
| EventType::ChatlistItemChanged { .. }
| EventType::ChatlistChanged => ptr::null_mut(),
EventType::ConfigureProgress { comment, .. } => {
if let Some(comment) = comment {
comment.to_c_string().unwrap_or_default().into_raw()
@@ -1015,49 +1022,6 @@ pub unsafe extern "C" fn dc_send_videochat_invitation(
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_send_reaction(
context: *mut dc_context_t,
msg_id: u32,
reaction: *const libc::c_char,
) -> u32 {
if context.is_null() {
eprintln!("ignoring careless call to dc_send_reaction()");
return 0;
}
let ctx = &*context;
block_on(async move {
send_reaction(ctx, MsgId::new(msg_id), &to_string_lossy(reaction))
.await
.map(|msg_id| msg_id.to_u32())
.unwrap_or_log_default(ctx, "Failed to send reaction")
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_get_msg_reactions(
context: *mut dc_context_t,
msg_id: u32,
) -> *mut dc_reactions_t {
if context.is_null() {
eprintln!("ignoring careless call to dc_get_msg_reactions()");
return ptr::null_mut();
}
let ctx = &*context;
let reactions = if let Ok(reactions) = block_on(get_msg_reactions(ctx, MsgId::new(msg_id)))
.context("failed dc_get_msg_reactions() call")
.log_err(ctx)
{
reactions
} else {
return ptr::null_mut();
};
Box::into_raw(Box::new(reactions))
}
#[no_mangle]
pub unsafe extern "C" fn dc_send_webxdc_status_update(
context: *mut dc_context_t,
@@ -4244,45 +4208,6 @@ pub unsafe extern "C" fn dc_lot_get_timestamp(lot: *mut dc_lot_t) -> i64 {
lot.get_timestamp()
}
#[no_mangle]
pub unsafe extern "C" fn dc_reactions_get_contacts(
reactions: *mut dc_reactions_t,
) -> *mut dc_array::dc_array_t {
if reactions.is_null() {
eprintln!("ignoring careless call to dc_reactions_get_contacts()");
return ptr::null_mut();
}
let reactions = &*reactions;
let array: dc_array_t = reactions.contacts().into();
Box::into_raw(Box::new(array))
}
#[no_mangle]
pub unsafe extern "C" fn dc_reactions_get_by_contact_id(
reactions: *mut dc_reactions_t,
contact_id: u32,
) -> *mut libc::c_char {
if reactions.is_null() {
eprintln!("ignoring careless call to dc_reactions_get_by_contact_id()");
return ptr::null_mut();
}
let reactions = &*reactions;
reactions.get(ContactId::new(contact_id)).as_str().strdup()
}
#[no_mangle]
pub unsafe extern "C" fn dc_reactions_unref(reactions: *mut dc_reactions_t) {
if reactions.is_null() {
eprintln!("ignoring careless call to dc_reactions_unref()");
return;
}
drop(Box::from_raw(reactions));
}
#[no_mangle]
pub unsafe extern "C" fn dc_str_unref(s: *mut libc::c_char) {
libc::free(s as *mut _)

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "1.137.1"
version = "1.137.3"
description = "DeltaChat JSON-RPC API"
edition = "2021"
default-run = "deltachat-jsonrpc-server"
@@ -28,11 +28,11 @@ typescript-type-def = { version = "0.5.8", features = ["json_value"] }
tokio = { version = "1.37.0" }
sanitize-filename = "0.5"
walkdir = "2.5.0"
base64 = "0.21"
base64 = "0.22"
# optional dependencies
axum = { version = "0.7", optional = true, features = ["ws"] }
env_logger = { version = "0.10.0", optional = true }
env_logger = { version = "0.11.3", optional = true }
[dev-dependencies]
tokio = { version = "1.37.0", features = ["full", "rt-multi-thread"] }

View File

@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use std::{collections::HashMap, str::FromStr};
use anyhow::{anyhow, bail, ensure, Context, Result};
@@ -62,14 +63,14 @@ use crate::api::types::qr::QrObject;
struct AccountState {
/// The Qr code for current [`CommandApi::provide_backup`] call.
///
/// If there currently is a call to [`CommandApi::provide_backup`] this will be
/// `Pending` or `Ready`, otherwise `NoProvider`.
backup_provider_qr: watch::Sender<ProviderQr>,
/// If there is currently is a call to [`CommandApi::provide_backup`] this will be
/// `Some`, otherwise `None`.
backup_provider_qr: watch::Sender<Option<Qr>>,
}
impl Default for AccountState {
fn default() -> Self {
let (tx, _rx) = watch::channel(ProviderQr::NoProvider);
let tx = watch::Sender::new(None);
Self {
backup_provider_qr: tx,
}
@@ -123,21 +124,13 @@ impl CommandApi {
.with_state(account_id, |state| state.backup_provider_qr.subscribe())
.await;
let val: ProviderQr = receiver.borrow_and_update().clone();
match val {
ProviderQr::NoProvider => bail!("No backup being provided"),
ProviderQr::Pending => loop {
if receiver.changed().await.is_err() {
bail!("No backup being provided (account state dropped)");
}
let val: ProviderQr = receiver.borrow().clone();
match val {
ProviderQr::NoProvider => bail!("No backup being provided"),
ProviderQr::Pending => continue,
ProviderQr::Ready(qr) => break Ok(qr),
};
},
ProviderQr::Ready(qr) => Ok(qr),
loop {
if let Some(qr) = receiver.borrow_and_update().clone() {
return Ok(qr);
}
if receiver.changed().await.is_err() {
bail!("No backup being provided (account state dropped)");
}
}
}
}
@@ -1569,20 +1562,21 @@ impl CommandApi {
/// Returns once a remote device has retrieved the backup, or is cancelled.
async fn provide_backup(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
self.with_state(account_id, |state| {
state.backup_provider_qr.send_replace(ProviderQr::Pending);
})
.await;
let provider = imex::BackupProvider::prepare(&ctx).await?;
self.with_state(account_id, |state| {
state
.backup_provider_qr
.send_replace(ProviderQr::Ready(provider.qr()));
state.backup_provider_qr.send_replace(Some(provider.qr()));
})
.await;
provider.await
let res = provider.await;
self.with_state(account_id, |state| {
state.backup_provider_qr.send_replace(None);
})
.await;
res
}
/// Returns the text of the QR code for the running [`CommandApi::provide_backup`].
@@ -1590,11 +1584,17 @@ impl CommandApi {
/// This QR code text can be used in [`CommandApi::get_backup`] on a second device to
/// retrieve the backup and setup this second device.
///
/// This call will fail if there is currently no concurrent call to
/// [`CommandApi::provide_backup`]. This call may block if the QR code is not yet
/// ready.
/// This call will block until the QR code is ready,
/// even if there is no concurrent call to [`CommandApi::provide_backup`],
/// but will fail after 10 seconds to avoid deadlocks.
async fn get_backup_qr(&self, account_id: u32) -> Result<String> {
let qr = self.inner_get_backup_qr(account_id).await?;
let qr = tokio::time::timeout(
Duration::from_secs(10),
self.inner_get_backup_qr(account_id),
)
.await
.context("Backup provider did not start in time")?
.context("Failed to get backup QR code")?;
qr::format_backup(&qr)
}
@@ -1603,14 +1603,20 @@ impl CommandApi {
/// This QR code can be used in [`CommandApi::get_backup`] on a second device to
/// retrieve the backup and setup this second device.
///
/// This call will fail if there is currently no concurrent call to
/// [`CommandApi::provide_backup`]. This call may block if the QR code is not yet
/// ready.
/// This call will block until the QR code is ready,
/// even if there is no concurrent call to [`CommandApi::provide_backup`],
/// but will fail after 10 seconds to avoid deadlocks.
///
/// Returns the QR code rendered as an SVG image.
async fn get_backup_qr_svg(&self, account_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
let qr = self.inner_get_backup_qr(account_id).await?;
let qr = tokio::time::timeout(
Duration::from_secs(10),
self.inner_get_backup_qr(account_id),
)
.await
.context("Backup provider did not start in time")?
.context("Failed to get backup QR code")?;
generate_backup_qr(&ctx, &qr).await
}
@@ -1620,6 +1626,9 @@ impl CommandApi {
/// the current device.
///
/// Can be cancelled by stopping the ongoing process.
///
/// Do not forget to call start_io on the account after a successful import,
/// otherwise it will not connect to the email server.
async fn get_backup(&self, account_id: u32, qr_text: String) -> Result<()> {
let ctx = self.get_context(account_id).await?;
let qr = qr::check_qr(&ctx, &qr_text).await?;
@@ -2141,15 +2150,3 @@ async fn get_config(
.await
}
}
/// Whether a QR code for a BackupProvider is currently available.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug)]
enum ProviderQr {
/// There is no provider, asking for a QR is an error.
NoProvider,
/// There is a provider, the QR code is pending.
Pending,
/// There is a provider and QR code.
Ready(Qr),
}

View File

@@ -250,6 +250,15 @@ pub enum EventType {
///
/// This event is only emitted by the account manager
AccountsBackgroundFetchDone,
/// Inform that set of chats or the order of the chats in the chatlist has changed.
///
/// Sometimes this is emitted together with `UIChatlistItemChanged`.
ChatlistChanged,
/// Inform that a single chat list item changed and needs to be rerendered.
/// If `chat_id` is set to None, then all currently visible chats need to be rerendered, and all not-visible items need to be cleared from cache if the UI has a cache.
#[serde(rename_all = "camelCase")]
ChatlistItemChanged { chat_id: Option<u32> },
}
impl From<CoreEventType> for EventType {
@@ -357,6 +366,10 @@ impl From<CoreEventType> for EventType {
msg_id: msg_id.to_u32(),
},
CoreEventType::AccountsBackgroundFetchDone => AccountsBackgroundFetchDone,
CoreEventType::ChatlistItemChanged { chat_id } => ChatlistItemChanged {
chat_id: chat_id.map(|id| id.to_u32()),
},
CoreEventType::ChatlistChanged => ChatlistChanged,
}
}
}

View File

@@ -53,5 +53,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "1.137.1"
"version": "1.137.3"
}

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "1.137.1"
version = "1.137.3"
license = "MPL-2.0"
edition = "2021"
repository = "https://github.com/deltachat/deltachat-core-rust"
@@ -13,8 +13,8 @@ dirs = "5"
log = "0.4.21"
pretty_env_logger = "0.5"
rusqlite = "0.31"
rustyline = "13"
tokio = { version = "1", features = ["fs", "rt-multi-thread", "macros"] }
rustyline = "14"
tokio = { version = "1.37.0", features = ["fs", "rt-multi-thread", "macros"] }
[features]
default = ["vendored"]

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "1.137.1"
version = "1.137.3"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -21,6 +21,9 @@ classifiers = [
"Topic :: Communications :: Email"
]
readme = "README.md"
dependencies = [
"imap-tools",
]
[tool.setuptools.package-data]
deltachat_rpc_client = [

View File

@@ -104,7 +104,11 @@ def _run_cli(
if not client.is_configured():
assert args.email, "Account is not configured and email must be provided"
assert args.password, "Account is not configured and password must be provided"
configure_thread = Thread(run=client.configure, kwargs={"email": args.email, "password": args.password})
configure_thread = Thread(
target=client.configure,
daemon=True,
kwargs={"email": args.email, "password": args.password},
)
configure_thread.start()
client.run_forever()

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
from typing import TYPE_CHECKING, Optional, Union
from warnings import warn
from ._utils import AttrDict, futuremethod
@@ -28,6 +30,10 @@ class Account:
"""Wait until the next event and return it."""
return AttrDict(self._rpc.wait_for_event(self.id))
def clear_all_events(self):
"""Removes all queued-up events for a given account. Useful for tests."""
self._rpc.clear_all_events(self.id)
def remove(self) -> None:
"""Remove the account."""
self._rpc.remove_account(self.id)
@@ -76,11 +82,25 @@ class Account:
"""Get self avatar."""
return self.get_config("selfavatar")
def check_qr(self, qr):
return self._rpc.check_qr(self.id, qr)
def set_config_from_qr(self, qr: str):
self._rpc.set_config_from_qr(self.id, qr)
@futuremethod
def configure(self):
"""Configure an account."""
yield self._rpc.configure.future(self.id)
def bring_online(self):
"""Start I/O and wait until IMAP becomes IDLE."""
self.start_io()
while True:
event = self.wait_for_event()
if event.kind == EventType.IMAP_INBOX_IDLE:
break
def create_contact(self, obj: Union[int, str, Contact], name: Optional[str] = None) -> Contact:
"""Create a new Contact or return an existing one.
@@ -98,6 +118,11 @@ class Account:
obj = obj.get_snapshot().address
return Contact(self, self._rpc.create_contact(self.id, obj, name))
def create_chat(self, account: "Account") -> Chat:
addr = account.get_config("addr")
contact = self.create_contact(addr)
return contact.create_chat()
def get_contact_by_id(self, contact_id: int) -> Contact:
"""Return Contact instance for the given contact ID."""
return Contact(self, contact_id)
@@ -107,7 +132,7 @@ class Account:
contact_id = self._rpc.lookup_contact_id_by_addr(self.id, address)
return contact_id and Contact(self, contact_id)
def get_blocked_contacts(self) -> List[AttrDict]:
def get_blocked_contacts(self) -> list[AttrDict]:
"""Return a list with snapshots of all blocked contacts."""
contacts = self._rpc.get_blocked_contacts(self.id)
return [AttrDict(contact=Contact(self, contact["id"]), **contact) for contact in contacts]
@@ -132,7 +157,7 @@ class Account:
with_self: bool = False,
verified_only: bool = False,
snapshot: bool = False,
) -> Union[List[Contact], List[AttrDict]]:
) -> Union[list[Contact], list[AttrDict]]:
"""Get a filtered list of contacts.
:param query: if a string is specified, only return contacts
@@ -167,7 +192,7 @@ class Account:
no_specials: bool = False,
alldone_hint: bool = False,
snapshot: bool = False,
) -> Union[List[Chat], List[AttrDict]]:
) -> Union[list[Chat], list[AttrDict]]:
"""Return list of chats.
:param query: if a string is specified only chats matching this query are returned.
@@ -225,7 +250,7 @@ class Account:
"""
return Chat(self, self._rpc.secure_join(self.id, qrdata))
def get_qr_code(self) -> Tuple[str, str]:
def get_qr_code(self) -> tuple[str, str]:
"""Get Setup-Contact QR Code text and SVG data.
this data needs to be transferred to another Delta Chat account
@@ -237,15 +262,15 @@ class Account:
"""Return the Message instance with the given ID."""
return Message(self, msg_id)
def mark_seen_messages(self, messages: List[Message]) -> None:
def mark_seen_messages(self, messages: list[Message]) -> None:
"""Mark the given set of messages as seen."""
self._rpc.markseen_msgs(self.id, [msg.id for msg in messages])
def delete_messages(self, messages: List[Message]) -> None:
def delete_messages(self, messages: list[Message]) -> None:
"""Delete messages (local and remote)."""
self._rpc.delete_messages(self.id, [msg.id for msg in messages])
def get_fresh_messages(self) -> List[Message]:
def get_fresh_messages(self) -> list[Message]:
"""Return the list of fresh messages, newest messages first.
This call is intended for displaying notifications.
@@ -255,12 +280,12 @@ class Account:
fresh_msg_ids = self._rpc.get_fresh_msgs(self.id)
return [Message(self, msg_id) for msg_id in fresh_msg_ids]
def get_next_messages(self) -> List[Message]:
def get_next_messages(self) -> list[Message]:
"""Return a list of next messages."""
next_msg_ids = self._rpc.get_next_msgs(self.id)
return [Message(self, msg_id) for msg_id in next_msg_ids]
def wait_next_messages(self) -> List[Message]:
def wait_next_messages(self) -> list[Message]:
"""Wait for new messages and return a list of them."""
next_msg_ids = self._rpc.wait_next_msgs(self.id)
return [Message(self, msg_id) for msg_id in next_msg_ids]
@@ -284,7 +309,13 @@ class Account:
if event["kind"] == "SecurejoinJoinerProgress" and event["progress"] == 1000:
break
def get_fresh_messages_in_arrival_order(self) -> List[Message]:
def wait_for_reactions_changed(self):
while True:
event = self.wait_for_event()
if event.kind == EventType.REACTIONS_CHANGED:
return event
def get_fresh_messages_in_arrival_order(self) -> list[Message]:
"""Return fresh messages list sorted in the order of their arrival, with ascending IDs."""
warn(
"get_fresh_messages_in_arrival_order is deprecated, use get_next_messages instead.",

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import calendar
from dataclasses import dataclass
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union
from typing import TYPE_CHECKING, Optional, Union
from ._utils import AttrDict
from .const import ChatVisibility, ViewType
@@ -93,7 +95,7 @@ class Chat:
"""Return encryption info for this chat."""
return self._rpc.get_chat_encryption_info(self.account.id, self.id)
def get_qr_code(self) -> Tuple[str, str]:
def get_qr_code(self) -> tuple[str, str]:
"""Get Join-Group QR code text and SVG data."""
return self._rpc.get_chat_securejoin_qr_code_svg(self.account.id, self.id)
@@ -117,7 +119,7 @@ class Chat:
html: Optional[str] = None,
viewtype: Optional[ViewType] = None,
file: Optional[str] = None,
location: Optional[Tuple[float, float]] = None,
location: Optional[tuple[float, float]] = None,
override_sender_name: Optional[str] = None,
quoted_msg: Optional[Union[int, Message]] = None,
) -> Message:
@@ -142,6 +144,10 @@ class Chat:
msg_id = self._rpc.misc_send_text_message(self.account.id, self.id, text)
return Message(self.account, msg_id)
def send_file(self, path):
"""Send a file and return the resulting Message instance."""
return self.send_message(file=path)
def send_videochat_invitation(self) -> Message:
"""Send a videochat invitation and return the resulting Message instance."""
msg_id = self._rpc.send_videochat_invitation(self.account.id, self.id)
@@ -152,7 +158,7 @@ class Chat:
msg_id = self._rpc.send_sticker(self.account.id, self.id, path)
return Message(self.account, msg_id)
def forward_messages(self, messages: List[Message]) -> None:
def forward_messages(self, messages: list[Message]) -> None:
"""Forward a list of messages to this chat."""
msg_ids = [msg.id for msg in messages]
self._rpc.forward_messages(self.account.id, msg_ids, self.id)
@@ -184,7 +190,7 @@ class Chat:
snapshot["message"] = Message(self.account, snapshot.id)
return snapshot
def get_messages(self, info_only: bool = False, add_daymarker: bool = False) -> List[Message]:
def get_messages(self, info_only: bool = False, add_daymarker: bool = False) -> list[Message]:
"""get the list of messages in this chat."""
msgs = self._rpc.get_message_ids(self.account.id, self.id, info_only, add_daymarker)
return [Message(self.account, msg_id) for msg_id in msgs]
@@ -219,7 +225,7 @@ class Chat:
contact_id = cnt
self._rpc.remove_contact_from_chat(self.account.id, self.id, contact_id)
def get_contacts(self) -> List[Contact]:
def get_contacts(self) -> list[Contact]:
"""Get the contacts belonging to this chat.
For single/direct chats self-address is not included.
@@ -243,7 +249,7 @@ class Chat:
contact: Optional[Contact] = None,
timestamp_from: Optional["datetime"] = None,
timestamp_to: Optional["datetime"] = None,
) -> List[AttrDict]:
) -> list[AttrDict]:
"""Get list of location snapshots for the given contact in the given timespan."""
time_from = calendar.timegm(timestamp_from.utctimetuple()) if timestamp_from else 0
time_to = calendar.timegm(timestamp_to.utctimetuple()) if timestamp_to else 0
@@ -251,7 +257,7 @@ class Chat:
result = self._rpc.get_locations(self.account.id, self.id, contact_id, time_from, time_to)
locations = []
contacts: Dict[int, Contact] = {}
contacts: dict[int, Contact] = {}
for loc in result:
location = AttrDict(loc)
location["chat"] = self

View File

@@ -1,14 +1,13 @@
"""Event loop implementations offering high level event handling/hooking."""
from __future__ import annotations
import logging
from typing import (
TYPE_CHECKING,
Callable,
Dict,
Iterable,
Optional,
Set,
Tuple,
Type,
Union,
)
@@ -39,16 +38,16 @@ class Client:
def __init__(
self,
account: "Account",
hooks: Optional[Iterable[Tuple[Callable, Union[type, EventFilter]]]] = None,
hooks: Optional[Iterable[tuple[Callable, Union[type, EventFilter]]]] = None,
logger: Optional[logging.Logger] = None,
) -> None:
self.account = account
self.logger = logger or logging
self._hooks: Dict[type, Set[tuple]] = {}
self._hooks: dict[type, set[tuple]] = {}
self._should_process_messages = 0
self.add_hooks(hooks or [])
def add_hooks(self, hooks: Iterable[Tuple[Callable, Union[type, EventFilter]]]) -> None:
def add_hooks(self, hooks: Iterable[tuple[Callable, Union[type, EventFilter]]]) -> None:
for hook, event in hooks:
self.add_hook(hook, event)

View File

@@ -59,6 +59,8 @@ class EventType(str, Enum):
SELFAVATAR_CHANGED = "SelfavatarChanged"
WEBXDC_STATUS_UPDATE = "WebxdcStatusUpdate"
WEBXDC_INSTANCE_DELETED = "WebxdcInstanceDeleted"
CHATLIST_CHANGED = "ChatlistChanged"
CHATLIST_ITEM_CHANGED = "ChatlistItemChanged"
class ChatId(IntEnum):

View File

@@ -1,4 +1,6 @@
from typing import TYPE_CHECKING, Dict, List
from __future__ import annotations
from typing import TYPE_CHECKING
from ._utils import AttrDict
from .account import Account
@@ -21,7 +23,7 @@ class DeltaChat:
account_id = self.rpc.add_account()
return Account(self, account_id)
def get_all_accounts(self) -> List[Account]:
def get_all_accounts(self) -> list[Account]:
"""Return a list of all available accounts."""
account_ids = self.rpc.get_all_account_ids()
return [Account(self, account_id) for account_id in account_ids]
@@ -44,6 +46,6 @@ class DeltaChat:
"""Get information about the Delta Chat core in this system."""
return AttrDict(self.rpc.get_system_info())
def set_translations(self, translations: Dict[str, str]) -> None:
def set_translations(self, translations: dict[str, str]) -> None:
"""Set stock translation strings."""
self.rpc.set_stock_strings(translations)

View File

@@ -0,0 +1,226 @@
"""
Internal Python-level IMAP handling used by the tests.
"""
from __future__ import annotations
import imaplib
import io
import pathlib
import ssl
from contextlib import contextmanager
from imap_tools import (
AND,
Header,
MailBox,
MailBoxTls,
MailMessage,
MailMessageFlags,
errors,
)
from . import Account, const
FLAGS = b"FLAGS"
FETCH = b"FETCH"
ALL = "1:*"
class DirectImap:
def __init__(self, account: Account) -> None:
self.account = account
self.logid = account.get_config("displayname") or id(account)
self._idling = False
self.connect()
def connect(self):
host = self.account.get_config("configured_mail_server")
port = int(self.account.get_config("configured_mail_port"))
security = int(self.account.get_config("configured_mail_security"))
user = self.account.get_config("addr")
pw = self.account.get_config("mail_pw")
if security == const.SocketSecurity.PLAIN:
ssl_context = None
else:
ssl_context = ssl.create_default_context()
# don't check if certificate hostname doesn't match target hostname
ssl_context.check_hostname = False
# don't check if the certificate is trusted by a certificate authority
ssl_context.verify_mode = ssl.CERT_NONE
if security == const.SocketSecurity.STARTTLS:
self.conn = MailBoxTls(host, port, ssl_context=ssl_context)
elif security == const.SocketSecurity.PLAIN or security == const.SocketSecurity.SSL:
self.conn = MailBox(host, port, ssl_context=ssl_context)
self.conn.login(user, pw)
self.select_folder("INBOX")
def shutdown(self):
try:
self.conn.logout()
except (OSError, imaplib.IMAP4.abort):
print("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))
def select_folder(self, foldername: str) -> tuple:
assert not self._idling
return self.conn.folder.set(foldername)
def select_config_folder(self, config_name: str):
"""Return info about selected folder if it is
configured, otherwise None.
"""
if "_" not in config_name:
config_name = f"configured_{config_name}_folder"
foldername = self.account.get_config(config_name)
if foldername:
return self.select_folder(foldername)
return None
def list_folders(self) -> list[str]:
"""return list of all existing folder names."""
assert not self._idling
return [folder.name for folder in self.conn.folder.list()]
def delete(self, uid_list: str, expunge=True):
"""delete a range of messages (imap-syntax).
If expunge is true, perform the expunge-operation
to make sure the messages are really gone and not
just flagged as deleted.
"""
self.conn.client.uid("STORE", uid_list, "+FLAGS", r"(\Deleted)")
if expunge:
self.conn.expunge()
def get_all_messages(self) -> list[MailMessage]:
assert not self._idling
return list(self.conn.fetch())
def get_unread_messages(self) -> list[str]:
assert not self._idling
return [msg.uid for msg in self.conn.fetch(AND(seen=False))]
def mark_all_read(self):
messages = self.get_unread_messages()
if messages:
res = self.conn.flag(messages, MailMessageFlags.SEEN, True)
print("marked seen:", messages, res)
def get_unread_cnt(self) -> int:
return len(self.get_unread_messages())
def dump_imap_structures(self, dir, logfile):
assert not self._idling
stream = io.StringIO()
def log(*args, **kwargs):
kwargs["file"] = stream
print(*args, **kwargs)
empty_folders = []
for imapfolder in self.list_folders():
self.select_folder(imapfolder)
messages = list(self.get_all_messages())
if not messages:
empty_folders.append(imapfolder)
continue
log("---------", imapfolder, len(messages), "messages ---------")
# get message content without auto-marking it as seen
# fetching 'RFC822' would mark it as seen.
for msg in self.conn.fetch(mark_seen=False):
body = getattr(msg.obj, "text", None)
if not body:
body = getattr(msg.obj, "html", None)
if not body:
log("Message", msg.uid, "has empty body")
continue
path = pathlib.Path(str(dir)).joinpath("IMAP", self.logid, imapfolder)
path.mkdir(parents=True, exist_ok=True)
fn = path.joinpath(str(msg.uid))
fn.write_bytes(body)
log("Message", msg.uid, fn)
log(
"Message",
msg.uid,
msg.flags,
"Message-Id:",
msg.obj.get("Message-Id"),
)
if empty_folders:
log("--------- EMPTY FOLDERS:", empty_folders)
print(stream.getvalue(), file=logfile)
@contextmanager
def idle(self):
"""return Idle ContextManager."""
idle_manager = IdleManager(self)
try:
yield idle_manager
finally:
idle_manager.done()
def append(self, folder: str, msg: str):
"""Upload a message to *folder*.
Trailing whitespace or a linebreak at the beginning will be removed automatically.
"""
if msg.startswith("\n"):
msg = msg[1:]
msg = "\n".join([s.lstrip() for s in msg.splitlines()])
self.conn.append(bytes(msg, encoding="ascii"), folder)
def get_uid_by_message_id(self, message_id) -> str:
msgs = [msg.uid for msg in self.conn.fetch(AND(header=Header("MESSAGE-ID", message_id)))]
if len(msgs) == 0:
raise Exception("Did not find message " + message_id + ", maybe you forgot to select the correct folder?")
return msgs[0]
class IdleManager:
def __init__(self, direct_imap) -> None:
self.direct_imap = direct_imap
self.log = direct_imap.account.log
# fetch latest messages before starting idle so that it only
# returns messages that arrive anew
self.direct_imap.conn.fetch("1:*")
self.direct_imap.conn.idle.start()
def check(self, timeout=None) -> list[bytes]:
"""(blocking) wait for next idle message from server."""
self.log("imap-direct: calling idle_check")
res = self.direct_imap.conn.idle.poll(timeout=timeout)
self.log(f"imap-direct: idle_check returned {res!r}")
return res
def wait_for_new_message(self, timeout=None) -> bytes:
while True:
for item in self.check(timeout=timeout):
if b"EXISTS" in item or b"RECENT" in item:
return item
def wait_for_seen(self, timeout=None) -> int:
"""Return first message with SEEN flag from a running idle-stream."""
while True:
for item in self.check(timeout=timeout):
if FETCH in item:
self.log(str(item))
if FLAGS in item and rb"\Seen" in item:
return int(item.split(b" ")[1])
def done(self):
"""send idle-done to server if we are currently in idle mode."""
return self.direct_imap.conn.idle.stop()

View File

@@ -1,8 +1,10 @@
"""High-level classes for event processing and filtering."""
from __future__ import annotations
import re
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Callable, Iterable, Iterator, Optional, Set, Tuple, Union
from typing import TYPE_CHECKING, Callable, Iterable, Iterator, Optional, Union
from .const import EventType
@@ -263,9 +265,9 @@ class HookCollection:
"""
def __init__(self) -> None:
self._hooks: Set[Tuple[Callable, Union[type, EventFilter]]] = set()
self._hooks: set[tuple[Callable, Union[type, EventFilter]]] = set()
def __iter__(self) -> Iterator[Tuple[Callable, Union[type, EventFilter]]]:
def __iter__(self) -> Iterator[tuple[Callable, Union[type, EventFilter]]]:
return iter(self._hooks)
def on(self, event: Union[type, EventFilter]) -> Callable: # noqa

View File

@@ -3,6 +3,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, Union
from ._utils import AttrDict
from .const import EventType
from .contact import Contact
if TYPE_CHECKING:
@@ -21,9 +22,10 @@ class Message:
def _rpc(self) -> "Rpc":
return self.account._rpc
def send_reaction(self, *reaction: str):
def send_reaction(self, *reaction: str) -> "Message":
"""Send a reaction to this message."""
self._rpc.send_reaction(self.account.id, self.id, reaction)
msg_id = self._rpc.send_reaction(self.account.id, self.id, reaction)
return Message(self.account, msg_id)
def get_snapshot(self) -> AttrDict:
"""Get a snapshot with the properties of this message."""
@@ -61,3 +63,10 @@ class Message:
def get_webxdc_info(self) -> dict:
return self._rpc.get_webxdc_info(self.account.id, self.id)
def wait_until_delivered(self) -> None:
"""Consume events until the message is delivered."""
while True:
event = self.account.wait_for_event()
if event.kind == EventType.MSG_DELIVERED and event.msg_id == self.id:
break

View File

@@ -1,10 +1,12 @@
from __future__ import annotations
import os
import random
from typing import AsyncGenerator, List, Optional
from typing import AsyncGenerator, Optional
import pytest
from . import Account, AttrDict, Bot, Client, DeltaChat, EventType, Message
from . import Account, AttrDict, Bot, Chat, Client, DeltaChat, EventType, Message
from ._utils import futuremethod
from .rpc import Rpc
@@ -54,14 +56,10 @@ class ACFactory:
@futuremethod
def get_online_account(self):
account = yield self.new_configured_account.future()
account.start_io()
while True:
event = account.wait_for_event()
if event.kind == EventType.IMAP_INBOX_IDLE:
break
account.bring_online()
return account
def get_online_accounts(self, num: int) -> List[Account]:
def get_online_accounts(self, num: int) -> list[Account]:
futures = [self.get_online_account.future() for _ in range(num)]
return [f() for f in futures]
@@ -75,6 +73,10 @@ class ACFactory:
ac_clone.configure()
return ac_clone
def get_accepted_chat(self, ac1: Account, ac2: Account) -> Chat:
ac2.create_chat(ac1)
return ac1.create_chat(ac2)
def send_message(
self,
to_account: Account,

View File

@@ -1,12 +1,14 @@
from __future__ import annotations
import itertools
import json
import logging
import os
import subprocess
import sys
from queue import Queue
from queue import Empty, Queue
from threading import Event, Thread
from typing import Any, Dict, Iterator, Optional
from typing import Any, Iterator, Optional
class JsonRpcError(Exception):
@@ -67,11 +69,11 @@ class Rpc:
self._kwargs = kwargs
self.process: subprocess.Popen
self.id_iterator: Iterator[int]
self.event_queues: Dict[int, Queue]
self.event_queues: dict[int, Queue]
# Map from request ID to `threading.Event`.
self.request_events: Dict[int, Event]
self.request_events: dict[int, Event]
# Map from request ID to the result.
self.request_results: Dict[int, Any]
self.request_results: dict[int, Any]
self.request_queue: Queue[Any]
self.closing: bool
self.reader_thread: Thread
@@ -186,5 +188,14 @@ class Rpc:
queue = self.get_queue(account_id)
return queue.get()
def clear_all_events(self, account_id: int):
"""Removes all queued-up events for a given account. Useful for tests."""
queue = self.get_queue(account_id)
try:
while True:
queue.get_nowait()
except Empty:
pass
def __getattr__(self, attr: str):
return RpcMethod(self, attr)

View File

@@ -0,0 +1,218 @@
from __future__ import annotations
import base64
import os
from typing import TYPE_CHECKING
from deltachat_rpc_client import Account, EventType, const
if TYPE_CHECKING:
from deltachat_rpc_client.pytestplugin import ACFactory
def wait_for_chatlist_and_specific_item(account, chat_id):
first_event = ""
while True:
event = account.wait_for_event()
if event.kind == EventType.CHATLIST_CHANGED:
first_event = "change"
break
if event.kind == EventType.CHATLIST_ITEM_CHANGED and event.chat_id == chat_id:
first_event = "item_change"
break
while True:
event = account.wait_for_event()
if event.kind == EventType.CHATLIST_CHANGED and first_event == "item_change":
break
if event.kind == EventType.CHATLIST_ITEM_CHANGED and event.chat_id == chat_id and first_event == "change":
break
def wait_for_chatlist_specific_item(account, chat_id):
while True:
event = account.wait_for_event()
if event.kind == EventType.CHATLIST_ITEM_CHANGED and event.chat_id == chat_id:
break
def wait_for_chatlist(account):
while True:
event = account.wait_for_event()
if event.kind == EventType.CHATLIST_CHANGED:
break
def test_delivery_status(acfactory: ACFactory) -> None:
"""
Test change status on chatlistitem when status changes (delivered, read)
"""
alice, bob = acfactory.get_online_accounts(2)
bob_addr = bob.get_config("addr")
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice.clear_all_events()
bob.stop_io()
alice.stop_io()
alice_chat_bob.send_text("hi")
wait_for_chatlist_and_specific_item(alice, chat_id=alice_chat_bob.id)
alice.clear_all_events()
alice.start_io()
wait_for_chatlist_specific_item(alice, chat_id=alice_chat_bob.id)
bob.clear_all_events()
bob.start_io()
event = bob.wait_for_incoming_msg_event()
msg = bob.get_message_by_id(event.msg_id)
msg.get_snapshot().chat.accept()
msg.mark_seen()
chat_item = alice._rpc.get_chatlist_items_by_entries(alice.id, [alice_chat_bob.id])[str(alice_chat_bob.id)]
assert chat_item["summaryStatus"] == const.MessageState.OUT_DELIVERED
alice.clear_all_events()
while True:
event = alice.wait_for_event()
if event.kind == EventType.MSG_READ:
break
wait_for_chatlist_specific_item(alice, chat_id=alice_chat_bob.id)
chat_item = alice._rpc.get_chatlist_items_by_entries(alice.id, [alice_chat_bob.id])[str(alice_chat_bob.id)]
assert chat_item["summaryStatus"] == const.MessageState.OUT_MDN_RCVD
def test_delivery_status_failed(acfactory: ACFactory) -> None:
"""
Test change status on chatlistitem when status changes failed
"""
(alice,) = acfactory.get_online_accounts(1)
invalid_contact = alice.create_contact("example@example.com", "invalid address")
invalid_chat = alice.get_chat_by_id(alice._rpc.create_chat_by_contact_id(alice.id, invalid_contact.id))
alice.clear_all_events()
failing_message = invalid_chat.send_text("test")
wait_for_chatlist_and_specific_item(alice, invalid_chat.id)
assert failing_message.get_snapshot().state == const.MessageState.OUT_PENDING
while True:
event = alice.wait_for_event()
if event.kind == EventType.MSG_FAILED:
break
wait_for_chatlist_specific_item(alice, invalid_chat.id)
assert failing_message.get_snapshot().state == const.MessageState.OUT_FAILED
def test_download_on_demand(acfactory: ACFactory) -> None:
"""
Test if download on demand emits chatlist update events.
This is only needed for last message in chat, but finding that out is too expensive, so it's always emitted
"""
alice, bob = acfactory.get_online_accounts(2)
bob_addr = bob.get_config("addr")
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_text("hi")
alice.set_config("download_limit", "1")
event = bob.wait_for_incoming_msg_event()
msg = bob.get_message_by_id(event.msg_id)
chat_id = msg.get_snapshot().chat_id
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"),
)
msg_id = alice.wait_for_incoming_msg_event().msg_id
assert alice.get_message_by_id(msg_id).get_snapshot().download_state == const.DownloadState.AVAILABLE
alice.clear_all_events()
chat_id = alice.get_message_by_id(msg_id).get_snapshot().chat_id
alice._rpc.download_full_message(alice.id, msg_id)
wait_for_chatlist_specific_item(alice, chat_id)
def get_multi_account_test_setup(acfactory: ACFactory) -> [Account, Account, Account]:
alice, bob = acfactory.get_online_accounts(2)
bob_addr = bob.get_config("addr")
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_text("hi")
bob.wait_for_incoming_msg_event()
alice_second_device: Account = acfactory.get_unconfigured_account()
alice._rpc.provide_backup.future(alice.id)
backup_code = alice._rpc.get_backup_qr(alice.id)
alice_second_device._rpc.get_backup(alice_second_device.id, backup_code)
alice_second_device.start_io()
alice.clear_all_events()
alice_second_device.clear_all_events()
bob.clear_all_events()
return [alice, alice_second_device, bob, alice_chat_bob]
def test_imap_sync_seen_msgs(acfactory: ACFactory) -> None:
"""
Test that chatlist changed events are emitted for the second device
when the message is marked as read on the first device
"""
alice, alice_second_device, bob, alice_chat_bob = get_multi_account_test_setup(acfactory)
alice_chat_bob.send_text("hello")
event = bob.wait_for_incoming_msg_event()
msg = bob.get_message_by_id(event.msg_id)
bob_chat_id = msg.get_snapshot().chat_id
msg.get_snapshot().chat.accept()
alice.clear_all_events()
alice_second_device.clear_all_events()
bob.get_chat_by_id(bob_chat_id).send_text("hello")
# make sure alice_second_device already received the message
alice_second_device.wait_for_incoming_msg_event()
event = alice.wait_for_incoming_msg_event()
msg = alice.get_message_by_id(event.msg_id)
alice_second_device.clear_all_events()
msg.mark_seen()
wait_for_chatlist_specific_item(bob, bob_chat_id)
wait_for_chatlist_specific_item(alice, alice_chat_bob.id)
def test_multidevice_sync_chat(acfactory: ACFactory) -> None:
"""
Test multidevice sync: syncing chat visibility and muting across multiple devices
"""
alice, alice_second_device, bob, alice_chat_bob = get_multi_account_test_setup(acfactory)
alice_chat_bob.archive()
wait_for_chatlist_specific_item(alice_second_device, alice_chat_bob.id)
assert alice_second_device.get_chat_by_id(alice_chat_bob.id).get_basic_snapshot().archived
alice_second_device.clear_all_events()
alice_chat_bob.pin()
wait_for_chatlist_specific_item(alice_second_device, alice_chat_bob.id)
alice_second_device.clear_all_events()
alice_chat_bob.mute()
wait_for_chatlist_specific_item(alice_second_device, alice_chat_bob.id)
assert alice_second_device.get_chat_by_id(alice_chat_bob.id).get_basic_snapshot().is_muted

View File

@@ -1,7 +1,7 @@
import logging
import pytest
from deltachat_rpc_client import Chat, SpecialContactId
from deltachat_rpc_client import Chat, EventType, SpecialContactId
def test_qr_setup_contact(acfactory, tmp_path) -> None:
@@ -579,3 +579,40 @@ def test_securejoin_after_contact_resetup(acfactory) -> None:
# ac1 is still "not verified" for ac2 due to inconsistent state.
assert not ac2_contact_ac1.get_snapshot().is_verified
def test_withdraw_securejoin_qr(acfactory):
alice, bob = acfactory.get_online_accounts(2)
logging.info("Alice creates a verified group")
alice_chat = alice.create_group("Verified group", protect=True)
assert alice_chat.get_basic_snapshot().is_protected
logging.info("Bob joins verified group")
qr_code, _svg = alice_chat.get_qr_code()
bob_chat = bob.secure_join(qr_code)
bob.wait_for_securejoin_joiner_success()
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
assert snapshot.text == "Member Me ({}) added by {}.".format(bob.get_config("addr"), alice.get_config("addr"))
assert snapshot.chat.get_basic_snapshot().is_protected
bob_chat.leave()
snapshot = alice.get_message_by_id(alice.wait_for_incoming_msg_event().msg_id).get_snapshot()
assert snapshot.text == "Group left by {}.".format(bob.get_config("addr"))
logging.info("Alice withdraws QR code.")
qr = alice.check_qr(qr_code)
assert qr["kind"] == "withdrawVerifyGroup"
alice.set_config_from_qr(qr_code)
logging.info("Bob scans withdrawn QR code.")
bob_chat = bob.secure_join(qr_code)
logging.info("Bob scanned withdrawn QR code")
while True:
event = alice.wait_for_event()
if event.kind == EventType.MSGS_CHANGED and event.chat_id != 0:
break
snapshot = alice.get_message_by_id(event.msg_id).get_snapshot()
assert snapshot.text == "Cannot establish guaranteed end-to-end encryption with {}".format(bob.get_config("addr"))

View File

@@ -1,10 +1,15 @@
import concurrent.futures
import json
import logging
import os
import subprocess
import time
from unittest.mock import MagicMock
import pytest
from deltachat_rpc_client import EventType, events
from deltachat_rpc_client import Contact, EventType, Message, events
from deltachat_rpc_client.const import DownloadState
from deltachat_rpc_client.direct_imap import DirectImap
from deltachat_rpc_client.rpc import JsonRpcError
@@ -439,3 +444,143 @@ def test_mdn_doesnt_break_autocrypt(acfactory) -> None:
message = alice.get_message_by_id(msg_id)
snapshot = message.get_snapshot()
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]
def test_reactions_for_a_reordering_move(acfactory):
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
messages they refer to and thus dropped.
"""
(ac1,) = acfactory.get_online_accounts(1)
ac2 = acfactory.new_preconfigured_account()
ac2.set_config("mvbox_move", "1")
ac2.configure()
ac2.bring_online()
chat1 = acfactory.get_accepted_chat(ac1, ac2)
ac2.stop_io()
logging.info("sending message + reaction from ac1 to ac2")
msg1 = chat1.send_text("hi")
msg1.wait_until_delivered()
# It's is sad, but messages must differ in their INTERNALDATEs to be processed in the correct
# order by DC, and most (if not all) mail servers provide only seconds precision.
time.sleep(1.1)
react_str = "\N{THUMBS UP SIGN}"
msg1.send_reaction(react_str).wait_until_delivered()
logging.info("moving messages to ac2's DeltaChat folder in the reverse order")
ac2_direct_imap = DirectImap(ac2)
ac2_direct_imap.connect()
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()], reverse=True):
ac2_direct_imap.conn.move(uid, "DeltaChat")
logging.info("receiving messages by ac2")
ac2.start_io()
msg2 = Message(ac2, ac2.wait_for_reactions_changed().msg_id)
assert msg2.get_snapshot().text == msg1.get_snapshot().text
reactions = msg2.get_reactions()
contacts = [Contact(ac2, int(i)) for i in reactions.reactions_by_contact]
assert len(contacts) == 1
assert contacts[0].get_snapshot().address == ac1.get_config("addr")
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
@pytest.mark.parametrize("n_accounts", [3, 2])
def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
download_limit = 300000
alice, *others = acfactory.get_online_accounts(n_accounts)
bob = others[0]
alice_group = alice.create_group("test group")
for account in others:
chat = account.create_chat(alice)
chat.send_text("Hello Alice!")
assert alice.get_message_by_id(alice.wait_for_incoming_msg_event().msg_id).get_snapshot().text == "Hello Alice!"
contact_addr = account.get_config("addr")
contact = alice.create_contact(contact_addr, "")
alice_group.add_contact(contact)
if n_accounts == 2:
bob_chat_alice = bob.create_chat(alice)
bob.set_config("download_limit", str(download_limit))
alice_group.send_text("hi")
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
assert snapshot.text == "hi"
bob_group = snapshot.chat
path = tmp_path / "large"
path.write_bytes(os.urandom(download_limit + 1))
for i in range(10):
logging.info("Sending message %s", i)
alice_group.send_file(str(path))
snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot()
assert snapshot.download_state == DownloadState.AVAILABLE
if n_accounts > 2:
assert snapshot.chat == bob_group
else:
# Group contains only Alice and Bob,
# so partially downloaded messages are
# hard to distinguish from private replies to group messages.
#
# Message may be a private reply, so we assign it to 1:1 chat with Alice.
assert snapshot.chat == bob_chat_alice

View File

@@ -25,7 +25,7 @@ deps =
black
commands =
black --quiet --check --diff src/ examples/ tests/
ruff src/ examples/ tests/
ruff check src/ examples/ tests/
[pytest]
timeout = 300

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "1.137.1"
version = "1.137.3"
description = "DeltaChat JSON-RPC server"
edition = "2021"
readme = "README.md"
@@ -14,7 +14,7 @@ deltachat-jsonrpc = { path = "../deltachat-jsonrpc", default-features = false }
deltachat = { path = "..", default-features = false }
anyhow = "1"
env_logger = { version = "0.10.0" }
env_logger = { version = "0.11.3" }
futures-lite = "2.3.0"
log = "0.4"
serde_json = "1"

View File

@@ -26,6 +26,7 @@ skip = [
{ name = "async-channel", version = "1.9.0" },
{ name = "base16ct", version = "0.1.1" },
{ name = "base64", version = "<0.21" },
{ name = "base64", version = "0.21.7" },
{ name = "bitflags", version = "1.3.2" },
{ name = "block-buffer", version = "<0.10" },
{ name = "convert_case", version = "0.4.0" },
@@ -37,6 +38,7 @@ skip = [
{ name = "digest", version = "<0.10" },
{ name = "ed25519-dalek", version = "1.0.1" },
{ name = "ed25519", version = "1.5.3" },
{ name = "env_logger", version = "0.10.2" },
{ name = "event-listener", version = "2.5.3" },
{ name = "getrandom", version = "<0.2" },
{ name = "idna", version = "0.4.0" },

View File

@@ -525,15 +525,12 @@
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
(fenixPkgs.complete.withComponents [
"cargo"
"clippy"
"rust-src"
"rustc"
"rustfmt"
])
cargo
clippy
rustc
rustfmt
rust-analyzer
cargo-deny
fenixPkgs.rust-analyzer
perl # needed to build vendored OpenSSL
];
};

201
fuzz/Cargo.lock generated
View File

@@ -339,6 +339,12 @@ version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a"
[[package]]
name = "base64"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51"
[[package]]
name = "base64ct"
version = "1.5.3"
@@ -515,9 +521,9 @@ dependencies = [
[[package]]
name = "brotli"
version = "3.4.0"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "516074a47ef4bce09577a3b379392300159ce5b1ba2e501ff1c819950066100f"
checksum = "125740193d7fee5cc63ab9e16c2fdc4e07c74ba755cc53b327d6ea029e9fc569"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -526,9 +532,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
version = "2.5.1"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f"
checksum = "65622a320492e09b5e0ac436b14c54ff68199bac392d0e89a6832c4518eea525"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -953,7 +959,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "1.136.0"
version = "1.137.2"
dependencies = [
"anyhow",
"async-channel 2.1.1",
@@ -989,6 +995,7 @@ dependencies = [
"num-traits",
"num_cpus",
"once_cell",
"openssl-src",
"parking_lot",
"percent-encoding",
"pgp",
@@ -1785,9 +1792,9 @@ checksum = "00f5fb52a06bdcadeb54e8d3671f8888a39697dcb0b81b23b55174030427f4eb"
[[package]]
name = "futures-lite"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "445ba825b27408685aaecefd65178908c36c6e96aaf6d8599419d46e624192ba"
checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5"
dependencies = [
"fastrand 2.0.1",
"futures-core",
@@ -1912,9 +1919,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.3.24"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9"
checksum = "816ec7294445779408f36fe57bc5b7fc1cf59664059096c65f905c1c61f58069"
dependencies = [
"bytes",
"fnv",
@@ -2051,9 +2058,9 @@ dependencies = [
[[package]]
name = "http"
version = "0.2.8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399"
checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
dependencies = [
"bytes",
"fnv",
@@ -2062,12 +2069,24 @@ dependencies = [
[[package]]
name = "http-body"
version = "0.4.5"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1"
checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
@@ -2077,12 +2096,6 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
[[package]]
name = "httpdate"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421"
[[package]]
name = "humansize"
version = "2.1.2"
@@ -2094,39 +2107,58 @@ dependencies = [
[[package]]
name = "hyper"
version = "0.14.23"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "034711faac9d2166cb1baf1a2fb0b60b1f277f8492fd72176c17f3515e1abd3c"
checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"socket2 0.4.7",
"smallvec",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"pin-project-lite",
"socket2 0.5.4",
"tokio",
"tower",
"tower-service",
"tracing",
]
[[package]]
@@ -2180,17 +2212,29 @@ dependencies = [
[[package]]
name = "image"
version = "0.24.9"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
checksum = "fd54d660e773627692c524beaad361aca785a4f9f5730ce91f42aabe5bce3d11"
dependencies = [
"bytemuck",
"byteorder",
"color_quant",
"gif",
"jpeg-decoder",
"image-webp",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a84a25dcae3ac487bc24ef280f9e20c79c9b1a3e5e32cbed3041d1c514aa87c"
dependencies = [
"byteorder",
"thiserror",
]
[[package]]
@@ -2314,12 +2358,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440"
[[package]]
name = "jpeg-decoder"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc0000e42512c92e31c2252315bda326620a4e034105e900c98ec492fa077b3e"
[[package]]
name = "js-sys"
version = "0.3.60"
@@ -3480,11 +3518,11 @@ checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
[[package]]
name = "reqwest"
version = "0.11.24"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251"
checksum = "3e6cc1e89e689536eb5aeede61520e874df5a4707df811cd5da4aa5fbb2aae19"
dependencies = [
"base64 0.21.0",
"base64 0.22.0",
"bytes",
"encoding_rs",
"futures-core",
@@ -3492,8 +3530,10 @@ dependencies = [
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-tls",
"hyper-util",
"ipnet",
"js-sys",
"log",
@@ -3502,7 +3542,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls-pemfile",
"rustls-pemfile 2.1.1",
"serde",
"serde_json",
"serde_urlencoded",
@@ -3515,7 +3555,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winreg 0.50.0",
"winreg 0.52.0",
]
[[package]]
@@ -3724,7 +3764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50"
dependencies = [
"openssl-probe",
"rustls-pemfile",
"rustls-pemfile 1.0.2",
"schannel",
"security-framework",
]
@@ -3738,6 +3778,22 @@ dependencies = [
"base64 0.21.0",
]
[[package]]
name = "rustls-pemfile"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f48172685e6ff52a556baa527774f61fcaa884f59daf3375c62a3f1cd2549dab"
dependencies = [
"base64 0.21.0",
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecd36cc4259e3e4514335c4a138c6b43171a8d61d8f5c9348f9fc7529416f247"
[[package]]
name = "rustls-webpki"
version = "0.101.7"
@@ -4055,9 +4111,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.10.0"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
[[package]]
name = "smawk"
@@ -4294,22 +4350,22 @@ dependencies = [
[[package]]
name = "thiserror"
version = "1.0.38"
version = "1.0.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0"
checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.38"
version = "1.0.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f"
checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.107",
"syn 2.0.52",
]
[[package]]
@@ -4442,9 +4498,9 @@ dependencies = [
[[package]]
name = "tokio-stream"
version = "0.1.14"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842"
checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af"
dependencies = [
"futures-core",
"pin-project-lite",
@@ -4514,6 +4570,28 @@ dependencies = [
"winnow",
]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"pin-project",
"pin-project-lite",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
[[package]]
name = "tower-service"
version = "0.3.2"
@@ -5169,9 +5247,9 @@ dependencies = [
[[package]]
name = "winreg"
version = "0.50.0"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
@@ -5265,3 +5343,18 @@ dependencies = [
"syn 1.0.107",
"synstructure",
]
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-jpeg"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec866b44a2a1fd6133d363f073ca1b179f438f99e7e5bfb1e33f7181facfe448"
dependencies = [
"zune-core",
]

View File

@@ -30,6 +30,8 @@ module.exports = {
DC_DOWNLOAD_IN_PROGRESS: 1000,
DC_DOWNLOAD_UNDECIPHERABLE: 30,
DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE: 2200,
DC_EVENT_CHATLIST_CHANGED: 2300,
DC_EVENT_CHATLIST_ITEM_CHANGED: 2301,
DC_EVENT_CHAT_EPHEMERAL_TIMER_MODIFIED: 2021,
DC_EVENT_CHAT_MODIFIED: 2020,
DC_EVENT_CONFIGURE_PROGRESS: 2041,
@@ -257,6 +259,7 @@ module.exports = {
DC_STR_PARTIAL_DOWNLOAD_MSG_BODY: 99,
DC_STR_PART_OF_TOTAL_USED: 116,
DC_STR_QUOTA_EXCEEDING_MSG_BODY: 98,
DC_STR_REACTED_BY: 177,
DC_STR_READRCPT: 31,
DC_STR_READRCPT_MAILBODY: 32,
DC_STR_REMOVE_MEMBER_BY_OTHER: 131,
@@ -284,6 +287,7 @@ module.exports = {
DC_STR_VIDEOCHAT_INVITE_MSG_BODY: 83,
DC_STR_VOICEMESSAGE: 7,
DC_STR_WELCOME_MESSAGE: 71,
DC_STR_YOU_REACTED: 176,
DC_TEXT1_DRAFT: 1,
DC_TEXT1_SELF: 3,
DC_TEXT1_USERNAME: 2,

View File

@@ -37,5 +37,7 @@ module.exports = {
2111: 'DC_EVENT_CONFIG_SYNCED',
2120: 'DC_EVENT_WEBXDC_STATUS_UPDATE',
2121: 'DC_EVENT_WEBXDC_INSTANCE_DELETED',
2200: 'DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE'
2200: 'DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE',
2300: 'DC_EVENT_CHATLIST_CHANGED',
2301: 'DC_EVENT_CHATLIST_ITEM_CHANGED'
}

View File

@@ -30,6 +30,8 @@ export enum C {
DC_DOWNLOAD_IN_PROGRESS = 1000,
DC_DOWNLOAD_UNDECIPHERABLE = 30,
DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE = 2200,
DC_EVENT_CHATLIST_CHANGED = 2300,
DC_EVENT_CHATLIST_ITEM_CHANGED = 2301,
DC_EVENT_CHAT_EPHEMERAL_TIMER_MODIFIED = 2021,
DC_EVENT_CHAT_MODIFIED = 2020,
DC_EVENT_CONFIGURE_PROGRESS = 2041,
@@ -257,6 +259,7 @@ export enum C {
DC_STR_PARTIAL_DOWNLOAD_MSG_BODY = 99,
DC_STR_PART_OF_TOTAL_USED = 116,
DC_STR_QUOTA_EXCEEDING_MSG_BODY = 98,
DC_STR_REACTED_BY = 177,
DC_STR_READRCPT = 31,
DC_STR_READRCPT_MAILBODY = 32,
DC_STR_REMOVE_MEMBER_BY_OTHER = 131,
@@ -284,6 +287,7 @@ export enum C {
DC_STR_VIDEOCHAT_INVITE_MSG_BODY = 83,
DC_STR_VOICEMESSAGE = 7,
DC_STR_WELCOME_MESSAGE = 71,
DC_STR_YOU_REACTED = 176,
DC_TEXT1_DRAFT = 1,
DC_TEXT1_SELF = 3,
DC_TEXT1_USERNAME = 2,
@@ -331,4 +335,6 @@ export const EventId2EventName: { [key: number]: string } = {
2120: 'DC_EVENT_WEBXDC_STATUS_UPDATE',
2121: 'DC_EVENT_WEBXDC_INSTANCE_DELETED',
2200: 'DC_EVENT_ACCOUNTS_BACKGROUND_FETCH_DONE',
2300: 'DC_EVENT_CHATLIST_CHANGED',
2301: 'DC_EVENT_CHATLIST_ITEM_CHANGED',
}

View File

@@ -26,6 +26,8 @@ function createTempUser(chatmailDomain) {
}
describe('static tests', function () {
this.timeout(60 * 5 * 1000) // increase timeout to 5 min
it('reverse lookup of events', function () {
const eventKeys = Object.keys(EventId2EventName).map((k) => Number(k))
const eventValues = Object.values(EventId2EventName)
@@ -701,7 +703,7 @@ describe('Offline Tests with unconfigured account', function () {
})
describe('Integration tests', function () {
this.timeout(60 * 3000) // increase timeout to 1min
this.timeout(60 * 5 * 1000) // increase timeout to 5 min
let [dc, context, accountId, directory, account] = [
null,

View File

@@ -55,5 +55,5 @@
"test:mocha": "mocha node/test/test.mjs --growl --reporter=spec --bail --exit"
},
"types": "node/dist/index.d.ts",
"version": "1.137.1"
"version": "1.137.3"
}

View File

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

View File

@@ -1,7 +1,6 @@
import os
import queue
import sys
import time
import base64
from datetime import datetime, timezone
@@ -1493,107 +1492,6 @@ def test_send_and_receive_image(acfactory, lp, data):
assert m == msg_in
def test_reaction_to_partially_fetched_msg(acfactory, lp, 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()
reactions_queue = queue.Queue()
class InPlugin:
@account_hookimpl
def ac_reactions_changed(self, message):
reactions_queue.put(message)
ac2.add_account_plugin(InPlugin())
lp.sec("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:
ac1._evtracker.wait_msg_delivered(m)
lp.sec("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))
ac1._evtracker.wait_msg_delivered(msgs[-1])
ac2.start_io()
lp.sec("wait for ac2 to receive a reaction")
msg2 = ac2._evtracker.wait_next_reactions_changed()
assert msg2.get_sender_contact().addr == ac1_addr
assert msg2.download_state == dc.const.DC_DOWNLOAD_AVAILABLE
assert reactions_queue.get() == msg2
reactions = msg2.get_reactions()
contacts = reactions.get_contacts()
assert len(contacts) == 1
assert contacts[0].addr == ac1_addr
assert reactions.get_by_contact(contacts[0]) == react_str
def test_reactions_for_a_reordering_move(acfactory, lp):
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
messages they refer to and thus dropped.
"""
(ac1,) = acfactory.get_online_accounts(1)
ac2 = acfactory.new_online_configuring_account(mvbox_move=True)
acfactory.bring_accounts_online()
chat1 = acfactory.get_accepted_chat(ac1, ac2)
ac2.stop_io()
lp.sec("sending message + reaction from ac1 to ac2")
msg1 = chat1.send_text("hi")
ac1._evtracker.wait_msg_delivered(msg1)
# It's is sad, but messages must differ in their INTERNALDATEs to be processed in the correct
# order by DC, and most (if not all) mail servers provide only seconds precision.
time.sleep(1.1)
react_str = "\N{THUMBS UP SIGN}"
ac1._evtracker.wait_msg_delivered(msg1.send_reaction(react_str))
lp.sec("moving messages to ac2's DeltaChat folder in the reverse order")
ac2.direct_imap.connect()
for uid in sorted([m.uid for m in ac2.direct_imap.get_all_messages()], reverse=True):
ac2.direct_imap.conn.move(uid, "DeltaChat")
lp.sec("receiving messages by ac2")
ac2.start_io()
msg2 = ac2._evtracker.wait_next_reactions_changed()
assert msg2.text == msg1.text
reactions = msg2.get_reactions()
contacts = reactions.get_contacts()
assert len(contacts) == 1
assert contacts[0].addr == ac1.get_config("addr")
assert reactions.get_by_contact(contacts[0]) == react_str
def test_import_export_online_all(acfactory, tmp_path, data, lp):
(ac1, some1) = acfactory.get_online_accounts(2)
@@ -2230,17 +2128,21 @@ def test_delete_multiple_messages(acfactory, lp):
def test_trash_multiple_messages(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.stop_io()
# Create the Trash folder on IMAP server
# and recreate the account so Trash folder is configured.
# Create the Trash folder on IMAP server and configure deletion to it. There was a bug that if
# Trash wasn't configured initially, it can't be configured later, let's check this.
lp.sec("Creating trash folder")
ac2.direct_imap.create_folder("Trash")
lp.sec("Creating new accounts")
ac2 = acfactory.new_online_configuring_account(cloned_from=ac2)
acfactory.bring_accounts_online()
ac2.set_config("delete_to_trash", "1")
assert ac2.get_config("configured_trash_folder")
lp.sec("Check that Trash can be configured initially as well")
ac3 = acfactory.new_online_configuring_account(cloned_from=ac2)
acfactory.bring_accounts_online()
assert ac3.get_config("configured_trash_folder")
ac3.stop_io()
ac2.start_io()
chat12 = acfactory.get_accepted_chat(ac1, ac2)
lp.sec("ac1: sending 3 messages")
@@ -2255,6 +2157,9 @@ def test_trash_multiple_messages(acfactory, lp):
assert msg.text in texts
if text != "second":
to_delete.append(msg)
# ac2 has received some messages, this is impossible w/o the trash folder configured, let's
# check the configuration.
assert ac2.get_config("configured_trash_folder") == "Trash"
lp.sec("ac2: deleting all messages except second")
assert len(to_delete) == len(texts) - 1

View File

@@ -47,7 +47,7 @@ deps =
restructuredtext_lint
commands =
black --quiet --check --diff setup.py src/deltachat examples/ tests/
ruff src/deltachat tests/ examples/
ruff check src/deltachat tests/ examples/
rst-lint --encoding 'utf-8' README.rst
[testenv:mypy]
@@ -62,7 +62,8 @@ commands =
[testenv:doc]
changedir=doc
deps =
sphinx
# Pinned version, workaround for <https://github.com/breathe-doc/breathe/issues/981>
sphinx<7.3
breathe
sphinx_rtd_theme
commands =

View File

@@ -1 +1 @@
2024-04-03
2024-04-16

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

View File

@@ -6,6 +6,7 @@ use std::collections::BTreeSet;
use std::fmt;
use anyhow::Result;
use deltachat_contact_tools::EmailAddress;
use mailparse::MailHeaderMap;
use mailparse::ParsedMail;
use once_cell::sync::Lazy;
@@ -14,7 +15,6 @@ use crate::config::Config;
use crate::context::Context;
use crate::headerdef::HeaderDef;
use crate::tools::time;
use crate::tools::EmailAddress;
/// `authres` is short for the Authentication-Results header, defined in
/// <https://datatracker.ietf.org/doc/html/rfc8601>, which contains info

View File

@@ -11,9 +11,8 @@ use std::path::{Path, PathBuf};
use anyhow::{format_err, Context as _, Result};
use base64::Engine as _;
use futures::StreamExt;
use image::{
DynamicImage, GenericImage, GenericImageView, ImageFormat, ImageOutputFormat, Pixel, Rgba,
};
use image::codecs::jpeg::JpegEncoder;
use image::{DynamicImage, GenericImage, GenericImageView, ImageFormat, Pixel, Rgba};
use num_traits::FromPrimitive;
use tokio::io::AsyncWriteExt;
use tokio::{fs, io};
@@ -37,6 +36,12 @@ pub struct BlobObject<'a> {
name: String,
}
#[derive(Debug, Clone)]
enum ImageOutputFormat {
Png,
Jpeg { quality: u8 },
}
impl<'a> BlobObject<'a> {
/// Creates a new blob object with a unique name.
///
@@ -457,9 +462,13 @@ impl<'a> BlobObject<'a> {
Ok(ImageFormat::Png) if !exceeds_max_bytes => ImageOutputFormat::Png,
Ok(ImageFormat::Jpeg) => {
add_white_bg = false;
ImageOutputFormat::Jpeg(jpeg_quality)
ImageOutputFormat::Jpeg {
quality: jpeg_quality,
}
}
_ => ImageOutputFormat::Jpeg(jpeg_quality),
_ => ImageOutputFormat::Jpeg {
quality: jpeg_quality,
},
};
// We need to rewrite images with Exif to remove metadata such as location,
// camera model, etc.
@@ -530,7 +539,7 @@ impl<'a> BlobObject<'a> {
if do_scale || exif.is_some() {
// The file format is JPEG/PNG now, we may have to change the file extension
if !matches!(fmt, Ok(ImageFormat::Jpeg))
&& matches!(ofmt, ImageOutputFormat::Jpeg(_))
&& matches!(ofmt, ImageOutputFormat::Jpeg { .. })
{
blob_abs = blob_abs.with_extension("jpg");
let file_name = blob_abs.file_name().context("No image file name (???)")?;
@@ -685,7 +694,13 @@ fn encode_img(
) -> anyhow::Result<()> {
encoded.clear();
let mut buf = Cursor::new(encoded);
img.write_to(&mut buf, fmt)?;
match fmt {
ImageOutputFormat::Png => img.write_to(&mut buf, ImageFormat::Png)?,
ImageOutputFormat::Jpeg { quality } => {
let encoder = JpegEncoder::new_with_quality(&mut buf, quality);
img.write_with_encoder(encoder)?;
}
}
Ok(())
}
@@ -725,7 +740,6 @@ fn add_white_bg(img: &mut DynamicImage) {
#[cfg(test)]
mod tests {
use fs::File;
use image::Pixel;
use super::*;
use crate::chat::{self, create_group_chat, ProtectionStatus};

View File

@@ -8,6 +8,7 @@ use std::str::FromStr;
use std::time::Duration;
use anyhow::{anyhow, bail, ensure, Context as _, Result};
use deltachat_contact_tools::{strip_rtlo_characters, ContactAddress};
use deltachat_derive::{FromSql, ToSql};
use serde::{Deserialize, Serialize};
use strum_macros::EnumIter;
@@ -15,13 +16,14 @@ use strum_macros::EnumIter;
use crate::aheader::EncryptPreference;
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::{
self, Blocked, Chattype, DC_CHAT_ID_ALLDONE_HINT, DC_CHAT_ID_ARCHIVED_LINK,
DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH, DC_RESEND_USER_AVATAR_DAYS,
};
use crate::contact::{self, Contact, ContactAddress, ContactId, Origin};
use crate::contact::{self, Contact, ContactId, Origin};
use crate::context::Context;
use crate::debug_logging::maybe_set_logging_xdc;
use crate::download::DownloadState;
@@ -43,7 +45,7 @@ use crate::sync::{self, Sync::*, SyncData};
use crate::tools::{
buf_compress, create_id, create_outgoing_rfc724_mid, create_smeared_timestamp,
create_smeared_timestamps, get_abs_path, gm2local_offset, improve_single_line_input,
smeared_time, strip_rtlo_characters, time, IsNoneOrEmpty, SystemTime,
smeared_time, time, IsNoneOrEmpty, SystemTime,
};
use crate::webxdc::WEBXDC_SUFFIX;
@@ -209,24 +211,11 @@ impl ChatId {
}
/// Returns [`ChatId`] of a chat that `msg` belongs to.
///
/// Checks that `msg` is assigned to the right chat.
pub(crate) fn lookup_by_message(msg: &Message) -> Option<Self> {
if msg.chat_id == DC_CHAT_ID_TRASH {
return None;
}
if msg.download_state != DownloadState::Done
// TODO (2023-09-12): Added for backward compatibility with versions that did not have
// `DownloadState::Undecipherable`. Remove eventually with the comment in
// `MimeMessage::from_bytes()`.
|| msg
.error
.as_ref()
.filter(|e| e.starts_with("Decrypting failed:"))
.is_some()
{
// If `msg` is not fully downloaded or undecipherable, it may have been assigned to the
// wrong chat (they often get assigned to the 1:1 chat with the sender).
if msg.download_state == DownloadState::Undecipherable {
return None;
}
Some(msg.chat_id)
@@ -308,6 +297,8 @@ impl ChatId {
}
};
context.emit_msgs_changed_without_ids();
chatlist_events::emit_chatlist_changed(context);
chatlist_events::emit_chatlist_item_changed(context, chat_id);
Ok(chat_id)
}
@@ -424,6 +415,7 @@ impl ChatId {
}
}
}
chatlist_events::emit_chatlist_changed(context);
if sync.into() {
// NB: For a 1:1 chat this currently triggers `Contact::block()` on other devices.
@@ -446,6 +438,8 @@ impl ChatId {
pub(crate) async fn unblock_ex(self, context: &Context, sync: sync::Sync) -> Result<()> {
self.set_blocked(context, Blocked::Not).await?;
chatlist_events::emit_chatlist_changed(context);
if sync.into() {
let chat = Chat::load_from_db(context, self).await?;
// TODO: For a 1:1 chat this currently triggers `Contact::unblock()` on other devices.
@@ -456,6 +450,7 @@ impl ChatId {
.log_err(context)
.ok();
}
Ok(())
}
@@ -499,6 +494,7 @@ impl ChatId {
if self.set_blocked(context, Blocked::Not).await? {
context.emit_event(EventType::ChatModified(self));
chatlist_events::emit_chatlist_item_changed(context, self);
}
if sync.into() {
@@ -541,6 +537,7 @@ impl ChatId {
.await?;
context.emit_event(EventType::ChatModified(self));
chatlist_events::emit_chatlist_item_changed(context, self);
// make sure, the receivers will get all keys
self.reset_gossiped_timestamp(context).await?;
@@ -589,6 +586,7 @@ impl ChatId {
if protection_status_modified {
self.add_protection_msg(context, protect, contact_id, timestamp_sort)
.await?;
chatlist_events::emit_chatlist_item_changed(context, self);
}
Ok(())
}
@@ -675,6 +673,8 @@ impl ChatId {
.await?;
context.emit_msgs_changed_without_ids();
chatlist_events::emit_chatlist_changed(context);
chatlist_events::emit_chatlist_item_changed(context, self);
if sync.into() {
let chat = Chat::load_from_db(context, self).await?;
@@ -781,6 +781,7 @@ impl ChatId {
.await?;
context.emit_msgs_changed_without_ids();
chatlist_events::emit_chatlist_changed(context);
context
.set_config_internal(Config::LastHousekeeping, None)
@@ -792,6 +793,7 @@ impl ChatId {
msg.text = stock_str::self_deleted_msg_body(context).await;
add_device_msg(context, None, Some(&mut msg)).await?;
}
chatlist_events::emit_chatlist_changed(context);
Ok(())
}
@@ -1172,6 +1174,15 @@ impl ChatId {
Ok(self.get_param(context).await?.exists(Param::Devicetalk))
}
/// Returns chat member list timestamp.
pub(crate) async fn get_member_list_timestamp(self, context: &Context) -> Result<i64> {
Ok(self
.get_param(context)
.await?
.get_i64(Param::MemberListTimestamp)
.unwrap_or_default())
}
async fn parent_query<T, F>(
self,
context: &Context,
@@ -1430,7 +1441,7 @@ impl rusqlite::types::ToSql for ChatId {
impl rusqlite::types::FromSql for ChatId {
fn column_result(value: rusqlite::types::ValueRef) -> rusqlite::types::FromSqlResult<Self> {
i64::column_result(value).and_then(|val| {
if 0 <= val && val <= i64::from(std::u32::MAX) {
if 0 <= val && val <= i64::from(u32::MAX) {
Ok(ChatId::new(val as u32))
} else {
Err(rusqlite::types::FromSqlError::OutOfRange(val))
@@ -1518,7 +1529,7 @@ impl Chat {
Ok(contacts) => {
if let Some(contact_id) = contacts.first() {
if let Ok(contact) = Contact::get_by_id(context, *contact_id).await {
chat_name = contact.get_display_name().to_owned();
contact.get_display_name().clone_into(&mut chat_name);
}
}
}
@@ -2698,7 +2709,9 @@ async fn send_msg_inner(context: &Context, chat_id: ChatId, msg: &mut Message) -
}
if !prepare_send_msg(context, chat_id, msg).await?.is_empty() {
context.emit_msgs_changed(msg.chat_id, msg.id);
if !msg.hidden {
context.emit_msgs_changed(msg.chat_id, msg.id);
}
if msg.param.exists(Param::SetLatitude) {
context.emit_event(EventType::LocationChanged(Some(ContactId::SELF)));
@@ -2802,15 +2815,21 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
);
}
let now = time();
let now = smeared_time(context);
if rendered_msg.is_gossiped {
msg.chat_id.set_gossiped_timestamp(context, now).await?;
}
if rendered_msg.is_group {
if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup {
// Reject member list synchronisation from older messages. See also
// `receive_imf::apply_group_changes()`.
msg.chat_id
.update_timestamp(context, Param::MemberListTimestamp, now)
.update_timestamp(
context,
Param::MemberListTimestamp,
now.saturating_add(constants::TIMESTAMP_SENT_TOLERANCE),
)
.await?;
}
@@ -2844,7 +2863,7 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
msg.update_param(context).await?;
}
msg.subject = rendered_msg.subject.clone();
msg.subject.clone_from(&rendered_msg.subject);
msg.update_subject(context).await?;
let chunk_size = context
.get_configured_provider()
@@ -3099,7 +3118,9 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()>
.await?;
for chat_id_in_archive in chat_ids_in_archive {
context.emit_event(EventType::MsgsNoticed(chat_id_in_archive));
chatlist_events::emit_chatlist_item_changed(context, chat_id_in_archive);
}
chatlist_events::emit_chatlist_item_changed(context, DC_CHAT_ID_ARCHIVED_LINK);
} else {
let exists = context
.sql
@@ -3126,6 +3147,7 @@ pub async fn marknoticed_chat(context: &Context, chat_id: ChatId) -> Result<()>
}
context.emit_event(EventType::MsgsNoticed(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);
Ok(())
}
@@ -3193,6 +3215,7 @@ pub(crate) async fn mark_old_messages_as_noticed(
for c in changed_chats {
context.emit_event(EventType::MsgsNoticed(c));
chatlist_events::emit_chatlist_item_changed(context, c);
}
Ok(())
@@ -3355,6 +3378,8 @@ pub async fn create_group_chat(
}
context.emit_msgs_changed_without_ids();
chatlist_events::emit_chatlist_changed(context);
chatlist_events::emit_chatlist_item_changed(context, chat_id);
if protect == ProtectionStatus::Protected {
chat_id
@@ -3442,11 +3467,14 @@ pub(crate) async fn create_broadcast_list_ex(
let chat_id = ChatId::new(u32::try_from(row_id)?);
context.emit_msgs_changed_without_ids();
chatlist_events::emit_chatlist_changed(context);
if sync.into() {
let id = SyncId::Grpid(grpid);
let action = SyncAction::CreateBroadcast(chat_name);
self::sync(context, id, action).await.log_err(context).ok();
}
Ok(chat_id)
}
@@ -3717,6 +3745,7 @@ pub(crate) async fn set_muted_ex(
.await
.context(format!("Failed to set mute duration for {chat_id}"))?;
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);
if sync.into() {
let chat = Chat::load_from_db(context, chat_id).await?;
chat.sync(context, SyncAction::SetMuted(duration))
@@ -3877,6 +3906,7 @@ async fn rename_ex(
sync = Nosync;
}
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);
success = true;
}
}
@@ -3937,6 +3967,7 @@ pub async fn set_chat_profile_image(
context.emit_msgs_changed(chat_id, msg.id);
}
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);
Ok(())
}
@@ -4083,6 +4114,8 @@ pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
msg_id: msg.id,
});
msg.timestamp_sort = create_smeared_timestamp(context);
// note(treefit): only matters if it is the last message in chat (but probably to expensive to check, debounce also solves it)
chatlist_events::emit_chatlist_item_changed(context, msg.chat_id);
if !create_send_msg_jobs(context, &mut msg).await?.is_empty() {
context.scheduler.interrupt_smtp().await;
}
@@ -4775,9 +4808,9 @@ mod tests {
Ok(())
}
/// Test simultaneous removal of user from the chat and leaving the group.
/// Test parallel removal of user from the chat and leaving the group.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_simultaneous_member_remove() -> Result<()> {
async fn test_parallel_member_remove() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
@@ -4808,20 +4841,25 @@ mod tests {
add_contact_to_chat(&alice, alice_chat_id, alice_claire_contact_id).await?;
let alice_sent_add_msg = alice.pop_sent_msg().await;
// Alice removes Bob from the chat.
remove_contact_from_chat(&alice, alice_chat_id, alice_bob_contact_id).await?;
let alice_sent_remove_msg = alice.pop_sent_msg().await;
// Bob leaves the chat.
remove_contact_from_chat(&bob, bob_chat_id, ContactId::SELF).await?;
// Bob receives a msg about Alice adding Claire to the group.
bob.recv_msg(&alice_sent_add_msg).await;
SystemTime::shift(Duration::from_secs(3600));
// This adds Bob because they left quite long ago.
let alice_sent_msg = alice.send_text(alice_chat_id, "What a silence!").await;
bob.recv_msg(&alice_sent_msg).await;
// Test that add message is rewritten.
bob.golden_test_chat(bob_chat_id, "chat_test_simultaneous_member_remove")
bob.golden_test_chat(bob_chat_id, "chat_test_parallel_member_remove")
.await;
// Alice removes Bob from the chat.
remove_contact_from_chat(&alice, alice_chat_id, alice_bob_contact_id).await?;
let alice_sent_remove_msg = alice.pop_sent_msg().await;
// Bob receives a msg about Alice removing him from the group.
let bob_received_remove_msg = bob.recv_msg(&alice_sent_remove_msg).await;
@@ -4858,8 +4896,13 @@ mod tests {
bob.recv_msg(&sent_msg).await;
remove_contact_from_chat(&bob, bob_chat_id, bob_fiona_contact_id).await?;
// This doesn't add Fiona back because Bob just removed them.
let sent_msg = alice.send_text(alice_chat_id, "Welcome, Fiona!").await;
bob.recv_msg(&sent_msg).await;
SystemTime::shift(Duration::from_secs(3600));
let sent_msg = alice.send_text(alice_chat_id, "Welcome back, Fiona!").await;
bob.recv_msg(&sent_msg).await;
bob.golden_test_chat(bob_chat_id, "chat_test_msg_with_implicit_member_add")
.await;
Ok(())

View File

@@ -416,7 +416,7 @@ impl Chatlist {
if chat.id.is_archived_link() {
Ok(Default::default())
} else if let Some(lastmsg) = lastmsg.filter(|msg| msg.from_id != ContactId::UNDEFINED) {
Ok(Summary::new(context, &lastmsg, chat, lastcontact.as_ref()).await)
Summary::new_with_reaction_details(context, &lastmsg, chat, lastcontact.as_ref()).await
} else {
Ok(Summary {
text: stock_str::no_messages(context).await,

View File

@@ -6,6 +6,7 @@ use std::str::FromStr;
use anyhow::{ensure, Context as _, Result};
use base64::Engine as _;
use deltachat_contact_tools::addr_cmp;
use serde::{Deserialize, Serialize};
use strum::{EnumProperty, IntoEnumIterator};
use strum_macros::{AsRefStr, Display, EnumIter, EnumProperty, EnumString};
@@ -13,7 +14,6 @@ use tokio::fs;
use crate::blob::BlobObject;
use crate::constants::{self, DC_VERSION_STR};
use crate::contact::addr_cmp;
use crate::context::Context;
use crate::events::EventType;
use crate::log::LogExt;

View File

@@ -16,6 +16,7 @@ mod server_params;
use anyhow::{bail, ensure, Context as _, Result};
use auto_mozilla::moz_autoconfigure;
use auto_outlook::outlk_autodiscover;
use deltachat_contact_tools::EmailAddress;
use futures::FutureExt;
use futures_lite::FutureExt as _;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
@@ -23,7 +24,6 @@ use server_params::{expand_param_vector, ServerParams};
use tokio::task;
use crate::config::{self, Config};
use crate::contact::addr_cmp;
use crate::context::Context;
use crate::imap::{session::Session as ImapSession, Imap};
use crate::log::LogExt;
@@ -35,8 +35,9 @@ use crate::smtp::Smtp;
use crate::socks::Socks5Config;
use crate::stock_str;
use crate::sync::Sync::*;
use crate::tools::{time, EmailAddress};
use crate::tools::time;
use crate::{chat, e2ee, provider};
use deltachat_contact_tools::addr_cmp;
macro_rules! progress {
($context:tt, $progress:expr, $comment:expr) => {
@@ -356,8 +357,8 @@ async fn configure(ctx: &Context, param: &mut LoginParam) -> Result<()> {
let mut smtp_configured = false;
let mut errors = Vec::new();
for smtp_server in smtp_servers {
smtp_param.user = smtp_server.username.clone();
smtp_param.server = smtp_server.hostname.clone();
smtp_param.user.clone_from(&smtp_server.username);
smtp_param.server.clone_from(&smtp_server.hostname);
smtp_param.port = smtp_server.port;
smtp_param.security = smtp_server.socket;
smtp_param.certificate_checks = match smtp_server.strict_tls {
@@ -403,8 +404,8 @@ async fn configure(ctx: &Context, param: &mut LoginParam) -> Result<()> {
let imap_servers_count = imap_servers.len();
let mut errors = Vec::new();
for (imap_server_index, imap_server) in imap_servers.into_iter().enumerate() {
param.imap.user = imap_server.username.clone();
param.imap.server = imap_server.hostname.clone();
param.imap.user.clone_from(&imap_server.username);
param.imap.server.clone_from(&imap_server.hostname);
param.imap.port = imap_server.port;
param.imap.security = imap_server.socket;
param.imap.certificate_checks = match imap_server.strict_tls {

View File

@@ -219,6 +219,10 @@ pub(crate) const DEFAULT_MAX_SMTP_RCPT_TO: usize = 50;
/// How far the last quota check needs to be in the past to be checked by the background function (in seconds).
pub(crate) const DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT: u64 = 12 * 60 * 60; // 12 hours
/// How far in the future the sender timestamp of a message is allowed to be, in seconds. Also used
/// in the group membership consistency algo to reject outdated membership changes.
pub(crate) const TIMESTAMP_SENT_TOLERANCE: i64 = 60;
#[cfg(test)]
mod tests {
use num_traits::FromPrimitive;

View File

@@ -3,15 +3,17 @@
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::fmt;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use anyhow::{bail, ensure, Context as _, Result};
use async_channel::{self as channel, Receiver, Sender};
pub use deltachat_contact_tools::may_be_valid_addr;
use deltachat_contact_tools::{
addr_cmp, addr_normalize, normalize_name, sanitize_name_and_addr, strip_rtlo_characters,
ContactAddress,
};
use deltachat_derive::{FromSql, ToSql};
use once_cell::sync::Lazy;
use regex::Regex;
use rusqlite::OptionalExtension;
use serde::{Deserialize, Serialize};
use tokio::task;
@@ -33,60 +35,12 @@ use crate::param::{Param, Params};
use crate::peerstate::Peerstate;
use crate::sql::{self, params_iter};
use crate::sync::{self, Sync::*};
use crate::tools::{
duration_to_str, get_abs_path, improve_single_line_input, strip_rtlo_characters, time,
EmailAddress, SystemTime,
};
use crate::{chat, stock_str};
use crate::tools::{duration_to_str, get_abs_path, improve_single_line_input, time, SystemTime};
use crate::{chat, chatlist_events, stock_str};
/// Time during which a contact is considered as seen recently.
const SEEN_RECENTLY_SECONDS: i64 = 600;
/// Valid contact address.
#[derive(Debug, Clone)]
pub(crate) struct ContactAddress(String);
impl Deref for ContactAddress {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl AsRef<str> for ContactAddress {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for ContactAddress {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl ContactAddress {
/// Constructs a new contact address from string,
/// normalizing and validating it.
pub fn new(s: &str) -> Result<Self> {
let addr = addr_normalize(s);
if !may_be_valid_addr(&addr) {
bail!("invalid address {:?}", s);
}
Ok(Self(addr.to_string()))
}
}
/// Allow converting [`ContactAddress`] to an SQLite type.
impl rusqlite::types::ToSql for ContactAddress {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput> {
let val = rusqlite::types::Value::Text(self.0.to_string());
let out = rusqlite::types::ToSqlOutput::Owned(val);
Ok(out)
}
}
/// Contact ID, including reserved IDs.
///
/// Some contact IDs are reserved to identify special contacts. This
@@ -760,6 +714,7 @@ impl Contact {
if count > 0 {
// Chat name updated
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_items_changed_for_contact(context, contact_id);
}
}
}
@@ -796,7 +751,9 @@ impl Contact {
Ok(row_id)
}).await?;
Ok((ContactId::new(row_id), sth_modified))
let contact_id = ContactId::new(row_id);
Ok((contact_id, sth_modified))
}
/// Add a number of contacts.
@@ -1051,55 +1008,52 @@ impl Contact {
"Can not provide encryption info for special contact"
);
let mut ret = String::new();
if let Ok(contact) = Contact::get_by_id(context, contact_id).await {
let loginparam = LoginParam::load_configured_params(context).await?;
let peerstate = Peerstate::from_addr(context, &contact.addr).await?;
let contact = Contact::get_by_id(context, contact_id).await?;
let loginparam = LoginParam::load_configured_params(context).await?;
let peerstate = Peerstate::from_addr(context, &contact.addr).await?;
if let Some(peerstate) =
peerstate.filter(|peerstate| peerstate.peek_key(false).is_some())
{
let stock_message = match peerstate.prefer_encrypt {
EncryptPreference::Mutual => stock_str::e2e_preferred(context).await,
EncryptPreference::NoPreference => stock_str::e2e_available(context).await,
EncryptPreference::Reset => stock_str::encr_none(context).await,
};
let Some(peerstate) = peerstate.filter(|peerstate| peerstate.peek_key(false).is_some())
else {
return Ok(stock_str::encr_none(context).await);
};
let finger_prints = stock_str::finger_prints(context).await;
ret += &format!("{stock_message}.\n{finger_prints}:");
let stock_message = match peerstate.prefer_encrypt {
EncryptPreference::Mutual => stock_str::e2e_preferred(context).await,
EncryptPreference::NoPreference => stock_str::e2e_available(context).await,
EncryptPreference::Reset => stock_str::encr_none(context).await,
};
let fingerprint_self = load_self_public_key(context)
.await?
.fingerprint()
.to_string();
let fingerprint_other_verified = peerstate
.peek_key(true)
.map(|k| k.fingerprint().to_string())
.unwrap_or_default();
let fingerprint_other_unverified = peerstate
.peek_key(false)
.map(|k| k.fingerprint().to_string())
.unwrap_or_default();
if loginparam.addr < peerstate.addr {
cat_fingerprint(&mut ret, &loginparam.addr, &fingerprint_self, "");
cat_fingerprint(
&mut ret,
&peerstate.addr,
&fingerprint_other_verified,
&fingerprint_other_unverified,
);
} else {
cat_fingerprint(
&mut ret,
&peerstate.addr,
&fingerprint_other_verified,
&fingerprint_other_unverified,
);
cat_fingerprint(&mut ret, &loginparam.addr, &fingerprint_self, "");
}
} else {
ret += &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 fingerprint_self = load_self_public_key(context)
.await?
.fingerprint()
.to_string();
let fingerprint_other_verified = peerstate
.peek_key(true)
.map(|k| k.fingerprint().to_string())
.unwrap_or_default();
let fingerprint_other_unverified = peerstate
.peek_key(false)
.map(|k| k.fingerprint().to_string())
.unwrap_or_default();
if loginparam.addr < peerstate.addr {
cat_fingerprint(&mut ret, &loginparam.addr, &fingerprint_self, "");
cat_fingerprint(
&mut ret,
&peerstate.addr,
&fingerprint_other_verified,
&fingerprint_other_unverified,
);
} else {
cat_fingerprint(
&mut ret,
&peerstate.addr,
&fingerprint_other_verified,
&fingerprint_other_unverified,
);
cat_fingerprint(&mut ret, &loginparam.addr, &fingerprint_self, "");
}
Ok(ret)
@@ -1415,46 +1369,6 @@ impl Contact {
}
}
/// Returns false if addr is an invalid address, otherwise true.
pub fn may_be_valid_addr(addr: &str) -> bool {
let res = EmailAddress::new(addr);
res.is_ok()
}
/// Returns address lowercased,
/// with whitespace trimmed and `mailto:` prefix removed.
pub fn addr_normalize(addr: &str) -> String {
let norm = addr.trim().to_lowercase();
if norm.starts_with("mailto:") {
norm.get(7..).unwrap_or(&norm).to_string()
} else {
norm
}
}
fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) {
static ADDR_WITH_NAME_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new("(.*)<(.*)>").unwrap());
if let Some(captures) = ADDR_WITH_NAME_REGEX.captures(addr.as_ref()) {
(
if name.is_empty() {
strip_rtlo_characters(
&captures
.get(1)
.map_or("".to_string(), |m| normalize_name(m.as_str())),
)
} else {
strip_rtlo_characters(name)
},
captures
.get(2)
.map_or("".to_string(), |m| m.as_str().to_string()),
)
} else {
(strip_rtlo_characters(name), addr.to_string())
}
}
pub(crate) async fn set_blocked(
context: &Context,
sync: sync::Sync,
@@ -1527,6 +1441,7 @@ WHERE type=? AND id IN (
}
}
chatlist_events::emit_chatlist_changed(context);
Ok(())
}
@@ -1577,6 +1492,7 @@ pub(crate) async fn set_profile_image(
if changed {
contact.update_param(context).await?;
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
chatlist_events::emit_chatlist_item_changed_for_contact_chat(context, contact_id).await;
}
Ok(())
}
@@ -1632,6 +1548,7 @@ pub(crate) async fn update_last_seen(
> 0
&& timestamp > time() - SEEN_RECENTLY_SECONDS
{
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
context
.scheduler
.interrupt_recently_seen(contact_id, timestamp)
@@ -1640,26 +1557,6 @@ pub(crate) async fn update_last_seen(
Ok(())
}
/// Normalize a name.
///
/// - Remove quotes (come from some bad MUA implementations)
/// - Trims the resulting string
///
/// Typically, this function is not needed as it is called implicitly by `Contact::add_address_book`.
pub fn normalize_name(full_name: &str) -> String {
let full_name = full_name.trim();
if full_name.is_empty() {
return full_name.into();
}
match full_name.as_bytes() {
[b'\'', .., b'\''] | [b'\"', .., b'\"'] | [b'<', .., b'>'] => full_name
.get(1..full_name.len() - 1)
.map_or("".to_string(), |s| s.trim().to_string()),
_ => full_name.to_string(),
}
}
fn cat_fingerprint(
ret: &mut String,
addr: &str,
@@ -1683,14 +1580,6 @@ fn cat_fingerprint(
}
}
/// Compares two email addresses, normalizing them beforehand.
pub fn addr_cmp(addr1: &str, addr2: &str) -> bool {
let norm1 = addr_normalize(addr1);
let norm2 = addr_normalize(addr2);
norm1 == norm2
}
fn split_address_book(book: &str) -> Vec<(&str, &str)> {
book.lines()
.collect::<Vec<&str>>()
@@ -1762,6 +1651,7 @@ impl RecentlySeenLoop {
.unwrap_or_default();
loop {
let now = SystemTime::now();
let (until, contact_id) =
if let Some((Reverse(timestamp), contact_id)) = unseen_queue.peek() {
(
@@ -1787,6 +1677,11 @@ impl RecentlySeenLoop {
// Timeout, notify about contact.
if let Some(contact_id) = contact_id {
context.emit_event(EventType::ContactsChanged(Some(*contact_id)));
chatlist_events::emit_chatlist_item_changed_for_contact_chat(
&context,
*contact_id,
)
.await;
unseen_queue.pop();
}
}
@@ -1804,7 +1699,10 @@ impl RecentlySeenLoop {
timestamp,
})) => {
// Received an interrupt.
unseen_queue.push((Reverse(timestamp + SEEN_RECENTLY_SECONDS), contact_id));
if contact_id != ContactId::UNDEFINED {
unseen_queue
.push((Reverse(timestamp + SEEN_RECENTLY_SECONDS), contact_id));
}
}
}
} else {
@@ -1816,13 +1714,18 @@ impl RecentlySeenLoop {
// Event is already in the past.
if let Some(contact_id) = contact_id {
context.emit_event(EventType::ContactsChanged(Some(*contact_id)));
chatlist_events::emit_chatlist_item_changed_for_contact_chat(
&context,
*contact_id,
)
.await;
}
unseen_queue.pop();
}
}
}
pub(crate) fn interrupt(&self, contact_id: ContactId, timestamp: i64) {
pub(crate) fn try_interrupt(&self, contact_id: ContactId, timestamp: i64) {
self.interrupt_send
.try_send(RecentlySeenInterrupt {
contact_id,
@@ -1831,6 +1734,17 @@ impl RecentlySeenLoop {
.ok();
}
#[cfg(test)]
pub(crate) async fn interrupt(&self, contact_id: ContactId, timestamp: i64) {
self.interrupt_send
.send(RecentlySeenInterrupt {
contact_id,
timestamp,
})
.await
.unwrap();
}
pub(crate) fn abort(self) {
self.handle.abort();
}
@@ -1838,6 +1752,8 @@ impl RecentlySeenLoop {
#[cfg(test)]
mod tests {
use deltachat_contact_tools::may_be_valid_addr;
use super::*;
use crate::chat::{get_chat_contacts, send_text_msg, Chat};
use crate::chatlist::Chatlist;
@@ -1977,18 +1893,6 @@ mod tests {
Ok(())
}
#[test]
fn test_contact_address() -> Result<()> {
let alice_addr = "alice@example.org";
let contact_address = ContactAddress::new(alice_addr)?;
assert_eq!(contact_address.as_ref(), alice_addr);
let invalid_addr = "<> foobar";
assert!(ContactAddress::new(invalid_addr).is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_add_or_lookup() {
// add some contacts, this also tests add_address_book()
@@ -2812,6 +2716,44 @@ Hi."#;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_was_seen_recently_event() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let recently_seen_loop = RecentlySeenLoop::new(bob.ctx.clone());
let chat = bob.create_chat(&alice).await;
let contacts = chat::get_chat_contacts(&bob, chat.id).await?;
for _ in 0..2 {
let chat = alice.create_chat(&bob).await;
let sent_msg = alice.send_text(chat.id, "moin").await;
let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?;
assert!(!contact.was_seen_recently());
while bob.evtracker.try_recv().is_ok() {}
bob.recv_msg(&sent_msg).await;
let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?;
assert!(contact.was_seen_recently());
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::ContactsChanged { .. }))
.await;
recently_seen_loop
.interrupt(contact.id, contact.last_seen)
.await;
// Wait for `was_seen_recently()` to turn off.
while bob.evtracker.try_recv().is_ok() {}
SystemTime::shift(Duration::from_secs(SEEN_RECENTLY_SECONDS as u64 * 2));
recently_seen_loop.interrupt(ContactId::UNDEFINED, 0).await;
let contact = Contact::get_by_id(&bob, *contacts.first().unwrap()).await?;
assert!(!contact.was_seen_recently());
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::ContactsChanged { .. }))
.await;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_verified_by_none() -> Result<()> {
let mut tcm = TestContextManager::new();

View File

@@ -16,6 +16,7 @@ use tokio::sync::{Mutex, Notify, RwLock};
use crate::aheader::EncryptPreference;
use crate::chat::{get_chat_cnt, ChatId, ProtectionStatus};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::{
self, DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT, DC_CHAT_ID_TRASH, DC_VERSION_STR,
@@ -593,11 +594,15 @@ impl Context {
/// Emits a MsgsChanged event with specified chat and message ids
pub fn emit_msgs_changed(&self, chat_id: ChatId, msg_id: MsgId) {
self.emit_event(EventType::MsgsChanged { chat_id, msg_id });
chatlist_events::emit_chatlist_changed(self);
chatlist_events::emit_chatlist_item_changed(self, chat_id);
}
/// Emits an IncomingMsg event with specified chat and message ids
pub fn emit_incoming_msg(&self, chat_id: ChatId, msg_id: MsgId) {
self.emit_event(EventType::IncomingMsg { chat_id, msg_id });
chatlist_events::emit_chatlist_changed(self);
chatlist_events::emit_chatlist_item_changed(self, chat_id);
}
/// Returns a receiver for emitted events.

View File

@@ -64,6 +64,7 @@ pub async fn debug_logging_loop(context: &Context, events: Receiver<DebugEventLo
document: None,
uid: None,
},
time,
)
.await
{

View File

@@ -4,12 +4,12 @@ use std::collections::HashSet;
use std::str::FromStr;
use anyhow::Result;
use deltachat_contact_tools::addr_cmp;
use mailparse::ParsedMail;
use crate::aheader::Aheader;
use crate::authres::handle_authres;
use crate::authres::{self, DkimResults};
use crate::contact::addr_cmp;
use crate::context::Context;
use crate::headerdef::{HeaderDef, HeaderDefMap};
use crate::key::{DcKey, Fingerprint, SignedPublicKey, SignedSecretKey};

View File

@@ -13,7 +13,7 @@ use crate::imap::session::Session;
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, Part};
use crate::tools::time;
use crate::{stock_str, EventType};
use crate::{chatlist_events, stock_str, EventType};
/// Download limits should not be used below `MIN_DOWNLOAD_LIMIT`.
///
@@ -115,6 +115,7 @@ impl MsgId {
chat_id: msg.chat_id,
msg_id: self,
});
chatlist_events::emit_chatlist_item_changed(context, msg.chat_id);
Ok(())
}
}
@@ -448,10 +449,9 @@ mod tests {
)
.await?;
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), 0);
assert!(Message::load_from_db(&bob, msg.id)
assert!(Message::load_from_db_optional(&bob, msg.id)
.await?
.chat_id
.is_trash());
.is_none());
Ok(())
}
@@ -507,10 +507,9 @@ mod tests {
// (usually mdn are too small for not being downloaded directly)
receive_imf_from_inbox(&bob, "bar@example.org", raw, false, None, false).await?;
assert_eq!(get_chat_msgs(&bob, chat_id).await?.len(), 0);
assert!(Message::load_from_db(&bob, msg.id)
assert!(Message::load_from_db_optional(&bob, msg.id)
.await?
.chat_id
.is_trash());
.is_none());
Ok(())
}

View File

@@ -392,7 +392,8 @@ WHERE
SELECT id, chat_id, type
FROM msgs
WHERE
timestamp < ?
timestamp < ?1
AND timestamp_rcvd < ?1
AND chat_id > ?
AND chat_id != ?
AND chat_id != ?
@@ -490,7 +491,7 @@ async fn next_delete_device_after_timestamp(context: &Context) -> Result<Option<
.sql
.query_get_value(
r#"
SELECT min(timestamp)
SELECT min(max(timestamp, timestamp_rcvd))
FROM msgs
WHERE chat_id > ?
AND chat_id != ?
@@ -1097,8 +1098,8 @@ mod tests {
})
.await;
let loaded = Message::load_from_db(t, msg_id).await?;
assert_eq!(loaded.chat_id, DC_CHAT_ID_TRASH);
let loaded = Message::load_from_db_optional(t, msg_id).await?;
assert!(loaded.is_none());
// Check that the msg was deleted locally.
check_msg_is_deleted(t, chat, msg_id).await;

View File

@@ -3,6 +3,7 @@
use async_channel::{self as channel, Receiver, Sender, TrySendError};
use pin_project::pin_project;
pub(crate) mod chatlist_events;
mod payload;
pub use self::payload::EventType;

View File

@@ -0,0 +1,643 @@
use crate::{chat::ChatId, contact::ContactId, context::Context, EventType};
/// order or content of chatlist changes (chat ids, not the actual chatlist item)
pub(crate) fn emit_chatlist_changed(context: &Context) {
context.emit_event(EventType::ChatlistChanged);
}
/// Chatlist item of a specific chat changed
pub(crate) fn emit_chatlist_item_changed(context: &Context, chat_id: ChatId) {
context.emit_event(EventType::ChatlistItemChanged {
chat_id: Some(chat_id),
});
}
/// Used when you don't know which chatlist items changed, this reloads all cached chatlist items in the UI
///
/// Avoid calling this when you can find out the affected chat ids easialy (without extra expensive db queries).
///
/// This method is not public, so you have to define and document your new case here in this file.
fn emit_unknown_chatlist_items_changed(context: &Context) {
context.emit_event(EventType::ChatlistItemChanged { chat_id: None });
}
/// update event for the 1:1 chat with the contact
/// used when recently seen changes and when profile image changes
pub(crate) async fn emit_chatlist_item_changed_for_contact_chat(
context: &Context,
contact_id: ContactId,
) {
match ChatId::lookup_by_contact(context, contact_id).await {
Ok(Some(chat_id)) => self::emit_chatlist_item_changed(context, chat_id),
Ok(None) => {}
Err(error) => context.emit_event(EventType::Error(format!(
"failed to find chat id for contact for chatlist event: {error:?}"
))),
}
}
/// update items for chats that have the contact
/// used when contact changes their name or did AEAP for example
///
/// The most common case is that the contact changed their name
/// and their name should be updated in the chatlistitems for the chats
/// where they sent the last message as there their name is shown in the summary on those
pub(crate) fn emit_chatlist_items_changed_for_contact(context: &Context, _contact_id: ContactId) {
// note:(treefit): it is too expensive to find the right chats
// so we'll just tell ui to reload every loaded item
emit_unknown_chatlist_items_changed(context)
// note:(treefit): in the future we could instead emit an extra event for this and also store contact id in the chatlistitems
// (contact id for dm chats and contact id of contact that wrote the message in the summary)
// the ui could then look for this info in the cache and only reload the needed chats.
}
/// Tests for chatlist events
///
/// Only checks if the events are emitted,
/// does not check for excess/too-many events
#[cfg(test)]
mod test_chatlist_events {
use std::{
sync::atomic::{AtomicBool, Ordering},
time::Duration,
};
use crate::{
chat::{
self, create_broadcast_list, create_group_chat, set_muted, ChatId, ChatVisibility,
MuteDuration, ProtectionStatus,
},
config::Config,
constants::*,
contact::Contact,
message::{self, Message, MessageState},
reaction,
receive_imf::receive_imf,
securejoin::{get_securejoin_qr, join_securejoin},
test_utils::{TestContext, TestContextManager},
EventType,
};
use crate::tools::SystemTime;
use anyhow::Result;
async fn wait_for_chatlist_and_specific_item(context: &TestContext, chat_id: ChatId) {
let first_event_is_item = AtomicBool::new(false);
context
.evtracker
.get_matching(|evt| match evt {
EventType::ChatlistItemChanged {
chat_id: Some(ev_chat_id),
} => {
if ev_chat_id == &chat_id {
first_event_is_item.store(true, Ordering::Relaxed);
true
} else {
false
}
}
EventType::ChatlistChanged => true,
_ => false,
})
.await;
if first_event_is_item.load(Ordering::Relaxed) {
wait_for_chatlist(context).await;
} else {
wait_for_chatlist_specific_item(context, chat_id).await;
}
}
async fn wait_for_chatlist_specific_item(context: &TestContext, chat_id: ChatId) {
context
.evtracker
.get_matching(|evt| match evt {
EventType::ChatlistItemChanged {
chat_id: Some(ev_chat_id),
} => ev_chat_id == &chat_id,
_ => false,
})
.await;
}
async fn wait_for_chatlist_all_items(context: &TestContext) {
context
.evtracker
.get_matching(|evt| matches!(evt, EventType::ChatlistItemChanged { chat_id: None }))
.await;
}
async fn wait_for_chatlist(context: &TestContext) {
context
.evtracker
.get_matching(|evt| matches!(evt, EventType::ChatlistChanged))
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_change_chat_visibility() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat_id = create_group_chat(
&alice,
crate::chat::ProtectionStatus::Unprotected,
"my_group",
)
.await?;
chat_id
.set_visibility(&alice, ChatVisibility::Pinned)
.await?;
wait_for_chatlist_and_specific_item(&alice, chat_id).await;
chat_id
.set_visibility(&alice, ChatVisibility::Archived)
.await?;
wait_for_chatlist_and_specific_item(&alice, chat_id).await;
chat_id
.set_visibility(&alice, ChatVisibility::Normal)
.await?;
wait_for_chatlist_and_specific_item(&alice, chat_id).await;
Ok(())
}
/// mute a chat, archive it, then use another account to send a message to it, the counter on the archived chatlist item should change
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_archived_counter_increases_for_muted_chats() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice.create_chat(&bob).await;
let sent_msg = alice.send_text(chat.id, "moin").await;
bob.recv_msg(&sent_msg).await;
let bob_chat = bob.create_chat(&alice).await;
bob_chat
.id
.set_visibility(&bob, ChatVisibility::Archived)
.await?;
set_muted(&bob, bob_chat.id, MuteDuration::Forever).await?;
bob.evtracker.clear_events().await;
let sent_msg = alice.send_text(chat.id, "moin2").await;
bob.recv_msg(&sent_msg).await;
bob.evtracker
.get_matching(|evt| match evt {
EventType::ChatlistItemChanged {
chat_id: Some(chat_id),
} => chat_id.is_archived_link(),
_ => false,
})
.await;
Ok(())
}
/// Mark noticed on archive-link chatlistitem should update the unread counter on it
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_archived_counter_update_on_mark_noticed() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice.create_chat(&bob).await;
let sent_msg = alice.send_text(chat.id, "moin").await;
bob.recv_msg(&sent_msg).await;
let bob_chat = bob.create_chat(&alice).await;
bob_chat
.id
.set_visibility(&bob, ChatVisibility::Archived)
.await?;
set_muted(&bob, bob_chat.id, MuteDuration::Forever).await?;
let sent_msg = alice.send_text(chat.id, "moin2").await;
bob.recv_msg(&sent_msg).await;
bob.evtracker.clear_events().await;
chat::marknoticed_chat(&bob, DC_CHAT_ID_ARCHIVED_LINK).await?;
wait_for_chatlist_specific_item(&bob, DC_CHAT_ID_ARCHIVED_LINK).await;
Ok(())
}
/// Contact name update - expect all chats to update
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_contact_name_update() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let alice_to_bob_chat = alice.create_chat(&bob).await;
let sent_msg = alice.send_text(alice_to_bob_chat.id, "hello").await;
bob.recv_msg(&sent_msg).await;
bob.evtracker.clear_events().await;
// set alice name then receive messagefrom her with bob
alice.set_config(Config::Displayname, Some("Alice")).await?;
let sent_msg = alice
.send_text(alice_to_bob_chat.id, "hello, I set a displayname")
.await;
bob.recv_msg(&sent_msg).await;
let alice_on_bob = bob.add_or_lookup_contact(&alice).await;
assert!(alice_on_bob.get_display_name() == "Alice");
wait_for_chatlist_all_items(&bob).await;
bob.evtracker.clear_events().await;
// set name
let addr = alice_on_bob.get_addr();
Contact::create(&bob, "Alice2", addr).await?;
assert!(bob.add_or_lookup_contact(&alice).await.get_display_name() == "Alice2");
wait_for_chatlist_all_items(&bob).await;
Ok(())
}
/// Contact changed avatar
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_contact_changed_avatar() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let alice_to_bob_chat = alice.create_chat(&bob).await;
let sent_msg = alice.send_text(alice_to_bob_chat.id, "hello").await;
bob.recv_msg(&sent_msg).await;
bob.evtracker.clear_events().await;
// set alice avatar then receive messagefrom her with bob
let file = alice.dir.path().join("avatar.png");
let bytes = include_bytes!("../../test-data/image/avatar64x64.png");
tokio::fs::write(&file, bytes).await?;
alice
.set_config(Config::Selfavatar, Some(file.to_str().unwrap()))
.await?;
let sent_msg = alice
.send_text(alice_to_bob_chat.id, "hello, I have a new avatar")
.await;
bob.recv_msg(&sent_msg).await;
let alice_on_bob = bob.add_or_lookup_contact(&alice).await;
assert!(alice_on_bob.get_profile_image(&bob).await?.is_some());
wait_for_chatlist_specific_item(&bob, bob.create_chat(&alice).await.id).await;
Ok(())
}
/// Delete chat
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
alice.evtracker.clear_events().await;
chat.delete(&alice).await?;
wait_for_chatlist(&alice).await;
Ok(())
}
/// Create group chat
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_group_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
alice.evtracker.clear_events().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
wait_for_chatlist_and_specific_item(&alice, chat).await;
Ok(())
}
/// Create broadcastlist
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_broadcastlist() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
alice.evtracker.clear_events().await;
create_broadcast_list(&alice).await?;
wait_for_chatlist(&alice).await;
Ok(())
}
/// Mute chat
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mute_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
alice.evtracker.clear_events().await;
chat::set_muted(&alice, chat, MuteDuration::Forever).await?;
wait_for_chatlist_specific_item(&alice, chat).await;
alice.evtracker.clear_events().await;
chat::set_muted(&alice, chat, MuteDuration::NotMuted).await?;
wait_for_chatlist_specific_item(&alice, chat).await;
Ok(())
}
/// Expiry of mute should also trigger an event
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "does not work yet"]
async fn test_mute_chat_expired() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let mute_duration = MuteDuration::Until(
std::time::SystemTime::now()
.checked_add(Duration::from_secs(2))
.unwrap(),
);
chat::set_muted(&alice, chat, mute_duration).await?;
alice.evtracker.clear_events().await;
SystemTime::shift(Duration::from_secs(3));
wait_for_chatlist_specific_item(&alice, chat).await;
Ok(())
}
/// Change chat name
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_change_chat_name() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
alice.evtracker.clear_events().await;
chat::set_chat_name(&alice, chat, "New Name").await?;
wait_for_chatlist_specific_item(&alice, chat).await;
Ok(())
}
/// Change chat profile image
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_change_chat_profile_image() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
alice.evtracker.clear_events().await;
let file = alice.dir.path().join("avatar.png");
let bytes = include_bytes!("../../test-data/image/avatar64x64.png");
tokio::fs::write(&file, bytes).await?;
chat::set_chat_profile_image(&alice, chat, file.to_str().unwrap()).await?;
wait_for_chatlist_specific_item(&alice, chat).await;
Ok(())
}
/// Receive group and receive name change
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_receiving_group_and_group_changes() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice
.create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob])
.await;
let sent_msg = alice.send_text(chat, "Hello").await;
let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id;
wait_for_chatlist_specific_item(&bob, chat_id_for_bob).await;
chat_id_for_bob.accept(&bob).await?;
bob.evtracker.clear_events().await;
chat::set_chat_name(&alice, chat, "New Name").await?;
let sent_msg = alice.send_text(chat, "Hello").await;
bob.recv_msg(&sent_msg).await;
wait_for_chatlist_specific_item(&bob, chat_id_for_bob).await;
Ok(())
}
/// Accept contact request
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_accept_contact_request() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice
.create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob])
.await;
let sent_msg = alice.send_text(chat, "Hello").await;
let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id;
bob.evtracker.clear_events().await;
chat_id_for_bob.accept(&bob).await?;
wait_for_chatlist_specific_item(&bob, chat_id_for_bob).await;
Ok(())
}
/// Block contact request
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_block_contact_request() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice
.create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob])
.await;
let sent_msg = alice.send_text(chat, "Hello").await;
let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id;
bob.evtracker.clear_events().await;
chat_id_for_bob.block(&bob).await?;
wait_for_chatlist(&bob).await;
Ok(())
}
/// Delete message
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_message() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let message = chat::send_text_msg(&alice, chat, "Hello World".to_owned()).await?;
alice.evtracker.clear_events().await;
message::delete_msgs(&alice, &[message]).await?;
wait_for_chatlist_specific_item(&alice, chat).await;
Ok(())
}
/// Click on chat should remove the unread count (on msgs noticed)
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_msgs_noticed_on_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice
.create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob])
.await;
let sent_msg = alice.send_text(chat, "Hello").await;
let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id;
chat_id_for_bob.accept(&bob).await?;
let sent_msg = alice.send_text(chat, "New Message").await;
let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id;
assert!(chat_id_for_bob.get_fresh_msg_cnt(&bob).await? >= 1);
bob.evtracker.clear_events().await;
chat::marknoticed_chat(&bob, chat_id_for_bob).await?;
wait_for_chatlist_specific_item(&bob, chat_id_for_bob).await;
Ok(())
}
// Block and Unblock contact
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unblock_contact() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let contact_id = Contact::create(&alice, "example", "example@example.com").await?;
let _ = ChatId::create_for_contact(&alice, contact_id).await;
alice.evtracker.clear_events().await;
Contact::block(&alice, contact_id).await?;
wait_for_chatlist(&alice).await;
alice.evtracker.clear_events().await;
Contact::unblock(&alice, contact_id).await?;
wait_for_chatlist(&alice).await;
Ok(())
}
/// Tests that expired disappearing message
/// produces events about chatlist being modified.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_update_after_ephemeral_messages() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
chat.set_ephemeral_timer(&alice, crate::ephemeral::Timer::Enabled { duration: 60 })
.await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::ChatEphemeralTimerModified { .. }))
.await;
let _ = chat::send_text_msg(&alice, chat, "Hello".to_owned()).await?;
wait_for_chatlist_and_specific_item(&alice, chat).await;
SystemTime::shift(Duration::from_secs(70));
crate::ephemeral::delete_expired_messages(&alice, crate::tools::time()).await?;
wait_for_chatlist_and_specific_item(&alice, chat).await;
Ok(())
}
/// AdHoc (Groups without a group ID.) group receiving
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_group() -> Result<()> {
let alice = TestContext::new_alice().await;
let mime = br#"Subject: First thread
Message-ID: first@example.org
To: Alice <alice@example.org>, Bob <bob@example.net>
From: Claire <claire@example.org>
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
First thread."#;
alice.evtracker.clear_events().await;
receive_imf(&alice, mime, false).await?;
wait_for_chatlist(&alice).await;
Ok(())
}
/// Test both direction of securejoin
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_secure_join_group() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let alice_chatid =
chat::create_group_chat(&alice.ctx, ProtectionStatus::Protected, "the chat").await?;
// Step 1: Generate QR-code, secure-join implied by chatid
let qr = get_securejoin_qr(&alice.ctx, Some(alice_chatid)).await?;
// Step 2: Bob scans QR-code, sends vg-request
bob.evtracker.clear_events().await;
let bob_chatid = join_securejoin(&bob.ctx, &qr).await?;
wait_for_chatlist(&bob).await;
let sent = bob.pop_sent_msg().await;
// Step 3: Alice receives vg-request, sends vg-auth-required
alice.evtracker.clear_events().await;
alice.recv_msg_trash(&sent).await;
let sent = alice.pop_sent_msg().await;
// Step 4: Bob receives vg-auth-required, sends vg-request-with-auth
bob.evtracker.clear_events().await;
bob.recv_msg_trash(&sent).await;
wait_for_chatlist_and_specific_item(&bob, bob_chatid).await;
let sent = bob.pop_sent_msg().await;
// Step 5+6: Alice receives vg-request-with-auth, sends vg-member-added
alice.evtracker.clear_events().await;
alice.recv_msg_trash(&sent).await;
wait_for_chatlist_and_specific_item(&alice, alice_chatid).await;
let sent = alice.pop_sent_msg().await;
// Step 7: Bob receives vg-member-added
bob.evtracker.clear_events().await;
bob.recv_msg(&sent).await;
wait_for_chatlist_and_specific_item(&bob, bob_chatid).await;
Ok(())
}
/// Call Resend on message
///
/// (the event is technically only needed if it is the last message in the chat, but checking that would be too expensive so the event is always emitted)
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_resend_message() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let msg_id = chat::send_text_msg(&alice, chat, "Hello".to_owned()).await?;
let _ = alice.pop_sent_msg().await;
let message = Message::load_from_db(&alice, msg_id).await?;
assert_eq!(message.get_state(), MessageState::OutDelivered);
alice.evtracker.clear_events().await;
chat::resend_msgs(&alice, &[msg_id]).await?;
wait_for_chatlist_specific_item(&alice, chat).await;
Ok(())
}
/// test that setting a reaction emits chatlistitem update event
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_reaction() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?;
let msg_id = chat::send_text_msg(&alice, chat, "Hello".to_owned()).await?;
let _ = alice.pop_sent_msg().await;
alice.evtracker.clear_events().await;
reaction::send_reaction(&alice, msg_id, "👍").await?;
let _ = alice.pop_sent_msg().await;
wait_for_chatlist_specific_item(&alice, chat).await;
Ok(())
}
}

View File

@@ -182,7 +182,7 @@ pub enum EventType {
timer: EphemeralTimer,
},
/// Contact(s) created, renamed, blocked or deleted.
/// Contact(s) created, renamed, blocked, deleted or changed their "recently seen" status.
///
/// @param data1 (int) If set, this is the contact_id of an added contact that should be selected.
ContactsChanged(Option<ContactId>),
@@ -291,4 +291,15 @@ pub enum EventType {
///
/// This event is only emitted by the account manager
AccountsBackgroundFetchDone,
/// Inform that set of chats or the order of the chats in the chatlist has changed.
///
/// Sometimes this is emitted together with `UIChatlistItemChanged`.
ChatlistChanged,
/// Inform that a single chat list item changed and needs to be rerendered.
/// If `chat_id` is set to None, then all currently visible chats need to be rerendered, and all not-visible items need to be cleared from cache if the UI has a cache.
ChatlistItemChanged {
/// ID of the changed chat
chat_id: Option<ChatId>,
},
}

View File

@@ -7,12 +7,8 @@
//! `MsgId.get_html()` will return HTML -
//! this allows nice quoting, handling linebreaks properly etc.
use std::future::Future;
use std::pin::Pin;
use anyhow::{Context as _, Result};
use base64::Engine as _;
use futures::future::FutureExt;
use lettre_email::mime::Mime;
use lettre_email::PartBuilder;
use mailparse::ParsedContentType;
@@ -116,119 +112,109 @@ 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.
fn collect_texts_recursive<'a>(
async fn collect_texts_recursive<'a>(
&'a mut self,
mail: &'a mailparse::ParsedMail<'a>,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a + Send>> {
// Boxed future to deal with recursion
async move {
match get_mime_multipart_type(&mail.ctype) {
MimeMultipartType::Multiple => {
for cur_data in &mail.subparts {
self.collect_texts_recursive(cur_data).await?
}
Ok(())
) -> Result<()> {
match get_mime_multipart_type(&mail.ctype) {
MimeMultipartType::Multiple => {
for cur_data in &mail.subparts {
Box::pin(self.collect_texts_recursive(cur_data)).await?
}
MimeMultipartType::Message => {
let raw = mail.get_body_raw()?;
if raw.is_empty() {
return Ok(());
}
let mail = mailparse::parse_mail(&raw).context("failed to parse mail")?;
self.collect_texts_recursive(&mail).await
Ok(())
}
MimeMultipartType::Message => {
let raw = mail.get_body_raw()?;
if raw.is_empty() {
return Ok(());
}
MimeMultipartType::Single => {
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
if mimetype == mime::TEXT_HTML {
if self.html.is_empty() {
if let Ok(decoded_data) = mail.get_body() {
self.html = decoded_data;
}
}
} else if mimetype == mime::TEXT_PLAIN && self.plain.is_none() {
let mail = mailparse::parse_mail(&raw).context("failed to parse mail")?;
Box::pin(self.collect_texts_recursive(&mail)).await
}
MimeMultipartType::Single => {
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
if mimetype == mime::TEXT_HTML {
if self.html.is_empty() {
if let Ok(decoded_data) = mail.get_body() {
self.plain = Some(PlainText {
text: decoded_data,
flowed: if let Some(format) = mail.ctype.params.get("format") {
format.as_str().to_ascii_lowercase() == "flowed"
} else {
false
},
delsp: if let Some(delsp) = mail.ctype.params.get("delsp") {
delsp.as_str().to_ascii_lowercase() == "yes"
} else {
false
},
});
self.html = decoded_data;
}
}
Ok(())
} else if mimetype == mime::TEXT_PLAIN && self.plain.is_none() {
if let Ok(decoded_data) = mail.get_body() {
self.plain = Some(PlainText {
text: decoded_data,
flowed: if let Some(format) = mail.ctype.params.get("format") {
format.as_str().to_ascii_lowercase() == "flowed"
} else {
false
},
delsp: if let Some(delsp) = mail.ctype.params.get("delsp") {
delsp.as_str().to_ascii_lowercase() == "yes"
} else {
false
},
});
}
}
Ok(())
}
}
.boxed()
}
/// Replace cid:-protocol by the data:-protocol where appropriate.
/// This allows the final html-file to be self-contained.
fn cid_to_data_recursive<'a>(
async fn cid_to_data_recursive<'a>(
&'a mut self,
context: &'a Context,
mail: &'a mailparse::ParsedMail<'a>,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a + Send>> {
// Boxed future to deal with recursion
async move {
match get_mime_multipart_type(&mail.ctype) {
MimeMultipartType::Multiple => {
for cur_data in &mail.subparts {
self.cid_to_data_recursive(context, cur_data).await?;
}
Ok(())
) -> Result<()> {
match get_mime_multipart_type(&mail.ctype) {
MimeMultipartType::Multiple => {
for cur_data in &mail.subparts {
Box::pin(self.cid_to_data_recursive(context, cur_data)).await?;
}
MimeMultipartType::Message => {
let raw = mail.get_body_raw()?;
if raw.is_empty() {
return Ok(());
}
let mail = mailparse::parse_mail(&raw).context("failed to parse mail")?;
self.cid_to_data_recursive(context, &mail).await
Ok(())
}
MimeMultipartType::Message => {
let raw = mail.get_body_raw()?;
if raw.is_empty() {
return Ok(());
}
MimeMultipartType::Single => {
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
if mimetype.type_() == mime::IMAGE {
if let Some(cid) = mail.headers.get_header_value(HeaderDef::ContentId) {
if let Ok(cid) = parse_message_id(&cid) {
if let Ok(replacement) = mimepart_to_data_url(mail) {
let re_string = format!(
"(<img[^>]*src[^>]*=[^>]*)(cid:{})([^>]*>)",
regex::escape(&cid)
);
match regex::Regex::new(&re_string) {
Ok(re) => {
self.html = re
.replace_all(
&self.html,
format!("${{1}}{replacement}${{3}}").as_str(),
)
.as_ref()
.to_string()
}
Err(e) => warn!(
context,
"Cannot create regex for cid: {} throws {}",
re_string,
e
),
let mail = mailparse::parse_mail(&raw).context("failed to parse mail")?;
Box::pin(self.cid_to_data_recursive(context, &mail)).await
}
MimeMultipartType::Single => {
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
if mimetype.type_() == mime::IMAGE {
if let Some(cid) = mail.headers.get_header_value(HeaderDef::ContentId) {
if let Ok(cid) = parse_message_id(&cid) {
if let Ok(replacement) = mimepart_to_data_url(mail) {
let re_string = format!(
"(<img[^>]*src[^>]*=[^>]*)(cid:{})([^>]*>)",
regex::escape(&cid)
);
match regex::Regex::new(&re_string) {
Ok(re) => {
self.html = re
.replace_all(
&self.html,
format!("${{1}}{replacement}${{3}}").as_str(),
)
.as_ref()
.to_string()
}
Err(e) => warn!(
context,
"Cannot create regex for cid: {} throws {}", re_string, e
),
}
}
}
}
Ok(())
}
Ok(())
}
}
.boxed()
}
}

View File

@@ -15,6 +15,7 @@ use std::{
use anyhow::{bail, format_err, Context as _, Result};
use async_channel::Receiver;
use async_imap::types::{Fetch, Flag, Name, NameAttribute, UnsolicitedResponse};
use deltachat_contact_tools::{normalize_name, ContactAddress};
use futures::{FutureExt as _, StreamExt, TryStreamExt};
use futures_lite::FutureExt;
use num_traits::FromPrimitive;
@@ -22,9 +23,10 @@ use ratelimit::Ratelimit;
use tokio::sync::RwLock;
use crate::chat::{self, ChatId, ChatIdBlocked};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::{self, Blocked, Chattype, ShowEmails};
use crate::contact::{normalize_name, Contact, ContactAddress, ContactId, Modifier, Origin};
use crate::contact::{Contact, ContactId, Modifier, Origin};
use crate::context::Context;
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
@@ -388,7 +390,7 @@ impl Imap {
Ok(session) => {
// Store server ID in the context to display in account info.
let mut lock = context.server_id.write().await;
*lock = session.capabilities.server_id.clone();
lock.clone_from(&session.capabilities.server_id);
self.login_failed_once = false;
context.emit_event(EventType::ImapConnected(format!(
@@ -420,7 +422,7 @@ impl Imap {
drop(lock);
let mut msg = Message::new(Viewtype::Text);
msg.text = message.clone();
msg.text.clone_from(&message);
if let Err(e) =
chat::add_device_msg_with_importance(context, None, Some(&mut msg), true)
.await
@@ -1170,6 +1172,7 @@ impl Session {
.with_context(|| format!("failed to set MODSEQ for folder {folder}"))?;
for updated_chat_id in updated_chat_ids {
context.emit_event(EventType::MsgsNoticed(updated_chat_id));
chatlist_events::emit_chatlist_item_changed(context, updated_chat_id);
}
Ok(())

View File

@@ -89,9 +89,16 @@ impl Imap {
Config::ConfiguredSentboxFolder,
Config::ConfiguredTrashFolder,
] {
context
.set_config_internal(conf, folder_configs.get(&conf).map(|s| s.as_str()))
.await?;
let val = folder_configs.get(&conf).map(|s| s.as_str());
let interrupt = conf == Config::ConfiguredTrashFolder
&& val.is_some()
&& context.get_config(conf).await?.is_none();
context.set_config_internal(conf, val).await?;
if interrupt {
// `Imap::fetch_move_delete()` is possible now for other folders (NB: we are in the
// Inbox loop).
context.scheduler.interrupt_oboxes().await;
}
}
last_scan.replace(tools::Time::now());

View File

@@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
use ::pgp::types::KeyTrait;
use anyhow::{bail, ensure, format_err, Context as _, Result};
use deltachat_contact_tools::EmailAddress;
use futures::StreamExt;
use futures_lite::FutureExt;
use rand::{thread_rng, Rng};
@@ -31,7 +32,6 @@ use crate::sql;
use crate::stock_str;
use crate::tools::{
create_folder, delete_file, get_filesuffix_lc, open_file_std, read_file, time, write_file,
EmailAddress,
};
mod transfer;
@@ -499,7 +499,7 @@ fn get_next_backup_path(
backup_time: i64,
) -> Result<(PathBuf, PathBuf, PathBuf)> {
let folder = PathBuf::from(folder);
let stem = chrono::NaiveDateTime::from_timestamp_opt(backup_time, 0)
let stem = chrono::DateTime::<chrono::Utc>::from_timestamp(backup_time, 0)
.context("can't get next backup path")?
// Don't change this file name format, in `dc_imex_has_backup` we use string comparison to determine which backup is newer:
.format("delta-chat-backup-%Y-%m-%d")
@@ -1158,7 +1158,8 @@ mod tests {
// Send a message that cannot be decrypted because the keys are
// not synchronized yet.
let sent = alice2.send_text(msg.chat_id, "Test").await;
alice.recv_msg(&sent).await;
let trashed_message = alice.recv_msg_opt(&sent).await;
assert!(trashed_message.is_none());
assert_ne!(alice.get_last_msg().await.get_text(), "Test");
// Transfer the key.

View File

@@ -6,6 +6,7 @@ use std::io::Cursor;
use anyhow::{ensure, Context as _, Result};
use base64::Engine as _;
use deltachat_contact_tools::EmailAddress;
use num_traits::FromPrimitive;
use pgp::composed::Deserializable;
pub use pgp::composed::{SignedPublicKey, SignedSecretKey};
@@ -18,7 +19,7 @@ use crate::constants::KeyGenType;
use crate::context::Context;
use crate::log::LogExt;
use crate::pgp::KeyPair;
use crate::tools::{self, time_elapsed, EmailAddress};
use crate::tools::{self, time_elapsed};
/// Convenience trait for working with keys.
///

View File

@@ -13,8 +13,8 @@ use crate::context::Context;
use crate::events::EventType;
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::SystemMessage;
use crate::stock_str;
use crate::tools::{duration_to_str, time};
use crate::{chatlist_events, stock_str};
/// Location record.
#[derive(Debug, Clone, Default)]
@@ -137,7 +137,7 @@ impl Kml {
// 0 4 7 10 13 16 19
match chrono::NaiveDateTime::parse_from_str(&val, "%Y-%m-%dT%H:%M:%SZ") {
Ok(res) => {
self.curr.timestamp = res.timestamp();
self.curr.timestamp = res.and_utc().timestamp();
let now = time();
if self.curr.timestamp > now {
self.curr.timestamp = now;
@@ -290,6 +290,7 @@ pub async fn send_locations_to_chat(
chat::add_info_msg(context, chat_id, &stock_str, now).await?;
}
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);
if 0 != seconds {
context.scheduler.interrupt_location().await;
}
@@ -540,7 +541,7 @@ pub async fn get_kml(context: &Context, chat_id: ChatId) -> Result<Option<(Strin
fn get_kml_timestamp(utc: i64) -> String {
// Returns a string formatted as YYYY-MM-DDTHH:MM:SSZ. The trailing `Z` indicates UTC.
chrono::NaiveDateTime::from_timestamp_opt(utc, 0)
chrono::DateTime::<chrono::Utc>::from_timestamp(utc, 0)
.unwrap()
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string()
@@ -802,6 +803,7 @@ async fn maybe_send_locations(context: &Context) -> Result<Option<u64>> {
let stock_str = stock_str::msg_location_disabled(context).await;
chat::add_info_msg(context, chat_id, &stock_str, now).await?;
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);
}
}

View File

@@ -80,7 +80,7 @@ impl LoginParam {
// Only check for IMAP password, SMTP password is an "advanced" setting.
ensure!(!param.imap.password.is_empty(), "Missing (IMAP) password.");
if param.smtp.password.is_empty() {
param.smtp.password = param.imap.password.clone()
param.smtp.password.clone_from(&param.imap.password)
}
Ok(param)
}

View File

@@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::blob::BlobObject;
use crate::chat::{Chat, ChatId};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::{
Blocked, Chattype, VideochatType, DC_CHAT_ID_TRASH, DC_DESIRED_TEXT_LEN, DC_MSG_ID_LAST_SPECIAL,
@@ -138,6 +139,7 @@ WHERE id=?;
chat_id,
msg_id: self,
});
chatlist_events::emit_chatlist_item_changed(context, chat_id);
Ok(())
}
@@ -362,7 +364,7 @@ impl rusqlite::types::FromSql for MsgId {
fn column_result(value: rusqlite::types::ValueRef) -> rusqlite::types::FromSqlResult<Self> {
// Would be nice if we could use match here, but alas.
i64::column_result(value).and_then(|val| {
if 0 <= val && val <= i64::from(std::u32::MAX) {
if 0 <= val && val <= i64::from(u32::MAX) {
Ok(MsgId::new(val as u32))
} else {
Err(rusqlite::types::FromSqlError::OutOfRange(val))
@@ -459,7 +461,19 @@ impl Message {
}
/// Loads message with given ID from the database.
///
/// Returns an error if the message does not exist.
pub async fn load_from_db(context: &Context, id: MsgId) -> Result<Message> {
let message = Self::load_from_db_optional(context, id)
.await?
.with_context(|| format!("Message {id} does not exist"))?;
Ok(message)
}
/// Loads message with given ID from the database.
///
/// Returns `None` if the message does not exist.
pub async fn load_from_db_optional(context: &Context, id: MsgId) -> Result<Option<Message>> {
ensure!(
!id.is_special(),
"Can not load special message ID {} from DB",
@@ -467,7 +481,7 @@ impl Message {
);
let msg = context
.sql
.query_row(
.query_row_optional(
concat!(
"SELECT",
" m.id AS id,",
@@ -494,7 +508,7 @@ impl Message {
" m.location_id AS location,",
" c.blocked AS blocked",
" FROM msgs m LEFT JOIN chats c ON c.id=m.chat_id",
" WHERE m.id=?;"
" WHERE m.id=? AND chat_id!=3;"
),
(id,),
|row| {
@@ -796,7 +810,7 @@ impl Message {
None
};
Ok(Summary::new(context, self, chat, contact.as_ref()).await)
Summary::new(context, self, chat, contact.as_ref()).await
}
// It's a little unfortunate that the UI has to first call `dc_msg_get_override_sender_name` and then if it was `NULL`, call
@@ -1133,13 +1147,8 @@ impl Message {
pub async fn parent(&self, context: &Context) -> Result<Option<Message>> {
if let Some(in_reply_to) = &self.in_reply_to {
if let Some((msg_id, _ts_sent)) = rfc724_mid_exists(context, in_reply_to).await? {
let msg = Message::load_from_db(context, msg_id).await?;
return if msg.chat_id.is_trash() {
// If message is already moved to trash chat, pretend it does not exist.
Ok(None)
} else {
Ok(Some(msg))
};
let msg = Message::load_from_db_optional(context, msg_id).await?;
return Ok(msg);
}
}
Ok(None)
@@ -1520,9 +1529,12 @@ pub async fn delete_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
for modified_chat_id in modified_chat_ids {
context.emit_msgs_changed(modified_chat_id, MsgId::new(0));
chatlist_events::emit_chatlist_item_changed(context, modified_chat_id);
}
if !msg_ids.is_empty() {
context.emit_msgs_changed_without_ids();
chatlist_events::emit_chatlist_changed(context);
// Run housekeeping to delete unused blobs.
context
.set_config_internal(Config::LastHousekeeping, None)
@@ -1657,6 +1669,7 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
for updated_chat_id in updated_chat_ids {
context.emit_event(EventType::MsgsNoticed(updated_chat_id));
chatlist_events::emit_chatlist_item_changed(context, updated_chat_id);
}
Ok(())
@@ -1717,6 +1730,7 @@ pub(crate) async fn set_msg_failed(
chat_id: msg.chat_id,
msg_id: msg.id,
});
chatlist_events::emit_chatlist_item_changed(context, msg.chat_id);
Ok(())
}
@@ -1869,8 +1883,7 @@ pub(crate) async fn get_latest_by_rfc724_mids(
) -> Result<Option<Message>> {
for id in mids.iter().rev() {
if let Some((msg_id, _)) = rfc724_mid_exists(context, id).await? {
let msg = Message::load_from_db(context, msg_id).await?;
if msg.chat_id != DC_CHAT_ID_TRASH {
if let Some(msg) = Message::load_from_db_optional(context, msg_id).await? {
return Ok(Some(msg));
}
}
@@ -1975,8 +1988,9 @@ mod tests {
use num_traits::FromPrimitive;
use super::*;
use crate::chat::{self, marknoticed_chat, ChatItem};
use crate::chat::{self, marknoticed_chat, send_text_msg, ChatItem};
use crate::chatlist::Chatlist;
use crate::reaction::send_reaction;
use crate::receive_imf::receive_imf;
use crate::test_utils as test;
use crate::test_utils::{TestContext, TestContextManager};
@@ -2466,6 +2480,24 @@ mod tests {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_message_summary_text() -> Result<()> {
let t = TestContext::new_alice().await;
let chat = t.get_self_chat().await;
let msg_id = send_text_msg(&t, chat.id, "foo".to_string()).await?;
let msg = Message::load_from_db(&t, msg_id).await?;
let summary = msg.get_summary(&t, None).await?;
assert_eq!(summary.text, "foo");
// message summary does not change when reactions are applied (in contrast to chatlist summary)
send_reaction(&t, msg_id, "🫵").await?;
let msg = Message::load_from_db(&t, msg_id).await?;
let summary = msg.get_summary(&t, None).await?;
assert_eq!(summary.text, "foo");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_format_flowed_round_trip() -> Result<()> {
let mut tcm = TestContextManager::new();

View File

@@ -93,7 +93,6 @@ pub struct RenderedEmail {
// pub envelope: Envelope,
pub is_encrypted: bool,
pub is_gossiped: bool,
pub is_group: bool,
pub last_added_location_id: Option<u32>,
/// A comma-separated string of sync-IDs that are used by the rendered email
@@ -575,11 +574,7 @@ impl<'a> MimeFactory<'a> {
.protected
.push(Header::new("Subject".into(), encoded_subject));
let date = chrono::Utc
.from_local_datetime(
&chrono::NaiveDateTime::from_timestamp_opt(self.timestamp, 0)
.context("can't convert timestamp to NativeDateTime")?,
)
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(self.timestamp, 0)
.unwrap()
.to_rfc2822();
headers.unprotected.push(Header::new("Date".into(), date));
@@ -618,8 +613,6 @@ impl<'a> MimeFactory<'a> {
));
}
let mut is_group = false;
if let Loaded::Message { chat } = &self.loaded {
if chat.typ == Chattype::Broadcast {
let encoded_chat_name = encode_words(&chat.name);
@@ -627,8 +620,6 @@ impl<'a> MimeFactory<'a> {
"List-ID".into(),
format!("{encoded_chat_name} <{}>", chat.grpid),
));
} else if chat.typ == Chattype::Group {
is_group = true;
}
}
@@ -900,7 +891,6 @@ impl<'a> MimeFactory<'a> {
// envelope: Envelope::new,
is_encrypted,
is_gossiped,
is_group,
last_added_location_id,
sync_ids_to_delete: self.sync_ids_to_delete,
rfc724_mid,
@@ -1592,18 +1582,18 @@ fn maybe_encode_words(words: &str) -> String {
#[cfg(test)]
mod tests {
use deltachat_contact_tools::ContactAddress;
use mailparse::{addrparse_header, MailHeaderMap};
use std::str;
use super::*;
use crate::chat::ChatId;
use crate::chat::{
self, add_contact_to_chat, create_group_chat, remove_contact_from_chat, send_text_msg,
add_contact_to_chat, create_group_chat, remove_contact_from_chat, send_text_msg, ChatId,
ProtectionStatus,
};
use crate::chatlist::Chatlist;
use crate::constants;
use crate::contact::{ContactAddress, Origin};
use crate::contact::Origin;
use crate::mimeparser::MimeMessage;
use crate::receive_imf::receive_imf;
use crate::test_utils::{get_chat_msg, TestContext, TestContextManager};

View File

@@ -2,12 +2,11 @@
use std::cmp::min;
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use std::str;
use anyhow::{bail, Context as _, Result};
use deltachat_contact_tools::{addr_cmp, addr_normalize, strip_rtlo_characters};
use deltachat_derive::{FromSql, ToSql};
use format_flowed::unformat_flowed;
use lettre_email::mime::Mime;
@@ -17,8 +16,8 @@ use crate::aheader::{Aheader, EncryptPreference};
use crate::blob::BlobObject;
use crate::chat::{add_info_msg, ChatId};
use crate::config::Config;
use crate::constants::{Chattype, DC_DESIRED_TEXT_LINES, DC_DESIRED_TEXT_LINE_LEN};
use crate::contact::{addr_cmp, addr_normalize, Contact, ContactId, Origin};
use crate::constants::{self, Chattype, DC_DESIRED_TEXT_LINES, DC_DESIRED_TEXT_LINE_LEN};
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::decrypt::{
keyring_from_peerstate, prepare_decryption, try_decrypt, validate_detached_signature,
@@ -34,13 +33,11 @@ use crate::message::{
use crate::param::{Param, Params};
use crate::peerstate::Peerstate;
use crate::simplify::{simplify, SimplifiedText};
use crate::stock_str;
use crate::sync::SyncItems;
use crate::tools::{
create_smeared_timestamp, get_filemeta, parse_receive_headers, smeared_time,
strip_rtlo_characters, truncate_by_lines,
create_smeared_timestamp, get_filemeta, parse_receive_headers, smeared_time, truncate_by_lines,
};
use crate::{location, tools};
use crate::{chatlist_events, location, stock_str, tools};
/// A parsed MIME message.
///
@@ -214,7 +211,9 @@ impl MimeMessage {
.headers
.get_header_value(HeaderDef::Date)
.and_then(|v| mailparse::dateparse(&v).ok())
.map_or(timestamp_rcvd, |value| min(value, timestamp_rcvd + 60));
.map_or(timestamp_rcvd, |value| {
min(value, timestamp_rcvd + constants::TIMESTAMP_SENT_TOLERANCE)
});
let mut hop_info = parse_receive_headers(&mail.get_headers());
let mut headers = Default::default();
@@ -604,7 +603,7 @@ impl MimeMessage {
let mut filepart = self.parts.swap_remove(1);
// insert new one
filepart.msg = self.parts[0].msg.clone();
filepart.msg.clone_from(&self.parts[0].msg);
if let Some(quote) = self.parts[0].param.get(Param::Quote) {
filepart.param.set(Param::Quote, quote);
}
@@ -818,59 +817,53 @@ impl MimeMessage {
self.headers.get(headerdef.get_headername())
}
fn parse_mime_recursive<'a>(
async fn parse_mime_recursive<'a>(
&'a mut self,
context: &'a Context,
mail: &'a mailparse::ParsedMail<'a>,
is_related: bool,
) -> Pin<Box<dyn Future<Output = Result<bool>> + 'a + Send>> {
use futures::future::FutureExt;
) -> Result<bool> {
enum MimeS {
Multiple,
Single,
Message,
}
// Boxed future to deal with recursion
async move {
enum MimeS {
Multiple,
Single,
Message,
}
let mimetype = mail.ctype.mimetype.to_lowercase();
let mimetype = mail.ctype.mimetype.to_lowercase();
let m = if mimetype.starts_with("multipart") {
if mail.ctype.params.contains_key("boundary") {
MimeS::Multiple
} else {
MimeS::Single
}
} else if mimetype.starts_with("message") {
if mimetype == "message/rfc822" && !is_attachment_disposition(mail) {
MimeS::Message
} else {
MimeS::Single
}
let m = if mimetype.starts_with("multipart") {
if mail.ctype.params.contains_key("boundary") {
MimeS::Multiple
} else {
MimeS::Single
};
}
} else if mimetype.starts_with("message") {
if mimetype == "message/rfc822" && !is_attachment_disposition(mail) {
MimeS::Message
} else {
MimeS::Single
}
} else {
MimeS::Single
};
let is_related = is_related || mimetype == "multipart/related";
match m {
MimeS::Multiple => self.handle_multiple(context, mail, is_related).await,
MimeS::Message => {
let raw = mail.get_body_raw()?;
if raw.is_empty() {
return Ok(false);
}
let mail = mailparse::parse_mail(&raw).context("failed to parse mail")?;
let is_related = is_related || mimetype == "multipart/related";
match m {
MimeS::Multiple => Box::pin(self.handle_multiple(context, mail, is_related)).await,
MimeS::Message => {
let raw = mail.get_body_raw()?;
if raw.is_empty() {
return Ok(false);
}
let mail = mailparse::parse_mail(&raw).context("failed to parse mail")?;
self.parse_mime_recursive(context, &mail, is_related).await
}
MimeS::Single => {
self.add_single_part_if_known(context, mail, is_related)
.await
}
Box::pin(self.parse_mime_recursive(context, &mail, is_related)).await
}
MimeS::Single => {
self.add_single_part_if_known(context, mail, is_related)
.await
}
}
.boxed()
}
async fn handle_multiple(
@@ -2159,6 +2152,8 @@ async fn handle_mdn(
{
update_msg_state(context, msg_id, MessageState::OutMdnRcvd).await?;
context.emit_event(EventType::MsgRead { chat_id, msg_id });
// note(treefit): only matters if it is the last message in chat (but probably too expensive to check, debounce also solves it)
chatlist_events::emit_chatlist_item_changed(context, chat_id);
}
Ok(())
}

View File

@@ -64,6 +64,15 @@ pub enum Param {
/// For Messages: the message is a reaction.
Reaction = b'x',
/// For Chats: the timestamp of the last reaction.
LastReactionTimestamp = b'y',
/// For Chats: Message ID of the last reaction.
LastReactionMsgId = b'Y',
/// For Chats: Contact ID of the last reaction.
LastReactionContactId = b'1',
/// For Messages: a message with "Auto-Submitted: auto-generated" header ("bot").
Bot = b'b',

View File

@@ -3,6 +3,7 @@
use std::mem;
use anyhow::{Context as _, Error, Result};
use deltachat_contact_tools::{addr_cmp, ContactAddress};
use num_traits::FromPrimitive;
use crate::aheader::{Aheader, EncryptPreference};
@@ -10,14 +11,14 @@ use crate::chat::{self, Chat};
use crate::chatlist::Chatlist;
use crate::config::Config;
use crate::constants::Chattype;
use crate::contact::{addr_cmp, Contact, ContactAddress, Origin};
use crate::contact::{Contact, Origin};
use crate::context::Context;
use crate::events::EventType;
use crate::key::{DcKey, Fingerprint, SignedPublicKey};
use crate::message::Message;
use crate::mimeparser::SystemMessage;
use crate::sql::Sql;
use crate::stock_str;
use crate::{chatlist_events, stock_str};
/// Type of the public key stored inside the peerstate.
#[derive(Debug)]
@@ -722,6 +723,9 @@ impl Peerstate {
.await?;
}
chatlist_events::emit_chatlist_changed(context);
// update the chats the contact is part of
chatlist_events::emit_chatlist_items_changed_for_contact(context, contact_id);
Ok(())
}
@@ -784,7 +788,7 @@ pub(crate) async fn maybe_do_aeap_transition(
.await?;
let old_addr = mem::take(&mut peerstate.addr);
peerstate.addr = info.from.clone();
peerstate.addr.clone_from(&info.from);
let header = info.autocrypt_header.as_ref().context(
"Internal error: Tried to do an AEAP transition without an autocrypt header??",
)?;

View File

@@ -5,6 +5,7 @@ use std::io;
use std::io::Cursor;
use anyhow::{bail, Context as _, Result};
use deltachat_contact_tools::EmailAddress;
use pgp::armor::BlockType;
use pgp::composed::{
Deserializable, KeyType as PgpKeyType, Message, SecretKeyParamsBuilder, SignedPublicKey,
@@ -20,7 +21,6 @@ use tokio::runtime::Handle;
use crate::constants::KeyGenType;
use crate::key::{DcKey, Fingerprint};
use crate::tools::EmailAddress;
#[allow(missing_docs)]
#[cfg(test)]

View File

@@ -3,12 +3,12 @@
mod data;
use anyhow::Result;
use deltachat_contact_tools::EmailAddress;
use hickory_resolver::{config, AsyncResolver, TokioAsyncResolver};
use crate::config::Config;
use crate::context::Context;
use crate::provider::data::{PROVIDER_DATA, PROVIDER_IDS};
use crate::tools::EmailAddress;
/// Provider status according to manual testing.
#[derive(Debug, Display, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)]

View File

@@ -5,6 +5,7 @@ use std::collections::BTreeMap;
use anyhow::{anyhow, bail, ensure, Context as _, Result};
pub use dclogin_scheme::LoginOptions;
use deltachat_contact_tools::{addr_normalize, may_be_valid_addr, ContactAddress};
use once_cell::sync::Lazy;
use percent_encoding::percent_decode_str;
use serde::Deserialize;
@@ -13,9 +14,7 @@ use self::dclogin_scheme::configure_from_login_qr;
use crate::chat::{get_chat_id_by_grpid, ChatIdBlocked};
use crate::config::Config;
use crate::constants::Blocked;
use crate::contact::{
addr_normalize, may_be_valid_addr, Contact, ContactAddress, ContactId, Origin,
};
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::events::EventType;
use crate::key::Fingerprint;

View File

@@ -1,13 +1,15 @@
use std::collections::HashMap;
use anyhow::{bail, Context as _, Result};
use deltachat_contact_tools::may_be_valid_addr;
use num_traits::cast::ToPrimitive;
use super::{Qr, DCLOGIN_SCHEME};
use crate::config::Config;
use crate::context::Context;
use crate::login_param::CertificateChecks;
use crate::provider::Socket;
use crate::{contact, login_param::CertificateChecks};
/// Options for `dclogin:` scheme.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -88,7 +90,7 @@ pub(super) fn decode_login(qr: &str) -> Result<Qr> {
.collect();
// check if username is there
if !contact::may_be_valid_addr(addr) {
if !may_be_valid_addr(addr) {
bail!("invalid DCLOGIN payload: invalid username E5");
}

View File

@@ -20,11 +20,13 @@ use std::fmt;
use anyhow::Result;
use crate::chat::{send_msg, ChatId};
use crate::chat::{send_msg, Chat, ChatId};
use crate::chatlist_events;
use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
use crate::message::{rfc724_mid_exists, Message, MsgId, Viewtype};
use crate::param::Param;
/// A single reaction consisting of multiple emoji sequences.
///
@@ -170,6 +172,7 @@ async fn set_msg_id_reaction(
msg_id: MsgId,
chat_id: ChatId,
contact_id: ContactId,
timestamp: i64,
reaction: Reaction,
) -> Result<()> {
if reaction.is_empty() {
@@ -194,6 +197,17 @@ async fn set_msg_id_reaction(
(msg_id, contact_id, reaction.as_str()),
)
.await?;
let mut chat = Chat::load_from_db(context, chat_id).await?;
if chat
.param
.update_timestamp(Param::LastReactionTimestamp, timestamp)?
{
chat.param
.set_i64(Param::LastReactionMsgId, i64::from(msg_id.to_u32()));
chat.param
.set_i64(Param::LastReactionContactId, i64::from(contact_id.to_u32()));
chat.update_param(context).await?;
}
}
context.emit_event(EventType::ReactionsChanged {
@@ -201,6 +215,7 @@ async fn set_msg_id_reaction(
msg_id,
contact_id,
});
chatlist_events::emit_chatlist_item_changed(context, chat_id);
Ok(())
}
@@ -223,7 +238,15 @@ pub async fn send_reaction(context: &Context, msg_id: MsgId, reaction: &str) ->
let reaction_msg_id = send_msg(context, chat_id, &mut reaction_msg).await?;
// Only set reaction if we successfully sent the message.
set_msg_id_reaction(context, msg_id, msg.chat_id, ContactId::SELF, reaction).await?;
set_msg_id_reaction(
context,
msg_id,
msg.chat_id,
ContactId::SELF,
reaction_msg.timestamp_sort,
reaction,
)
.await?;
Ok(reaction_msg_id)
}
@@ -250,10 +273,11 @@ pub(crate) async fn set_msg_reaction(
in_reply_to: &str,
chat_id: ChatId,
contact_id: ContactId,
timestamp: i64,
reaction: Reaction,
) -> Result<()> {
if let Some((msg_id, _)) = rfc724_mid_exists(context, in_reply_to).await? {
set_msg_id_reaction(context, msg_id, chat_id, contact_id, reaction).await
set_msg_id_reaction(context, msg_id, chat_id, contact_id, timestamp, reaction).await
} else {
info!(
context,
@@ -307,18 +331,72 @@ pub async fn get_msg_reactions(context: &Context, msg_id: MsgId) -> Result<React
Ok(Reactions { reactions })
}
impl Chat {
/// Check if there is a reaction newer than the given timestamp.
///
/// If so, reaction details are returned and can be used to create a summary string.
pub async fn get_last_reaction_if_newer_than(
&self,
context: &Context,
timestamp: i64,
) -> Result<Option<(Message, ContactId, String)>> {
if self
.param
.get_i64(Param::LastReactionTimestamp)
.filter(|&reaction_timestamp| reaction_timestamp > timestamp)
.is_none()
{
return Ok(None);
};
let reaction_msg_id = MsgId::new(
self.param
.get_int(Param::LastReactionMsgId)
.unwrap_or_default() as u32,
);
let Some(reaction_msg) = Message::load_from_db_optional(context, reaction_msg_id).await?
else {
// The message reacted to may be deleted.
// These are no errors as `Param::LastReaction*` are just weak pointers.
// Instead, just return `Ok(None)` and let the caller create another summary.
return Ok(None);
};
let reaction_contact_id = ContactId::new(
self.param
.get_int(Param::LastReactionContactId)
.unwrap_or_default() as u32,
);
if let Some(reaction) = context
.sql
.query_get_value(
"SELECT reaction FROM reactions WHERE msg_id=? AND contact_id=?",
(reaction_msg.id, reaction_contact_id),
)
.await?
{
Ok(Some((reaction_msg, reaction_contact_id, reaction)))
} else {
Ok(None)
}
}
}
#[cfg(test)]
mod tests {
use deltachat_contact_tools::ContactAddress;
use super::*;
use crate::chat::{get_chat_msgs, send_text_msg};
use crate::chat::{forward_msgs, get_chat_msgs, send_text_msg};
use crate::chatlist::Chatlist;
use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::contact::{Contact, ContactAddress, Origin};
use crate::contact::{Contact, Origin};
use crate::download::DownloadState;
use crate::message::MessageState;
use crate::message::{delete_msgs, MessageState};
use crate::receive_imf::{receive_imf, receive_imf_from_inbox};
use crate::sql::housekeeping;
use crate::test_utils::TestContext;
use crate::test_utils::TestContextManager;
use crate::tools::SystemTime;
use std::time::Duration;
#[test]
fn test_parse_reaction() {
@@ -518,8 +596,7 @@ Here's my footer -- bob@example.net"
assert_eq!(get_chat_msgs(&bob, bob_msg.chat_id).await?.len(), 2);
let bob_reaction_msg = bob.pop_sent_msg().await;
let alice_reaction_msg = alice.recv_msg_opt(&bob_reaction_msg).await.unwrap();
assert_eq!(alice_reaction_msg.chat_id, DC_CHAT_ID_TRASH);
alice.recv_msg_trash(&bob_reaction_msg).await;
assert_eq!(get_chat_msgs(&alice, chat_alice.id).await?.len(), 2);
let reactions = get_msg_reactions(&alice, alice_msg.sender_msg_id).await?;
@@ -549,6 +626,145 @@ Here's my footer -- bob@example.net"
Ok(())
}
async fn assert_summary(t: &TestContext, expected: &str) {
let chatlist = Chatlist::try_load(t, 0, None, None).await.unwrap();
let summary = chatlist.get_summary(t, 0, None).await.unwrap();
assert_eq!(summary.text, expected);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_reaction_summary() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
alice.set_config(Config::Displayname, Some("ALICE")).await?;
bob.set_config(Config::Displayname, Some("BOB")).await?;
// Alice sends message to Bob
let alice_chat = alice.create_chat(&bob).await;
let alice_msg1 = alice.send_text(alice_chat.id, "Party?").await;
let bob_msg1 = bob.recv_msg(&alice_msg1).await;
// Bob reacts to Alice's message, this is shown in the summaries
SystemTime::shift(Duration::from_secs(10));
bob_msg1.chat_id.accept(&bob).await?;
send_reaction(&bob, bob_msg1.id, "👍").await?;
let bob_send_reaction = bob.pop_sent_msg().await;
alice.recv_msg_trash(&bob_send_reaction).await;
let chatlist = Chatlist::try_load(&bob, 0, None, None).await?;
let summary = chatlist.get_summary(&bob, 0, None).await?;
assert_eq!(summary.text, "You reacted 👍 to \"Party?\"");
assert_eq!(summary.timestamp, bob_msg1.get_timestamp()); // time refers to message, not to reaction
assert_eq!(summary.state, MessageState::InFresh); // state refers to message, not to reaction
assert!(summary.prefix.is_none());
assert!(summary.thumbnail_path.is_none());
assert_summary(&alice, "BOB reacted 👍 to \"Party?\"").await;
// Alice reacts to own message as well
SystemTime::shift(Duration::from_secs(10));
send_reaction(&alice, alice_msg1.sender_msg_id, "🍿").await?;
let alice_send_reaction = alice.pop_sent_msg().await;
bob.recv_msg_opt(&alice_send_reaction).await;
assert_summary(&alice, "You reacted 🍿 to \"Party?\"").await;
assert_summary(&bob, "ALICE reacted 🍿 to \"Party?\"").await;
// Alice sends a newer message, this overwrites reaction summaries
SystemTime::shift(Duration::from_secs(10));
let alice_msg2 = alice.send_text(alice_chat.id, "kewl").await;
bob.recv_msg(&alice_msg2).await;
assert_summary(&alice, "kewl").await;
assert_summary(&bob, "kewl").await;
// Reactions to older messages still overwrite newer messages
SystemTime::shift(Duration::from_secs(10));
send_reaction(&alice, alice_msg1.sender_msg_id, "🤘").await?;
let alice_send_reaction = alice.pop_sent_msg().await;
bob.recv_msg_opt(&alice_send_reaction).await;
assert_summary(&alice, "You reacted 🤘 to \"Party?\"").await;
assert_summary(&bob, "ALICE reacted 🤘 to \"Party?\"").await;
// Retracted reactions remove all summary reactions
SystemTime::shift(Duration::from_secs(10));
send_reaction(&alice, alice_msg1.sender_msg_id, "").await?;
let alice_remove_reaction = alice.pop_sent_msg().await;
bob.recv_msg_opt(&alice_remove_reaction).await;
assert_summary(&alice, "kewl").await;
assert_summary(&bob, "kewl").await;
// Alice adds another reaction and then deletes the message reacted to; this will also delete reaction summary
SystemTime::shift(Duration::from_secs(10));
send_reaction(&alice, alice_msg1.sender_msg_id, "🧹").await?;
assert_summary(&alice, "You reacted 🧹 to \"Party?\"").await;
delete_msgs(&alice, &[alice_msg1.sender_msg_id]).await?; // this will leave a tombstone
assert_summary(&alice, "kewl").await;
housekeeping(&alice).await?; // this will delete the tombstone
assert_summary(&alice, "kewl").await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_reaction_forwarded_summary() -> Result<()> {
let alice = TestContext::new_alice().await;
// Alice adds a message to "Saved Messages"
let self_chat = alice.get_self_chat().await;
let msg_id = send_text_msg(&alice, self_chat.id, "foo".to_string()).await?;
assert_summary(&alice, "foo").await;
// Alice reacts to that message
SystemTime::shift(Duration::from_secs(10));
send_reaction(&alice, msg_id, "🐫").await?;
assert_summary(&alice, "You reacted 🐫 to \"foo\"").await;
let reactions = get_msg_reactions(&alice, msg_id).await?;
assert_eq!(reactions.reactions.len(), 1);
// Alice forwards that message to Bob: Reactions are not forwarded, the message is prefixed by "Forwarded".
let bob_id = Contact::create(&alice, "", "bob@example.net").await?;
let bob_chat_id = ChatId::create_for_contact(&alice, bob_id).await?;
forward_msgs(&alice, &[msg_id], bob_chat_id).await?;
assert_summary(&alice, "Forwarded: foo").await; // forwarded messages are prefixed
let chatlist = Chatlist::try_load(&alice, 0, None, None).await.unwrap();
let forwarded_msg_id = chatlist.get_msg_id(0)?.unwrap();
let reactions = get_msg_reactions(&alice, forwarded_msg_id).await?;
assert!(reactions.reactions.is_empty()); // reactions are not forwarded
// Alice reacts to forwarded message:
// For reaction summary neither original message author nor "Forwarded" prefix is shown
SystemTime::shift(Duration::from_secs(10));
send_reaction(&alice, forwarded_msg_id, "🐳").await?;
assert_summary(&alice, "You reacted 🐳 to \"foo\"").await;
let reactions = get_msg_reactions(&alice, msg_id).await?;
assert_eq!(reactions.reactions.len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_reaction_self_chat_multidevice_summary() -> Result<()> {
let alice0 = TestContext::new_alice().await;
let alice1 = TestContext::new_alice().await;
let chat = alice0.get_self_chat().await;
let msg_id = send_text_msg(&alice0, chat.id, "mom's birthday!".to_string()).await?;
alice1.recv_msg(&alice0.pop_sent_msg().await).await;
SystemTime::shift(Duration::from_secs(10));
send_reaction(&alice0, msg_id, "👆").await?;
let sync = alice0.pop_sent_msg().await;
receive_imf(&alice1, sync.payload().as_bytes(), false).await?;
assert_summary(&alice0, "You reacted 👆 to \"mom's birthday!\"").await;
assert_summary(&alice1, "You reacted 👆 to \"mom's birthday!\"").await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_partial_download_and_reaction() -> Result<()> {
let alice = TestContext::new_alice().await;
@@ -591,7 +807,7 @@ Here's my footer -- bob@example.net"
let bob_reaction_msg = bob.pop_sent_msg().await;
// Alice receives a reaction.
alice.recv_msg_opt(&bob_reaction_msg).await.unwrap();
alice.recv_msg_trash(&bob_reaction_msg).await;
let reactions = get_msg_reactions(&alice, alice_msg_id).await?;
assert_eq!(reactions.to_string(), "👍1");
@@ -643,7 +859,7 @@ Here's my footer -- bob@example.net"
{
send_reaction(&alice2, alice2_msg.id, "👍").await?;
let msg = alice2.pop_sent_msg().await;
alice1.recv_msg(&msg).await;
alice1.recv_msg_trash(&msg).await;
}
// Check that the status is still the same.
@@ -665,8 +881,7 @@ Here's my footer -- bob@example.net"
let alice1_msg = alice1.recv_msg(&alice0.pop_sent_msg().await).await;
send_reaction(&alice0, alice0_msg_id, "👀").await?;
let sync = alice0.pop_sent_msg().await;
receive_imf(&alice1, sync.payload().as_bytes(), false).await?;
alice1.recv_msg_trash(&alice0.pop_sent_msg().await).await;
expect_reactions_changed_event(&alice0, chat_id, alice0_msg_id, ContactId::SELF).await?;
expect_reactions_changed_event(&alice1, alice1_msg.chat_id, alice1_msg.id, ContactId::SELF)

View File

@@ -3,6 +3,9 @@
use std::collections::HashSet;
use anyhow::{Context as _, Result};
use deltachat_contact_tools::{
addr_cmp, may_be_valid_addr, normalize_name, strip_rtlo_characters, ContactAddress,
};
use mailparse::{parse_mail, SingleInfo};
use num_traits::FromPrimitive;
use once_cell::sync::Lazy;
@@ -11,10 +14,8 @@ use regex::Regex;
use crate::aheader::EncryptPreference;
use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus};
use crate::config::Config;
use crate::constants::{Blocked, Chattype, ShowEmails, DC_CHAT_ID_TRASH};
use crate::contact::{
addr_cmp, may_be_valid_addr, normalize_name, Contact, ContactAddress, ContactId, Origin,
};
use crate::constants::{self, Blocked, Chattype, ShowEmails, DC_CHAT_ID_TRASH};
use crate::contact::{Contact, ContactId, Origin};
use crate::context::Context;
use crate::debug_logging::maybe_set_logging_xdc_inner;
use crate::download::DownloadState;
@@ -22,7 +23,6 @@ use crate::ephemeral::{stock_ephemeral_timer_changed, Timer as EphemeralTimer};
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
use crate::imap::{markseen_on_imap_table, GENERATED_PREFIX};
use crate::location;
use crate::log::LogExt;
use crate::message::{
self, rfc724_mid_exists, rfc724_mid_exists_and, Message, MessageState, MessengerMessage, MsgId,
@@ -37,9 +37,8 @@ use crate::simplify;
use crate::sql;
use crate::stock_str;
use crate::sync::Sync::*;
use crate::tools::{
self, buf_compress, extract_grpid_from_rfc724_mid, strip_rtlo_characters, validate_id,
};
use crate::tools::{self, buf_compress, extract_grpid_from_rfc724_mid, validate_id};
use crate::{chatlist_events, location};
use crate::{contact, imap};
/// This is the struct that is returned after receiving one email (aka MIME message).
@@ -476,11 +475,46 @@ pub(crate) async fn receive_imf_inner(
}
if let Some(ref status_update) = mime_parser.webxdc_status_update {
if let Err(err) = context
.receive_status_update(from_id, insert_msg_id, status_update)
.await
let can_info_msg;
let instance = if mime_parser
.parts
.first()
.filter(|part| part.typ == Viewtype::Webxdc)
.is_some()
{
warn!(context, "receive_imf cannot update status: {err:#}.");
can_info_msg = false;
Some(Message::load_from_db(context, insert_msg_id).await?)
} else if let Some(field) = mime_parser.get_header(HeaderDef::InReplyTo) {
if let Some(instance) = get_rfc724_mid_in_list(context, field).await? {
can_info_msg = instance.download_state() == DownloadState::Done;
Some(instance)
} else {
can_info_msg = false;
None
}
} else {
can_info_msg = false;
None
};
if let Some(instance) = instance {
if let Err(err) = context
.receive_status_update(
from_id,
&instance,
received_msg.sort_timestamp,
can_info_msg,
status_update,
)
.await
{
warn!(context, "receive_imf cannot update status: {err:#}.");
}
} else {
warn!(
context,
"Received webxdc update, but cannot assign it to message."
);
}
}
@@ -1077,6 +1111,12 @@ async fn add_parts(
chat_id_blocked = chat.blocked;
}
}
if chat_id.is_none() && is_dc_message == MessengerMessage::Yes {
if let Some(chat) = ChatIdBlocked::lookup_by_contact(context, to_id).await? {
chat_id = Some(chat.id);
chat_id_blocked = chat.blocked;
}
}
// automatically unblock chat when the user sends a message
if chat_id_blocked != Blocked::Not {
@@ -1374,6 +1414,7 @@ async fn add_parts(
&mime_in_reply_to,
orig_chat_id.unwrap_or_default(),
from_id,
sort_timestamp,
Reaction::from(reaction_str.as_str()),
)
.await?;
@@ -1721,11 +1762,6 @@ async fn is_probably_private_reply(
}
}
let is_reaction = mime_parser.parts.iter().any(|part| part.is_reaction);
if is_reaction {
return Ok(false);
}
Ok(true)
}
@@ -1747,7 +1783,19 @@ async fn create_or_lookup_group(
) -> Result<Option<(ChatId, Blocked)>> {
let grpid = if let Some(grpid) = try_getting_grpid(mime_parser) {
grpid
} else if allow_creation {
} else if !allow_creation {
info!(context, "Creating ad-hoc group prevented from caller.");
return Ok(None);
} else if is_partial_download {
// Partial download may be an encrypted message with protected Subject header.
//
// We do not want to create a group with "..." or "Encrypted message" as a subject.
info!(
context,
"Ad-hoc group cannot be created from partial download."
);
return Ok(None);
} else {
let mut member_ids: Vec<ContactId> = to_ids.to_vec();
if !member_ids.contains(&(from_id)) {
member_ids.push(from_id);
@@ -1761,9 +1809,6 @@ async fn create_or_lookup_group(
.context("could not create ad hoc group")?
.map(|chat_id| (chat_id, create_blocked));
return Ok(res);
} else {
info!(context, "Creating ad-hoc group prevented from caller.");
return Ok(None);
};
let mut chat_id;
@@ -1857,6 +1902,8 @@ async fn create_or_lookup_group(
chat::add_to_chat_contacts_table(context, new_chat_id, &members).await?;
context.emit_event(EventType::ChatModified(new_chat_id));
chatlist_events::emit_chatlist_changed(context);
chatlist_events::emit_chatlist_item_changed(context, new_chat_id);
}
if let Some(chat_id) = chat_id {
@@ -1917,18 +1964,24 @@ async fn apply_group_changes(
HashSet::<ContactId>::from_iter(chat::get_chat_contacts(context, chat_id).await?);
let is_from_in_chat =
!chat_contacts.contains(&ContactId::SELF) || chat_contacts.contains(&from_id);
// Reject group membership changes from non-members and old changes.
let allow_member_list_changes = !is_partial_download
&& is_from_in_chat
&& chat_id
.update_timestamp(
context,
Param::MemberListTimestamp,
mime_parser.timestamp_sent,
)
.await?;
let member_list_ts = match !is_partial_download && is_from_in_chat {
true => Some(chat_id.get_member_list_timestamp(context).await?),
false => None,
};
// When we remove a member locally, we shift `MemberListTimestamp` by `TIMESTAMP_SENT_TOLERANCE`
// into the future, so add some more tolerance here to allow remote membership changes as well.
let timestamp_sent_tolerance = constants::TIMESTAMP_SENT_TOLERANCE * 2;
let allow_member_list_changes = member_list_ts
.filter(|t| {
*t <= mime_parser
.timestamp_sent
.saturating_add(timestamp_sent_tolerance)
})
.is_some();
let sync_member_list = member_list_ts
.filter(|t| *t <= mime_parser.timestamp_sent)
.is_some();
// Whether to rebuild the member list from scratch.
let recreate_member_list = {
// Always recreate membership list if SELF has been added. The older versions of DC
@@ -1943,15 +1996,16 @@ async fn apply_group_changes(
.is_none(),
None => false,
}
} && {
if !allow_member_list_changes {
} && (
// Don't allow the timestamp tolerance here for more reliable leaving of groups.
sync_member_list || {
info!(
context,
"Ignoring a try to recreate member list of {chat_id} by {from_id}.",
);
false
}
allow_member_list_changes
};
);
if mime_parser.get_header(HeaderDef::ChatVerified).is_some() {
if let VerifiedEncryption::NotVerified(err) = verified_encryption {
@@ -2064,6 +2118,13 @@ async fn apply_group_changes(
}
if !recreate_member_list {
let mut diff = HashSet::<ContactId>::new();
if sync_member_list {
diff = new_members.difference(&chat_contacts).copied().collect();
} else if let Some(added_id) = added_id {
diff.insert(added_id);
}
new_members.clone_from(&chat_contacts);
// Don't delete any members locally, but instead add absent ones to provide group
// membership consistency for all members:
// - Classical MUA users usually don't intend to remove users from an email thread, so
@@ -2075,9 +2136,6 @@ async fn apply_group_changes(
// will likely recreate the member list from the next received message. The problem
// occurs only if that "somebody" managed to reply earlier. Really, it's a problem for
// big groups with high message rate, but let it be for now.
let mut diff: HashSet<ContactId> =
new_members.difference(&chat_contacts).copied().collect();
new_members = chat_contacts.clone();
new_members.extend(diff.clone());
if let Some(added_id) = added_id {
diff.remove(&added_id);
@@ -2113,6 +2171,17 @@ async fn apply_group_changes(
chat_contacts = new_members;
send_event_chat_modified = true;
}
if sync_member_list {
let mut ts = mime_parser.timestamp_sent;
if recreate_member_list {
// Reject all older membership changes. See `allow_member_list_changes` to know how
// this works.
ts += timestamp_sent_tolerance;
}
chat_id
.update_timestamp(context, Param::MemberListTimestamp, ts)
.await?;
}
}
if let Some(avatar_action) = &mime_parser.group_avatar {
@@ -2148,6 +2217,7 @@ async fn apply_group_changes(
if send_event_chat_modified {
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);
}
Ok((group_changes_msgs, better_msg))
}
@@ -2244,7 +2314,7 @@ fn compute_mailinglist_name(
// and we can detect these lists by a unique `ListId`-suffix.
if listid.ends_with(".list-id.mcsv.net") {
if let Some(display_name) = &mime_parser.from.display_name {
name = display_name.clone();
name.clone_from(display_name);
}
}
@@ -2272,7 +2342,7 @@ fn compute_mailinglist_name(
|| listid.ends_with(".xt.local"))
{
if let Some(display_name) = &mime_parser.from.display_name {
name = display_name.clone();
name.clone_from(display_name);
}
}
@@ -2450,6 +2520,8 @@ async fn create_adhoc_group(
chat::add_to_chat_contacts_table(context, new_chat_id, member_ids).await?;
context.emit_event(EventType::ChatModified(new_chat_id));
chatlist_events::emit_chatlist_changed(context);
chatlist_events::emit_chatlist_item_changed(context, new_chat_id);
Ok(Some(new_chat_id))
}
@@ -2661,8 +2733,6 @@ async fn mark_recipients_as_verified(
/// Returns the last message referenced from `References` header if it is in the database.
///
/// For Delta Chat messages it is the last message in the chat of the sender.
///
/// Note that the returned message may be trashed.
async fn get_previous_message(
context: &Context,
mime_parser: &MimeMessage,
@@ -2670,7 +2740,7 @@ async fn get_previous_message(
if let Some(field) = mime_parser.get_header(HeaderDef::References) {
if let Some(rfc724mid) = parse_message_ids(field).last() {
if let Some((msg_id, _)) = rfc724_mid_exists(context, rfc724mid).await? {
return Ok(Some(Message::load_from_db(context, msg_id).await?));
return Message::load_from_db_optional(context, msg_id).await;
}
}
}

View File

@@ -142,6 +142,35 @@ async fn test_adhoc_group_show_accepted_contact_unknown() {
assert_eq!(chats.len(), 0);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_group_outgoing_show_accepted_contact_unaccepted() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
bob.set_config(
Config::ShowEmails,
Some(&ShowEmails::AcceptedContacts.to_string()),
)
.await?;
tcm.send_recv(alice, bob, "hi").await;
receive_imf(
bob,
b"From: bob@example.net\n\
To: alice@example.org, claire@example.com\n\
Message-ID: <3333@example.net>\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
\n\
hello\n",
false,
)
.await?;
let chats = Chatlist::try_load(bob, 0, None, None).await?;
assert_eq!(chats.len(), 1);
let chat_id = chats.get_chat_id(0)?;
assert_eq!(chat_id.get_msg_cnt(bob).await?, 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_group_show_accepted_contact_known() {
let t = TestContext::new_alice().await;
@@ -3699,6 +3728,7 @@ async fn test_dont_recreate_contacts_on_add_remove() -> Result<()> {
alice.recv_msg(&bob.pop_sent_msg().await).await;
assert_eq!(get_chat_contacts(&alice, alice_chat_id).await?.len(), 3);
SystemTime::shift(Duration::from_secs(3600));
send_text_msg(
&alice,
alice_chat_id,
@@ -3781,17 +3811,18 @@ async fn test_dont_readd_with_normal_msg() -> Result<()> {
remove_contact_from_chat(&bob, bob_chat_id, ContactId::SELF).await?;
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?.len(), 1);
SystemTime::shift(Duration::from_secs(3600));
add_contact_to_chat(
&alice,
alice_chat_id,
Contact::create(&alice, "fiora", "fiora@example.net").await?,
)
.await?;
bob.recv_msg(&alice.pop_sent_msg().await).await;
// Alice didn't receive Bob's leave message, so Bob must readd themselves otherwise other
// members would think Bob is still here while they aren't, and then retry to leave if they
// Alice didn't receive Bob's leave message although a lot of time has
// passed, so Bob must readd themselves otherwise other members would think
// Bob is still here while they aren't. Bob should retry to leave if they
// think that Alice didn't re-add them on purpose (which is possible if Alice uses a classical
// MUA).
assert!(is_contact_in_chat(&bob, bob_chat_id, ContactId::SELF).await?);
@@ -3983,8 +4014,7 @@ async fn test_member_left_does_not_create_chat() -> Result<()> {
// which some members simply deleted and some members left,
// recreating the chat for others.
remove_contact_from_chat(&alice, alice_chat_id, ContactId::SELF).await?;
let bob_chat_id = bob.recv_msg(&alice.pop_sent_msg().await).await.chat_id;
assert!(bob_chat_id.is_trash());
bob.recv_msg_trash(&alice.pop_sent_msg().await).await;
Ok(())
}
@@ -4011,6 +4041,15 @@ async fn test_recreate_member_list_on_missing_add_of_self() -> Result<()> {
// Bob missed the message adding them, but must recreate the member list.
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?.len(), 2);
assert!(is_contact_in_chat(&bob, bob_chat_id, ContactId::SELF).await?);
// But if Bob just left, they mustn't recreate the member list even after missing a message.
bob_chat_id.accept(&bob).await?;
remove_contact_from_chat(&bob, bob_chat_id, ContactId::SELF).await?;
send_text_msg(&alice, alice_chat_id, "3rd message".to_string()).await?;
alice.pop_sent_msg().await;
send_text_msg(&alice, alice_chat_id, "4th message".to_string()).await?;
bob.recv_msg(&alice.pop_sent_msg().await).await;
assert!(!is_contact_in_chat(&bob, bob_chat_id, ContactId::SELF).await?);
Ok(())
}
@@ -4325,8 +4364,8 @@ async fn test_forged_from() -> Result<()> {
.payload
.replace("bob@example.net", "notbob@example.net");
let msg = alice.recv_msg(&sent_msg).await;
assert!(msg.chat_id.is_trash());
let msg = alice.recv_msg_opt(&sent_msg).await;
assert!(msg.is_none());
Ok(())
}

View File

@@ -247,6 +247,14 @@ impl SchedulerState {
}
}
/// Interrupt optional boxes (mvbox, sentbox) loops.
pub(crate) async fn interrupt_oboxes(&self) {
let inner = self.inner.read().await;
if let InnerSchedulerState::Started(ref scheduler) = *inner {
scheduler.interrupt_oboxes();
}
}
pub(crate) async fn interrupt_smtp(&self) {
let inner = self.inner.read().await;
if let InnerSchedulerState::Started(ref scheduler) = *inner {
@@ -565,19 +573,28 @@ async fn fetch_idle(
.context("store_seen_flags_on_imap")?;
}
// Fetch the watched folder.
connection
.fetch_move_delete(ctx, &mut session, &watch_folder, folder_meaning)
.await
.context("fetch_move_delete")?;
if !ctx.should_delete_to_trash().await?
|| ctx
.get_config(Config::ConfiguredTrashFolder)
.await?
.is_some()
{
// Fetch the watched folder.
connection
.fetch_move_delete(ctx, &mut session, &watch_folder, folder_meaning)
.await
.context("fetch_move_delete")?;
// Mark expired messages for deletion. Marked messages will be deleted from the server
// on the next iteration of `fetch_move_delete`. `delete_expired_imap_messages` is not
// called right before `fetch_move_delete` because it is not well optimized and would
// otherwise slow down message fetching.
delete_expired_imap_messages(ctx)
.await
.context("delete_expired_imap_messages")?;
// Mark expired messages for deletion. Marked messages will be deleted from the server
// on the next iteration of `fetch_move_delete`. `delete_expired_imap_messages` is not
// called right before `fetch_move_delete` because it is not well optimized and would
// otherwise slow down message fetching.
delete_expired_imap_messages(ctx)
.await
.context("delete_expired_imap_messages")?;
} else if folder_config == Config::ConfiguredInboxFolder {
ctx.last_full_folder_scan.lock().await.take();
}
// Scan additional folders only after finishing fetching the watched folder.
//
@@ -911,6 +928,12 @@ impl Scheduler {
self.inbox.conn_state.interrupt();
}
fn interrupt_oboxes(&self) {
for b in &self.oboxes {
b.conn_state.interrupt();
}
}
fn interrupt_smtp(&self) {
self.smtp.interrupt();
}
@@ -924,7 +947,7 @@ impl Scheduler {
}
fn interrupt_recently_seen(&self, contact_id: ContactId, timestamp: i64) {
self.recently_seen_loop.interrupt(contact_id, timestamp);
self.recently_seen_loop.try_interrupt(contact_id, timestamp);
}
/// Halt the scheduler.

View File

@@ -9,8 +9,8 @@ use tokio::sync::Mutex;
use crate::events::EventType;
use crate::imap::{scan_folders::get_watched_folder_configs, FolderMeaning};
use crate::quota::{QUOTA_ERROR_THRESHOLD_PERCENTAGE, QUOTA_WARN_THRESHOLD_PERCENTAGE};
use crate::stock_str;
use crate::{context::Context, log::LogExt};
use crate::{stock_str, tools};
use super::InnerSchedulerState;
@@ -413,7 +413,9 @@ impl Context {
// [======67%===== ]
// =============================================================================================
let domain = &tools::EmailAddress::new(&self.get_primary_self_addr().await?)?.domain;
let domain =
&deltachat_contact_tools::EmailAddress::new(&self.get_primary_self_addr().await?)?
.domain;
let storage_on_domain = stock_str::storage_on_domain(self, domain).await;
ret += &format!("<h3>{storage_on_domain}</h3><ul>");
let quota = self.quota.read().await;

View File

@@ -5,6 +5,7 @@ use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use crate::aheader::EncryptPreference;
use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::Blocked;
use crate::contact::{Contact, ContactId, Origin};
@@ -680,6 +681,7 @@ async fn secure_connection_established(
)
.await?;
context.emit_event(EventType::ChatModified(chat_id));
chatlist_events::emit_chatlist_item_changed(context, chat_id);
Ok(())
}
@@ -754,17 +756,18 @@ fn encrypted_and_signed(
#[cfg(test)]
mod tests {
use deltachat_contact_tools::{ContactAddress, EmailAddress};
use super::*;
use crate::chat::remove_contact_from_chat;
use crate::chatlist::Chatlist;
use crate::constants::Chattype;
use crate::contact::ContactAddress;
use crate::imex::{imex, ImexMode};
use crate::receive_imf::receive_imf;
use crate::stock_str::chat_protection_enabled;
use crate::test_utils::get_chat_msg;
use crate::test_utils::{TestContext, TestContextManager};
use crate::tools::{EmailAddress, SystemTime};
use crate::tools::SystemTime;
use std::collections::HashSet;
use std::time::Duration;
@@ -833,7 +836,7 @@ mod tests {
assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some());
// Step 3: Alice receives vc-request, sends vc-auth-required
alice.recv_msg(&sent).await;
alice.recv_msg_trash(&sent).await;
assert_eq!(
Chatlist::try_load(&alice, 0, None, None)
.await
@@ -851,7 +854,7 @@ mod tests {
);
// Step 4: Bob receives vc-auth-required, sends vc-request-with-auth
bob.recv_msg(&sent).await;
bob.recv_msg_trash(&sent).await;
// Check Bob emitted the JoinerProgress event.
let event = bob
@@ -933,7 +936,7 @@ mod tests {
}
// Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm
alice.recv_msg(&sent).await;
alice.recv_msg_trash(&sent).await;
assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), true);
// exactly one one-to-one chat should be visible for both now
@@ -982,7 +985,7 @@ mod tests {
assert_eq!(contact_bob.is_verified(&bob.ctx).await.unwrap(), false);
// Step 7: Bob receives vc-contact-confirm
bob.recv_msg(&sent).await;
bob.recv_msg_trash(&sent).await;
assert_eq!(contact_alice.is_verified(&bob.ctx).await.unwrap(), true);
// Check Bob got the verified message in his 1:1 chat.
@@ -1083,7 +1086,7 @@ mod tests {
assert_eq!(contact_bob.is_verified(&alice.ctx).await?, false);
// Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm
alice.recv_msg(&sent).await;
alice.recv_msg_trash(&sent).await;
assert_eq!(contact_bob.is_verified(&alice.ctx).await?, true);
let sent = alice.pop_sent_msg().await;
@@ -1104,7 +1107,7 @@ mod tests {
assert_eq!(contact_bob.is_verified(&bob.ctx).await?, false);
// Step 7: Bob receives vc-contact-confirm
bob.recv_msg(&sent).await;
bob.recv_msg_trash(&sent).await;
assert_eq!(contact_alice.is_verified(&bob.ctx).await?, true);
Ok(())
@@ -1182,7 +1185,7 @@ mod tests {
assert!(msg.get_header(HeaderDef::SecureJoinGroup).is_none());
// Step 3: Alice receives vg-request, sends vg-auth-required
alice.recv_msg(&sent).await;
alice.recv_msg_trash(&sent).await;
let sent = alice.pop_sent_msg().await;
let msg = bob.parse_msg(&sent).await;
@@ -1193,7 +1196,7 @@ mod tests {
);
// Step 4: Bob receives vg-auth-required, sends vg-request-with-auth
bob.recv_msg(&sent).await;
bob.recv_msg_trash(&sent).await;
let sent = bob.pop_sent_msg().await;
// Check Bob emitted the JoinerProgress event.
@@ -1240,7 +1243,7 @@ mod tests {
assert_eq!(contact_bob.is_verified(&alice.ctx).await?, false);
// Step 5+6: Alice receives vg-request-with-auth, sends vg-member-added
alice.recv_msg(&sent).await;
alice.recv_msg_trash(&sent).await;
assert_eq!(contact_bob.is_verified(&alice.ctx).await?, true);
let sent = alice.pop_sent_msg().await;
@@ -1388,15 +1391,15 @@ First thread."#;
// vc-request
let sent = bob.pop_sent_msg().await;
alice.recv_msg(&sent).await;
alice.recv_msg_trash(&sent).await;
// vc-auth-required
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
bob.recv_msg_trash(&sent).await;
// vc-request-with-auth
let sent = bob.pop_sent_msg().await;
alice.recv_msg(&sent).await;
alice.recv_msg_trash(&sent).await;
// Alice has Bob verified now.
let contact_bob_id =

View File

@@ -1,6 +1,7 @@
//! Migrations module.
use anyhow::{Context as _, Result};
use deltachat_contact_tools::EmailAddress;
use rusqlite::OptionalExtension;
use crate::config::Config;
@@ -10,7 +11,6 @@ use crate::imap;
use crate::message::MsgId;
use crate::provider::get_provider_by_domain;
use crate::sql::Sql;
use crate::tools::EmailAddress;
const DBVERSION: i32 = 68;
const VERSION_CFG: &str = "dbversion";

View File

@@ -429,6 +429,12 @@ pub enum StockMessage {
fallback = "⚠️ It seems you are using Delta Chat on multiple devices that cannot decrypt each other's outgoing messages. To fix this, on the older device use \"Settings / Add Second Device\" and follow the instructions."
))]
CantDecryptOutgoingMsgs = 175,
#[strum(props(fallback = "You reacted %1$s to \"%2$s\""))]
MsgYouReacted = 176,
#[strum(props(fallback = "%1$s reacted %2$s to \"%3$s\""))]
MsgReactedBy = 177,
}
impl StockMessage {
@@ -730,6 +736,27 @@ pub(crate) async fn msg_group_left_local(context: &Context, by_contact: ContactI
}
}
/// Stock string: `You reacted %1$s to "%2$s"` or `%1$s reacted %2$s to "%3$s"`.
pub(crate) async fn msg_reacted(
context: &Context,
by_contact: ContactId,
reaction: &str,
summary: &str,
) -> String {
if by_contact == ContactId::SELF {
translated(context, StockMessage::MsgYouReacted)
.await
.replace1(reaction)
.replace2(summary)
} else {
translated(context, StockMessage::MsgReactedBy)
.await
.replace1(&by_contact.get_stock_name(context).await)
.replace2(reaction)
.replace3(summary)
}
}
/// Stock string: `GIF`.
pub(crate) async fn gif(context: &Context) -> String {
translated(context, StockMessage::Gif).await

View File

@@ -10,7 +10,9 @@ use crate::context::Context;
use crate::message::{Message, MessageState, Viewtype};
use crate::mimeparser::SystemMessage;
use crate::stock_str;
use crate::stock_str::msg_reacted;
use crate::tools::truncate;
use anyhow::Result;
/// Prefix displayed before message and separated by ":" in the chatlist.
#[derive(Debug)]
@@ -57,12 +59,39 @@ pub struct Summary {
impl Summary {
/// Constructs chatlist summary
/// from the provided message, chat and message author contact snapshots.
pub async fn new_with_reaction_details(
context: &Context,
msg: &Message,
chat: &Chat,
contact: Option<&Contact>,
) -> Result<Summary> {
if let Some((reaction_msg, reaction_contact_id, reaction)) = chat
.get_last_reaction_if_newer_than(context, msg.timestamp_sort)
.await?
{
// there is a reaction newer than the latest message, show that.
// sorting and therefore date is still the one of the last message,
// the reaction is is more sth. that overlays temporarily.
let summary = reaction_msg.get_summary_text_without_prefix(context).await;
return Ok(Summary {
prefix: None,
text: msg_reacted(context, reaction_contact_id, &reaction, &summary).await,
timestamp: msg.get_timestamp(), // message timestamp (not reaction) to make timestamps more consistent with chats ordering
state: msg.state, // message state (not reaction) - indicating if it was me sending the last message
thumbnail_path: None,
});
}
Self::new(context, msg, chat, contact).await
}
/// Constructs search result summary
/// from the provided message, chat and message author contact snapshots.
pub async fn new(
context: &Context,
msg: &Message,
chat: &Chat,
contact: Option<&Contact>,
) -> Self {
) -> Result<Summary> {
let prefix = if msg.state == MessageState::OutDraft {
Some(SummaryPrefix::Draft(stock_str::draft(context).await))
} else if msg.from_id == ContactId::SELF {
@@ -102,13 +131,13 @@ impl Summary {
None
};
Self {
Ok(Summary {
prefix,
text,
timestamp: msg.get_timestamp(),
state: msg.state,
thumbnail_path,
}
})
}
/// Returns the [`Summary::text`] attribute truncated to an approximate length.
@@ -120,6 +149,17 @@ impl Summary {
impl Message {
/// Returns a summary text.
async fn get_summary_text(&self, context: &Context) -> String {
let summary = self.get_summary_text_without_prefix(context).await;
if self.is_forwarded() {
format!("{}: {}", stock_str::forwarded(context).await, summary)
} else {
summary
}
}
/// Returns a summary text without "Forwarded:" prefix.
async fn get_summary_text_without_prefix(&self, context: &Context) -> String {
let (emoji, type_name, type_file, append_text);
match self.viewtype {
Viewtype::Image => {
@@ -230,12 +270,6 @@ impl Message {
summary
};
let summary = if self.is_forwarded() {
format!("{}: {}", stock_str::forwarded(context).await, summary)
} else {
summary
};
summary.split_whitespace().collect::<Vec<&str>>().join(" ")
}
}
@@ -246,6 +280,11 @@ mod tests {
use crate::param::Param;
use crate::test_utils as test;
async fn assert_summary_texts(msg: &Message, ctx: &Context, expected: &str) {
assert_eq!(msg.get_summary_text(ctx).await, expected);
assert_eq!(msg.get_summary_text_without_prefix(ctx).await, expected);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_summary_text() {
let d = test::TestContext::new().await;
@@ -255,131 +294,81 @@ mod tests {
let mut msg = Message::new(Viewtype::Text);
msg.set_text(some_text.to_string());
assert_eq!(
msg.get_summary_text(ctx).await,
"bla bla" // for simple text, the type is not added to the summary
);
assert_summary_texts(&msg, ctx, "bla bla").await; // for simple text, the type is not added to the summary
let mut msg = Message::new(Viewtype::Image);
msg.set_file("foo.jpg", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"📷 Image" // file names are not added for images
);
assert_summary_texts(&msg, ctx, "📷 Image").await; // file names are not added for images
let mut msg = Message::new(Viewtype::Image);
msg.set_text(some_text.to_string());
msg.set_file("foo.jpg", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"📷 bla bla" // type is visible by emoji if text is set
);
assert_summary_texts(&msg, ctx, "📷 bla bla").await; // type is visible by emoji if text is set
let mut msg = Message::new(Viewtype::Video);
msg.set_file("foo.mp4", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"🎥 Video" // file names are not added for videos
);
assert_summary_texts(&msg, ctx, "🎥 Video").await; // file names are not added for videos
let mut msg = Message::new(Viewtype::Video);
msg.set_text(some_text.to_string());
msg.set_file("foo.mp4", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"🎥 bla bla" // type is visible by emoji if text is set
);
assert_summary_texts(&msg, ctx, "🎥 bla bla").await; // type is visible by emoji if text is set
let mut msg = Message::new(Viewtype::Gif);
msg.set_file("foo.gif", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"GIF" // file names are not added for GIFs
);
assert_summary_texts(&msg, ctx, "GIF").await; // file names are not added for GIFs
let mut msg = Message::new(Viewtype::Gif);
msg.set_text(some_text.to_string());
msg.set_file("foo.gif", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"GIF \u{2013} bla bla" // file names are not added for GIFs
);
assert_summary_texts(&msg, ctx, "GIF \u{2013} bla bla").await; // file names are not added for GIFs
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file("foo.png", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"Sticker" // file names are not added for stickers
);
assert_summary_texts(&msg, ctx, "Sticker").await; // file names are not added for stickers
let mut msg = Message::new(Viewtype::Voice);
msg.set_file("foo.mp3", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"🎤 Voice message" // file names are not added for voice messages
);
assert_summary_texts(&msg, ctx, "🎤 Voice message").await; // file names are not added for voice messages
let mut msg = Message::new(Viewtype::Voice);
msg.set_text(some_text.clone());
msg.set_file("foo.mp3", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"🎤 bla bla" // `\u{2013}` explicitly checks for "EN DASH"
);
assert_summary_texts(&msg, ctx, "🎤 bla bla").await;
let mut msg = Message::new(Viewtype::Audio);
msg.set_file("foo.mp3", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"🎵 foo.mp3" // file name is added for audio
);
assert_summary_texts(&msg, ctx, "🎵 foo.mp3").await; // file name is added for audio
let mut msg = Message::new(Viewtype::Audio);
msg.set_file("foo.mp3", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"🎵 foo.mp3" // file name is added for audio, empty text is not added
);
assert_summary_texts(&msg, ctx, "🎵 foo.mp3").await; // file name is added for audio, empty text is not added
let mut msg = Message::new(Viewtype::Audio);
msg.set_text(some_text.clone());
msg.set_file("foo.mp3", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"🎵 foo.mp3 \u{2013} bla bla" // file name and text added for audio
);
assert_summary_texts(&msg, ctx, "🎵 foo.mp3 \u{2013} bla bla").await; // file name and text added for audio
let mut msg = Message::new(Viewtype::File);
msg.set_file("foo.bar", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"📎 foo.bar" // file name is added for files
);
assert_summary_texts(&msg, ctx, "📎 foo.bar").await; // file name is added for files
let mut msg = Message::new(Viewtype::File);
msg.set_text(some_text.clone());
msg.set_file("foo.bar", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"📎 foo.bar \u{2013} bla bla" // file name is added for files
);
assert_summary_texts(&msg, ctx, "📎 foo.bar \u{2013} bla bla").await; // file name is added for files
let mut msg = Message::new(Viewtype::VideochatInvitation);
msg.set_text(some_text.clone());
msg.set_file("foo.bar", None);
assert_eq!(
msg.get_summary_text(ctx).await,
"Video chat invitation" // text is not added for videochat invitations
);
assert_summary_texts(&msg, ctx, "Video chat invitation").await; // text is not added for videochat invitations
// Forwarded
let mut msg = Message::new(Viewtype::Text);
msg.set_text(some_text.clone());
msg.param.set_int(Param::Forwarded, 1);
assert_eq!(
msg.get_summary_text(ctx).await,
"Forwarded: bla bla" // for simple text, the type is not added to the summary
);
assert_eq!(msg.get_summary_text(ctx).await, "Forwarded: bla bla"); // for simple text, the type is not added to the summary
assert_eq!(msg.get_summary_text_without_prefix(ctx).await, "bla bla"); // skipping prefix used for reactions summaries
let mut msg = Message::new(Viewtype::File);
msg.set_text(some_text.clone());
@@ -389,14 +378,15 @@ mod tests {
msg.get_summary_text(ctx).await,
"Forwarded: 📎 foo.bar \u{2013} bla bla"
);
assert_eq!(
msg.get_summary_text_without_prefix(ctx).await,
"📎 foo.bar \u{2013} bla bla"
); // skipping prefix used for reactions summaries
let mut msg = Message::new(Viewtype::File);
msg.set_text(some_text.clone());
msg.param.set(Param::File, "foo.bar");
msg.param.set_cmd(SystemMessage::AutocryptSetupMessage);
assert_eq!(
msg.get_summary_text(ctx).await,
"Autocrypt Setup Message" // file name is not added for autocrypt setup messages
);
assert_summary_texts(&msg, ctx, "Autocrypt Setup Message").await; // file name is not added for autocrypt setup messages
}
}

View File

@@ -571,7 +571,7 @@ mod tests {
let sent_msg = alice.pop_sent_msg().await;
let alice2 = TestContext::new_alice().await;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
alice2.recv_msg(&sent_msg).await;
alice2.recv_msg_trash(&sent_msg).await;
assert!(token::exists(&alice2, token::Namespace::Auth, "testtoken").await?);
assert_eq!(Chatlist::try_load(&alice2, 0, None, None).await?.len(), 0);

View File

@@ -13,6 +13,7 @@ use std::time::{Duration, Instant};
use ansi_term::Color;
use async_channel::{self as channel, Receiver, Sender};
use chat::ChatItem;
use deltachat_contact_tools::{ContactAddress, EmailAddress};
use once_cell::sync::Lazy;
use pretty_assertions::assert_eq;
use rand::Rng;
@@ -27,9 +28,10 @@ use crate::chat::{
};
use crate::chatlist::Chatlist;
use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::constants::DC_GCL_NO_SPECIALS;
use crate::constants::{Blocked, Chattype};
use crate::constants::{DC_GCL_NO_SPECIALS, DC_MSG_ID_DAYMARKER};
use crate::contact::{Contact, ContactAddress, ContactId, Modifier, Origin};
use crate::contact::{Contact, ContactId, Modifier, Origin};
use crate::context::Context;
use crate::e2ee::EncryptHelper;
use crate::events::{Event, EventType, Events};
@@ -41,7 +43,6 @@ use crate::pgp::KeyPair;
use crate::receive_imf::receive_imf;
use crate::securejoin::{get_securejoin_qr, join_securejoin};
use crate::stock_str::StockStrings;
use crate::tools::EmailAddress;
#[allow(non_upper_case_globals)]
pub const AVATAR_900x900_BYTES: &[u8] = include_bytes!("../test-data/image/avatar900x900.png");
@@ -179,9 +180,9 @@ impl TestContextManager {
loop {
if let Some(sent) = scanner.pop_sent_msg_opt(Duration::ZERO).await {
scanned.recv_msg(&sent).await;
scanned.recv_msg_opt(&sent).await;
} else if let Some(sent) = scanned.pop_sent_msg_opt(Duration::ZERO).await {
scanner.recv_msg(&sent).await;
scanner.recv_msg_opt(&sent).await;
} else {
break;
}
@@ -537,6 +538,16 @@ impl TestContext {
receive_imf(self, msg.payload().as_bytes(), false)
.await
.unwrap()
.filter(|msg| msg.chat_id != DC_CHAT_ID_TRASH)
}
/// Recevies a message and asserts that it goes to trash chat.
pub async fn recv_msg_trash(&self, msg: &SentMessage<'_>) {
let received = receive_imf(self, msg.payload().as_bytes(), false)
.await
.unwrap()
.unwrap();
assert_eq!(received.chat_id, DC_CHAT_ID_TRASH);
}
/// Gets the most recent message of a chat.
@@ -701,16 +712,16 @@ impl TestContext {
chat_id,
MessageListOptions {
info_only: false,
add_daymarker: true,
add_daymarker: false,
},
)
.await
.unwrap();
let msglist: Vec<MsgId> = msglist
.into_iter()
.map(|x| match x {
ChatItem::Message { msg_id } => msg_id,
ChatItem::DayMarker { .. } => MsgId::new(DC_MSG_ID_DAYMARKER),
.filter_map(|x| match x {
ChatItem::Message { msg_id } => Some(msg_id),
ChatItem::DayMarker { .. } => None,
})
.collect();
@@ -758,23 +769,17 @@ impl TestContext {
let mut lines_out = 0;
for msg_id in msglist {
if msg_id == MsgId::new(DC_MSG_ID_DAYMARKER) {
if msg_id.is_special() {
continue;
}
if lines_out == 0 {
writeln!(res,
"--------------------------------------------------------------------------------"
)
.unwrap();
lines_out += 1
} else if !msg_id.is_special() {
if lines_out == 0 {
writeln!(res,
"--------------------------------------------------------------------------------",
).unwrap();
lines_out += 1
}
let msg = Message::load_from_db(self, msg_id).await.unwrap();
write_msg(self, "", &msg, &mut res).await;
lines_out += 1
}
let msg = Message::load_from_db(self, msg_id).await.unwrap();
write_msg(self, "", &msg, &mut res).await;
}
if lines_out > 0 {
writeln!(
@@ -1019,6 +1024,18 @@ impl EventTracker {
self.get_matching(|evt| matches!(evt, EventType::IncomingMsg { .. }))
.await;
}
/// Clears event queue.
///
/// This spends 1 second instead of using `try_recv`
/// to avoid accidentally leaving an event that
/// was emitted right before calling `clear_events()`.
///
/// Avoid using this function if you can
/// by waiting for specific events you expect to receive.
pub async fn clear_events(&self) {
while let Ok(_ev) = tokio::time::timeout(Duration::from_secs(1), self.recv()).await {}
}
}
/// Gets a specific message from a chat and asserts that the chat has a specific length.
@@ -1062,8 +1079,10 @@ pub(crate) async fn mark_as_verified(this: &TestContext, other: &TestContext) {
0,
);
peerstate.verified_key = peerstate.public_key.clone();
peerstate.verified_key_fingerprint = peerstate.public_key_fingerprint.clone();
peerstate.verified_key.clone_from(&peerstate.public_key);
peerstate
.verified_key_fingerprint
.clone_from(&peerstate.public_key_fingerprint);
peerstate.backward_verified_key_id = Some(this.get_config_i64(Config::KeyId).await.unwrap());
peerstate.save_to_db(&this.sql).await.unwrap();
@@ -1073,7 +1092,8 @@ pub(crate) async fn mark_as_verified(this: &TestContext, other: &TestContext) {
/// alice0's side that implies sending a sync message.
pub(crate) async fn sync(alice0: &TestContext, alice1: &TestContext) {
let sync_msg = alice0.pop_sent_msg().await;
alice1.recv_msg(&sync_msg).await;
let no_msg = alice1.recv_msg_opt(&sync_msg).await;
assert!(no_msg.is_none());
}
/// Pretty-print an event to stdout
@@ -1100,7 +1120,10 @@ fn print_event(event: &Event) {
"Received MSGS_CHANGED(chat_id={chat_id}, msg_id={msg_id})",
))
),
EventType::ContactsChanged(_) => format!("{}", green.paint("Received CONTACTS_CHANGED()")),
EventType::ContactsChanged(contact) => format!(
"{}",
green.paint(format!("Received CONTACTS_CHANGED(contact={contact:?})"))
),
EventType::LocationChanged(contact) => format!(
"{}",
green.paint(format!("Received LOCATION_CHANGED(contact={contact:?})"))

View File

@@ -4,7 +4,6 @@
#![allow(missing_docs)]
use std::borrow::Cow;
use std::fmt;
use std::io::{Cursor, Write};
use std::mem;
use std::path::{Path, PathBuf};
@@ -23,6 +22,7 @@ pub use std::time::SystemTime;
use anyhow::{bail, Context as _, Result};
use base64::Engine as _;
use chrono::{Local, NaiveDateTime, NaiveTime, TimeZone};
use deltachat_contact_tools::{strip_rtlo_characters, EmailAddress};
#[cfg(test)]
pub use deltachat_time::SystemTimeTools as SystemTime;
use futures::{StreamExt, TryStreamExt};
@@ -181,6 +181,7 @@ pub fn get_release_timestamp() -> i64 {
*crate::release::DATE,
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
)
.and_utc()
.timestamp_millis()
/ 1_000
}
@@ -205,7 +206,7 @@ async fn maybe_warn_on_bad_time(context: &Context, now: i64, known_past_timestam
),
)
.await;
if let Some(timestamp) = chrono::NaiveDateTime::from_timestamp_opt(now, 0) {
if let Some(timestamp) = chrono::DateTime::<chrono::Utc>::from_timestamp(now, 0) {
add_device_msg_with_importance(
context,
Some(
@@ -232,7 +233,7 @@ async fn maybe_warn_on_outdated(context: &Context, now: i64, approx_compile_time
if now > approx_compile_time + DC_OUTDATED_WARNING_DAYS * 24 * 60 * 60 {
let mut msg = Message::new(Viewtype::Text);
msg.text = stock_str::update_reminder_msg_body(context).await;
if let Some(timestamp) = chrono::NaiveDateTime::from_timestamp_opt(now, 0) {
if let Some(timestamp) = chrono::DateTime::<chrono::Utc>::from_timestamp(now, 0) {
add_device_msg(
context,
Some(
@@ -535,80 +536,6 @@ pub fn parse_mailto(mailto_url: &str) -> Option<MailTo> {
}
}
///
/// Represents an email address, right now just the `name@domain` portion.
///
/// # Example
///
/// ```
/// use deltachat::tools::EmailAddress;
/// let email = match EmailAddress::new("someone@example.com") {
/// Ok(addr) => addr,
/// Err(e) => panic!("Error parsing address, error was {}", e),
/// };
/// assert_eq!(&email.local, "someone");
/// assert_eq!(&email.domain, "example.com");
/// assert_eq!(email.to_string(), "someone@example.com");
/// ```
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct EmailAddress {
/// Local part of the email address.
pub local: String,
/// Email address domain.
pub domain: String,
}
impl fmt::Display for EmailAddress {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}@{}", self.local, self.domain)
}
}
impl EmailAddress {
/// Performs a dead-simple parse of an email address.
pub fn new(input: &str) -> Result<EmailAddress> {
if input.is_empty() {
bail!("empty string is not valid");
}
let parts: Vec<&str> = input.rsplitn(2, '@').collect();
if input
.chars()
.any(|c| c.is_whitespace() || c == '<' || c == '>')
{
bail!("Email {:?} must not contain whitespaces, '>' or '<'", input);
}
match &parts[..] {
[domain, local] => {
if local.is_empty() {
bail!("empty string is not valid for local part in {:?}", input);
}
if domain.is_empty() {
bail!("missing domain after '@' in {:?}", input);
}
if domain.ends_with('.') {
bail!("Domain {domain:?} should not contain the dot in the end");
}
Ok(EmailAddress {
local: (*local).to_string(),
domain: (*domain).to_string(),
})
}
_ => bail!("Email {:?} must contain '@' character", input),
}
}
}
impl rusqlite::types::ToSql for EmailAddress {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput> {
let val = rusqlite::types::Value::Text(self.to_string());
let out = rusqlite::types::ToSqlOutput::Owned(val);
Ok(out)
}
}
/// Sanitizes user input
/// - strip newlines
/// - strip malicious bidi characters
@@ -752,13 +679,6 @@ pub(crate) fn buf_decompress(buf: &[u8]) -> Result<Vec<u8>> {
Ok(mem::take(decompressor.get_mut()))
}
const RTLO_CHARACTERS: [char; 5] = ['\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}'];
/// This method strips all occurrences of the RTLO Unicode character.
/// [Why is this needed](https://github.com/deltachat/deltachat-core-rust/issues/3479)?
pub(crate) fn strip_rtlo_characters(input_str: &str) -> String {
input_str.replace(|char| RTLO_CHARACTERS.contains(&char), "")
}
#[cfg(test)]
mod tests {
#![allow(clippy::indexing_slicing)]
@@ -1041,40 +961,6 @@ DKIM Results: Passed=true, Works=true, Allow_Keychange=true";
assert!(extract_grpid_from_rfc724_mid(mid.as_str()).is_none());
}
#[test]
fn test_emailaddress_parse() {
assert_eq!(EmailAddress::new("").is_ok(), false);
assert_eq!(
EmailAddress::new("user@domain.tld").unwrap(),
EmailAddress {
local: "user".into(),
domain: "domain.tld".into(),
}
);
assert_eq!(
EmailAddress::new("user@localhost").unwrap(),
EmailAddress {
local: "user".into(),
domain: "localhost".into()
}
);
assert_eq!(EmailAddress::new("uuu").is_ok(), false);
assert_eq!(EmailAddress::new("dd.tt").is_ok(), false);
assert!(EmailAddress::new("tt.dd@uu").is_ok());
assert!(EmailAddress::new("u@d").is_ok());
assert!(EmailAddress::new("u@d.").is_err());
assert!(EmailAddress::new("u@d.t").is_ok());
assert_eq!(
EmailAddress::new("u@d.tt").unwrap(),
EmailAddress {
local: "u".into(),
domain: "d.tt".into(),
}
);
assert!(EmailAddress::new("u@tt").is_ok());
assert_eq!(EmailAddress::new("@d.tt").is_ok(), false);
}
use chrono::NaiveDate;
use proptest::prelude::*;
@@ -1214,6 +1100,7 @@ DKIM Results: Passed=true, Works=true, Allow_Keychange=true";
NaiveDate::from_ymd_opt(2020, 9, 1).unwrap(),
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
)
.and_utc()
.timestamp_millis()
/ 1_000;
@@ -1329,6 +1216,7 @@ DKIM Results: Passed=true, Works=true, Allow_Keychange=true";
NaiveDate::from_ymd_opt(2020, 9, 9).unwrap(),
NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
)
.and_utc()
.timestamp_millis()
/ 1_000;
assert!(get_release_timestamp() <= time());

View File

@@ -19,8 +19,10 @@ use std::path::Path;
use anyhow::{anyhow, bail, ensure, format_err, Context as _, Result};
use deltachat_contact_tools::strip_rtlo_characters;
use deltachat_derive::FromSql;
use lettre_email::PartBuilder;
use rusqlite::OptionalExtension;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::io::AsyncReadExt;
@@ -29,7 +31,6 @@ use crate::chat::{self, Chat};
use crate::constants::Chattype;
use crate::contact::ContactId;
use crate::context::Context;
use crate::download::DownloadState;
use crate::events::EventType;
use crate::message::{Message, MessageState, MsgId, Viewtype};
use crate::mimefactory::wrapped_base64_encode;
@@ -37,7 +38,6 @@ use crate::mimeparser::SystemMessage;
use crate::param::Param;
use crate::param::Params;
use crate::tools::create_id;
use crate::tools::strip_rtlo_characters;
use crate::tools::{create_smeared_timestamp, get_abs_path};
/// The current API version.
@@ -284,14 +284,14 @@ impl Context {
/// writes it to the database and handles events, info-messages, document name and summary.
async fn create_status_update_record(
&self,
instance: &mut Message,
instance: &Message,
status_update_item: StatusUpdateItem,
timestamp: i64,
can_info_msg: bool,
from_id: ContactId,
) -> Result<Option<StatusUpdateSerial>> {
let Some(status_update_serial) = self
.write_status_update_inner(&instance.id, &status_update_item)
.write_status_update_inner(&instance.id, &status_update_item, timestamp)
.await?
else {
return Ok(None);
@@ -328,6 +328,7 @@ impl Context {
let mut param_changed = false;
let mut instance = instance.clone();
if let Some(ref document) = status_update_item.document {
if instance
.param
@@ -372,27 +373,30 @@ impl Context {
&self,
instance_id: &MsgId,
status_update_item: &StatusUpdateItem,
timestamp: i64,
) -> Result<Option<StatusUpdateSerial>> {
let _lock = self.sql.write_lock().await;
let uid = status_update_item.uid.as_deref();
let Some(rowid) = self
.sql
.query_row_optional(
"INSERT INTO msgs_status_updates (msg_id, update_item, uid) VALUES(?, ?, ?)
ON CONFLICT (uid) DO NOTHING
RETURNING id",
(
instance_id,
serde_json::to_string(&status_update_item)?,
uid,
),
|row| {
let id: u32 = row.get(0)?;
Ok(id)
},
)
.await?
else {
let status_update_item = serde_json::to_string(&status_update_item)?;
let trans_fn = |t: &mut rusqlite::Transaction| {
t.execute(
"UPDATE msgs SET timestamp_rcvd=? WHERE id=?",
(timestamp, instance_id),
)?;
let rowid = t
.query_row(
"INSERT INTO msgs_status_updates (msg_id, update_item, uid) VALUES(?, ?, ?)
ON CONFLICT (uid) DO NOTHING
RETURNING id",
(instance_id, status_update_item, uid),
|row| {
let id: u32 = row.get(0)?;
Ok(id)
},
)
.optional()?;
Ok(rowid)
};
let Some(rowid) = self.sql.transaction(trans_fn).await? else {
let uid = uid.unwrap_or("-");
info!(self, "Ignoring duplicate status update with uid={uid}");
return Ok(None);
@@ -442,7 +446,7 @@ impl Context {
mut status_update: StatusUpdateItem,
descr: &str,
) -> Result<()> {
let mut instance = Message::load_from_db(self, instance_msg_id)
let instance = Message::load_from_db(self, instance_msg_id)
.await
.with_context(|| {
format!("Failed to load message {instance_msg_id} from the database")
@@ -470,7 +474,7 @@ impl Context {
status_update.uid = Some(create_id());
let status_update_serial: StatusUpdateSerial = self
.create_status_update_record(
&mut instance,
&instance,
status_update,
create_smeared_timestamp(self),
send_now,
@@ -565,33 +569,22 @@ impl Context {
/// Receives status updates from receive_imf to the database
/// and sends out an event.
///
/// `from_id` is the sender
/// `instance` is a webxdc instance.
///
/// `msg_id` may be an instance (in case there are initial status updates)
/// or a reply to an instance (for all other updates).
/// `from_id` is the sender.
///
/// `timestamp` is the timestamp of the update.
///
/// `json` is an array containing one or more update items as created by send_webxdc_status_update(),
/// the array is parsed using serde, the single payloads are used as is.
pub(crate) async fn receive_status_update(
&self,
from_id: ContactId,
msg_id: MsgId,
instance: &Message,
timestamp: i64,
can_info_msg: bool,
json: &str,
) -> Result<()> {
let msg = Message::load_from_db(self, msg_id).await?;
let (timestamp, mut instance, can_info_msg) = if msg.viewtype == Viewtype::Webxdc {
(msg.timestamp_sort, msg, false)
} else if let Some(parent) = msg.parent(self).await? {
if parent.viewtype == Viewtype::Webxdc {
(msg.timestamp_sort, parent, true)
} else if parent.download_state() != DownloadState::Done {
(msg.timestamp_sort, parent, false)
} else {
bail!("receive_status_update: message is not the child of a webxdc message.")
}
} else {
bail!("receive_status_update: status message has no parent.")
};
let chat_id = instance.chat_id;
if from_id != ContactId::SELF && !chat::is_contact_in_chat(self, chat_id, from_id).await? {
@@ -608,7 +601,7 @@ impl Context {
let updates: StatusUpdates = serde_json::from_str(json)?;
for update_item in updates.updates {
self.create_status_update_record(
&mut instance,
instance,
update_item,
timestamp,
can_info_msg,
@@ -850,6 +843,8 @@ impl Message {
#[cfg(test)]
mod tests {
use std::time::Duration;
use serde_json::json;
use super::*;
@@ -860,8 +855,11 @@ mod tests {
use crate::chatlist::Chatlist;
use crate::config::Config;
use crate::contact::Contact;
use crate::download::DownloadState;
use crate::ephemeral;
use crate::receive_imf::{receive_imf, receive_imf_from_inbox};
use crate::test_utils::TestContext;
use crate::test_utils::{TestContext, TestContextManager};
use crate::tools::{self, SystemTime};
use crate::{message, sql};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -1045,8 +1043,10 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_resend_webxdc_instance_and_info() -> Result<()> {
let mut tcm = TestContextManager::new();
// Alice uses webxdc in a group
let alice = TestContext::new_alice().await;
let alice = tcm.alice().await;
alice.set_config_bool(Config::BccSelf, false).await?;
let alice_grp = create_group_chat(&alice, ProtectionStatus::Unprotected, "grp").await?;
let alice_instance = send_webxdc_instance(&alice, alice_grp).await?;
@@ -1061,7 +1061,7 @@ mod tests {
assert_eq!(alice_grp.get_msg_cnt(&alice).await?, 2);
assert!(alice.get_last_msg_in(alice_grp).await.is_info());
// Alice adds Bob and resend already used webxdc
// Alice adds Bob and resends already used webxdc
add_contact_to_chat(
&alice,
alice_grp,
@@ -1073,7 +1073,7 @@ mod tests {
let sent1 = alice.pop_sent_msg().await;
// Bob received webxdc, legacy info-messages updates are received but not added to the chat
let bob = TestContext::new_bob().await;
let bob = tcm.bob().await;
let bob_instance = bob.recv_msg(&sent1).await;
assert_eq!(bob_instance.viewtype, Viewtype::Webxdc);
assert!(!bob_instance.is_info());
@@ -1196,7 +1196,7 @@ mod tests {
.await?;
let bob_instance = bob.get_last_msg().await;
bob_instance.chat_id.accept(&bob).await?;
bob.recv_msg(&sent2).await;
bob.recv_msg_trash(&sent2).await;
assert_eq!(bob_instance.download_state, DownloadState::Available);
// Bob downloads instance, updates should be assigned correctly
@@ -1231,9 +1231,12 @@ mod tests {
let t = TestContext::new_alice().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?;
let instance = send_webxdc_instance(&t, chat_id).await?;
let now = tools::time();
t.receive_status_update(
ContactId::SELF,
instance.id,
&instance,
now,
true,
r#"{"updates":[{"payload":1}]}"#,
)
.await?;
@@ -1261,9 +1264,12 @@ mod tests {
let t = TestContext::new_alice().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?;
let instance = send_webxdc_instance(&t, chat_id).await?;
let now = tools::time();
t.receive_status_update(
ContactId::SELF,
instance.id,
&instance,
now,
true,
r#"{"updates":[{"payload":1}, {"payload":2}]}"#,
)
.await?;
@@ -1328,7 +1334,7 @@ mod tests {
async fn test_create_status_update_record() -> Result<()> {
let t = TestContext::new_alice().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?;
let mut instance = send_webxdc_instance(&t, chat_id).await?;
let instance = send_webxdc_instance(&t, chat_id).await?;
assert_eq!(
t.get_webxdc_status_updates(instance.id, StatusUpdateSerial(0))
@@ -1338,7 +1344,7 @@ mod tests {
let update_id1 = t
.create_status_update_record(
&mut instance,
&instance,
StatusUpdateItem {
payload: json!({"foo": "bar"}),
info: None,
@@ -1362,7 +1368,7 @@ mod tests {
// Whatever the payload is, update should be ignored just because ID is duplicate.
let update_id1_duplicate = t
.create_status_update_record(
&mut instance,
&instance,
StatusUpdateItem {
payload: json!({"nothing": "this should be ignored"}),
info: None,
@@ -1395,7 +1401,7 @@ mod tests {
let update_id2 = t
.create_status_update_record(
&mut instance,
&instance,
StatusUpdateItem {
payload: json!({"foo2": "bar2"}),
info: None,
@@ -1414,7 +1420,7 @@ mod tests {
r#"[{"payload":{"foo2":"bar2"},"serial":3,"max_serial":3}]"#
);
t.create_status_update_record(
&mut instance,
&instance,
StatusUpdateItem {
payload: Value::Bool(true),
info: None,
@@ -1455,15 +1461,18 @@ mod tests {
let t = TestContext::new_alice().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?;
let instance = send_webxdc_instance(&t, chat_id).await?;
let now = tools::time();
assert!(t
.receive_status_update(ContactId::SELF, instance.id, r#"foo: bar"#)
.receive_status_update(ContactId::SELF, &instance, now, true, r#"foo: bar"#)
.await
.is_err()); // no json
assert!(t
.receive_status_update(
ContactId::SELF,
instance.id,
&instance,
now,
true,
r#"{"updada":[{"payload":{"foo":"bar"}}]}"#
)
.await
@@ -1471,7 +1480,9 @@ mod tests {
assert!(t
.receive_status_update(
ContactId::SELF,
instance.id,
&instance,
now,
true,
r#"{"updates":[{"foo":"bar"}]}"#
)
.await
@@ -1479,7 +1490,9 @@ mod tests {
assert!(t
.receive_status_update(
ContactId::SELF,
instance.id,
&instance,
now,
true,
r#"{"updates":{"payload":{"foo":"bar"}}}"#
)
.await
@@ -1487,7 +1500,9 @@ mod tests {
t.receive_status_update(
ContactId::SELF,
instance.id,
&instance,
now,
true,
r#"{"updates":[{"payload":{"foo":"bar"}, "someTrash": "definitely TrAsH"}]}"#,
)
.await?;
@@ -1499,7 +1514,9 @@ mod tests {
t.receive_status_update(
ContactId::SELF,
instance.id,
&instance,
now,
true,
r#" {"updates": [ {"payload" :42} , {"payload": 23} ] } "#,
)
.await?;
@@ -1513,7 +1530,9 @@ mod tests {
t.receive_status_update(
ContactId::SELF,
instance.id,
&instance,
now,
true,
r#" {"updates": [ {"payload" :"ok", "future_item": "test"} ], "from": "future" } "#,
)
.await?; // ignore members that may be added in the future
@@ -1611,7 +1630,8 @@ mod tests {
assert_eq!(bob_instance.viewtype, Viewtype::Webxdc);
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, 1);
bob.recv_msg(sent2).await;
let bob_received_update = bob.recv_msg_opt(sent2).await;
assert!(bob_received_update.is_none());
expect_status_update_event(&bob, bob_instance.id).await?;
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, 1);
@@ -1624,7 +1644,7 @@ mod tests {
// Alice has a second device and also receives messages there
let alice2 = TestContext::new_alice().await;
alice2.recv_msg(sent1).await;
alice2.recv_msg(sent2).await;
alice2.recv_msg_trash(sent2).await;
let alice2_instance = alice2.get_last_msg().await;
let alice2_chat_id = alice2_instance.chat_id;
assert_eq!(alice2_instance.viewtype, Viewtype::Webxdc);
@@ -2158,8 +2178,8 @@ sth_for_the = "future""#
// Bob receives the updates
let bob_instance = bob.recv_msg(sent_instance).await;
bob.recv_msg(sent_update1).await;
bob.recv_msg(sent_update2).await;
bob.recv_msg_trash(sent_update1).await;
bob.recv_msg_trash(sent_update2).await;
let info = Message::load_from_db(&bob, bob_instance.id)
.await?
.get_webxdc_info(&bob)
@@ -2169,8 +2189,8 @@ sth_for_the = "future""#
// Alice has a second device and also receives the updates there
let alice2 = TestContext::new_alice().await;
let alice2_instance = alice2.recv_msg(sent_instance).await;
alice2.recv_msg(sent_update1).await;
alice2.recv_msg(sent_update2).await;
alice2.recv_msg_trash(sent_update1).await;
alice2.recv_msg_trash(sent_update2).await;
let info = Message::load_from_db(&alice2, alice2_instance.id)
.await?
.get_webxdc_info(&alice2)
@@ -2211,7 +2231,7 @@ sth_for_the = "future""#
// Bob receives the updates
let bob_instance = bob.recv_msg(sent_instance).await;
bob.recv_msg(sent_update1).await;
bob.recv_msg_trash(sent_update1).await;
let info = Message::load_from_db(&bob, bob_instance.id)
.await?
.get_webxdc_info(&bob)
@@ -2263,7 +2283,7 @@ sth_for_the = "future""#
// Bob receives all messages
let bob_instance = bob.recv_msg(sent1).await;
let bob_chat_id = bob_instance.chat_id;
bob.recv_msg(sent2).await;
bob.recv_msg_trash(sent2).await;
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, 2);
let info_msg = bob.get_last_msg().await;
assert!(info_msg.is_info());
@@ -2282,7 +2302,7 @@ sth_for_the = "future""#
let alice2 = TestContext::new_alice().await;
let alice2_instance = alice2.recv_msg(sent1).await;
let alice2_chat_id = alice2_instance.chat_id;
alice2.recv_msg(sent2).await;
alice2.recv_msg_trash(sent2).await;
assert_eq!(alice2_chat_id.get_msg_cnt(&alice2).await?, 2);
let info_msg = alice2.get_last_msg().await;
assert!(info_msg.is_info());
@@ -2332,9 +2352,9 @@ sth_for_the = "future""#
// When Bob receives the messages, they should be cleaned up as well
let bob_instance = bob.recv_msg(sent1).await;
let bob_chat_id = bob_instance.chat_id;
bob.recv_msg(sent2).await;
bob.recv_msg_trash(sent2).await;
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, 2);
bob.recv_msg(sent3).await;
bob.recv_msg_trash(sent3).await;
assert_eq!(bob_chat_id.get_msg_cnt(&bob).await?, 2);
let info_msg = bob.get_last_msg().await;
assert_eq!(info_msg.get_text(), "i2");
@@ -2392,7 +2412,7 @@ sth_for_the = "future""#
// Bob receives instance+update
let bob_instance = bob.recv_msg(sent1).await;
bob.recv_msg(sent2).await;
bob.recv_msg_trash(sent2).await;
assert!(bob_instance.get_showpadlock());
// Bob adds Claire with unknown key, update to Alice+Claire cannot be encrypted
@@ -2522,7 +2542,7 @@ sth_for_the = "future""#
.await?;
bob.flush_status_updates().await?;
let msg = bob.pop_sent_msg().await;
alice.recv_msg(&msg).await;
alice.recv_msg_trash(&msg).await;
alice
.get_webxdc_status_updates(alice_instance.id, StatusUpdateSerial(0))
.await
@@ -2640,7 +2660,8 @@ sth_for_the = "future""#
)
.await?;
alice.flush_status_updates().await?;
bob.recv_msg(&alice.pop_sent_msg().await).await;
let received_update = bob.recv_msg_opt(&alice.pop_sent_msg().await).await;
assert!(received_update.is_none());
assert_eq!(
bob.get_webxdc_status_updates(bob_instance.id, StatusUpdateSerial(0))
@@ -2650,4 +2671,43 @@ sth_for_the = "future""#
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_status_update_vs_delete_device_after() -> Result<()> {
let alice = &TestContext::new_alice().await;
let bob = &TestContext::new_bob().await;
bob.set_config(Config::DeleteDeviceAfter, Some("3600"))
.await?;
let alice_chat = alice.create_chat(bob).await;
let alice_instance = send_webxdc_instance(alice, alice_chat.id).await?;
let bob_instance = bob.recv_msg(&alice.pop_sent_msg().await).await;
SystemTime::shift(Duration::from_secs(1800));
let mut update = Message {
chat_id: alice_chat.id,
viewtype: Viewtype::Text,
text: "I'm an update".to_string(),
hidden: true,
..Default::default()
};
update.param.set_cmd(SystemMessage::WebxdcStatusUpdate);
update
.param
.set(Param::Arg, r#"{"updates":[{"payload":{"foo":"bar"}}]}"#);
update.set_quote(alice, Some(&alice_instance)).await?;
let sent_msg = alice.send_msg(alice_chat.id, &mut update).await;
bob.recv_msg_trash(&sent_msg).await;
assert_eq!(
bob.get_webxdc_status_updates(bob_instance.id, StatusUpdateSerial(0))
.await?,
r#"[{"payload":{"foo":"bar"},"serial":1,"max_serial":1}]"#
);
SystemTime::shift(Duration::from_secs(2700));
ephemeral::delete_expired_messages(bob, tools::time()).await?;
let bob_instance = Message::load_from_db(bob, bob_instance.id).await?;
assert_eq!(bob_instance.chat_id.is_trash(), false);
Ok(())
}
}

View File

@@ -3,6 +3,7 @@ Group#Chat#10: Group chat [3 member(s)]
Msg#10: (Contact#Contact#11): I created a group [FRESH]
Msg#11: (Contact#Contact#11): Member Fiona (fiona@example.net) added by alice@example.org. [FRESH][INFO]
Msg#12: Me (Contact#Contact#Self): You removed member Fiona (fiona@example.net). [INFO] o
Msg#13: info (Contact#Contact#Info): Member Fiona (fiona@example.net) added. [NOTICED][INFO]
Msg#14: (Contact#Contact#11): Welcome, Fiona! [FRESH]
Msg#13: (Contact#Contact#11): Welcome, Fiona! [FRESH]
Msg#14: info (Contact#Contact#Info): Member Fiona (fiona@example.net) added. [NOTICED][INFO]
Msg#15: (Contact#Contact#11): Welcome back, Fiona! [FRESH]
--------------------------------------------------------------------------------

View File

@@ -2,6 +2,7 @@ Group#Chat#10: Group chat [4 member(s)]
--------------------------------------------------------------------------------
Msg#10: (Contact#Contact#10): Hi! I created a group. [FRESH]
Msg#11: Me (Contact#Contact#Self): You left the group. [INFO] o
Msg#12: info (Contact#Contact#Info): Member Me (bob@example.net) added. [NOTICED][INFO]
Msg#13: (Contact#Contact#10): Member claire@example.net added by alice@example.org. [FRESH][INFO]
Msg#12: (Contact#Contact#10): Member claire@example.net added by alice@example.org. [FRESH][INFO]
Msg#13: info (Contact#Contact#Info): Member Me (bob@example.net) added. [NOTICED][INFO]
Msg#14: (Contact#Contact#10): What a silence! [FRESH]
--------------------------------------------------------------------------------