Compare commits

..

2 Commits

Author SHA1 Message Date
iequidoo
fdc2864df4 feat(ffi): Return chat id as data2 for OutgoingCallAccepted, CallEnded events 2026-04-10 10:27:16 -03:00
iequidoo
9cb2077c94 feat: Add EventType::CallMissed and emit it for missed calls (#7840)
Before, only `CallEnded` was emitted for missed calls, or, if a call arrives already being stale,
`IncomingMsg`. Now:
- `CallMissed` is emitted in addition to `CallEnded`.
- `IncomingMsg` is replaced with `CallMissed` for stale calls.
Having only one event type for missed calls should simplify handling them in the apps.

This doesn't emit `CallMissed` for those who aren't allowed to call us. Also, don't emit `CallEnded`
if the caller isn't allowed to call us and the call wasn't accepted, as there's no previous
`IncomingCall` event in this case.
2026-04-10 10:12:48 -03:00
483 changed files with 8732 additions and 5898 deletions

View File

@@ -20,10 +20,10 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.95.0
RUST_VERSION: 1.94.0
# Minimum Supported Rust Version
MSRV: 1.89.0
MSRV: 1.88.0
jobs:
lint_rust:
@@ -40,10 +40,7 @@ jobs:
- run: rustup override set $RUST_VERSION
shell: bash
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Run rustfmt
run: cargo fmt --all -- --check
- name: Run clippy
@@ -62,7 +59,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: EmbarkStudios/cargo-deny-action@a531616d8ce3b9177443e48a1159bc945a099823
- uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979
with:
arguments: --workspace --all-features --locked
command: check
@@ -94,10 +91,7 @@ jobs:
show-progress: false
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Rustdoc
run: cargo doc --document-private-items --no-deps
@@ -140,13 +134,10 @@ jobs:
shell: bash
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Install nextest
uses: taiki-e/install-action@60ae4ce63c7aeb6e96d7f572c1ec7fafbb17ca80
uses: taiki-e/install-action@69e777b377e4ec209ddad9426ae3e0c1008b0ef3
with:
tool: nextest
@@ -177,13 +168,10 @@ jobs:
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Build C library
run: cargo build -p deltachat_ffi --locked
run: cargo build -p deltachat_ffi
- name: Upload C library
uses: actions/upload-artifact@v7
@@ -206,13 +194,10 @@ jobs:
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Build deltachat-rpc-server
run: cargo build -p deltachat-rpc-server --locked
run: cargo build -p deltachat-rpc-server
- name: Upload deltachat-rpc-server
uses: actions/upload-artifact@v7

View File

@@ -34,7 +34,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
@@ -58,7 +58,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
@@ -82,7 +82,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
@@ -106,7 +106,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel
@@ -157,7 +157,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
@@ -181,7 +181,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel
@@ -208,7 +208,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v7
@@ -382,7 +382,7 @@ jobs:
- name: Publish deltachat-rpc-server to PyPI
if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e
publish_npm_package:
name: Build & Publish npm prebuilds and deltachat-rpc-server

View File

@@ -25,10 +25,7 @@ jobs:
with:
node-version: 18.x
- name: Add Rust cache
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: npm install
working-directory: deltachat-jsonrpc/typescript
run: npm install

View File

@@ -25,7 +25,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- run: nix fmt flake.nix -- --check
build:
@@ -63,6 +63,7 @@ jobs:
- deltachat-rpc-server-armv7l-linux-wheel
- deltachat-rpc-server-i686-linux
- deltachat-rpc-server-i686-linux-wheel
- deltachat-rpc-server-source
- deltachat-rpc-server-win32
- deltachat-rpc-server-win32-wheel
- deltachat-rpc-server-win64
@@ -83,7 +84,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- run: nix build .#${{ matrix.installable }}
build-macos:
@@ -104,5 +105,5 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- run: nix build .#${{ matrix.installable }}

View File

@@ -47,4 +47,4 @@ jobs:
name: python-package-distributions
path: dist/
- name: Publish deltachat-rpc-client to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e

View File

@@ -18,7 +18,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary

View File

@@ -41,7 +41,7 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build Python documentation
run: nix build .#python-docs
- name: Upload to py.delta.chat
@@ -63,7 +63,7 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- name: Build C documentation
run: nix build .#docs
- name: Upload to c.delta.chat

View File

@@ -23,4 +23,4 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2

View File

@@ -1,258 +1,5 @@
# Changelog
## [2.51.0] - 2026-05-29
### Features / Changes
- Follow certificate check parameter in autoconfig.
- Immediately remove all encrypted messages from the server in single-device mode.
### Fixes
- Fix syntax error in `only_fetch_mvbox` migration 150 resulting in failure to upgrade for `only_fetch_mvbox` users.
- Do not try to resolve proxy IPv6 addresses in square brackets.
- Do not fail to receive post-message with status updates for deleted webxdc.
- Don't make message `OutDelivered` after successful resending to new broadcast member.
### Build system
- nix: fix downloads from crates.io in nix builds.
### Documentation
- Fix reference in `delete_expired_imap_messages` comment.
### Refactor
- Remove `pre_encrypt_mime_hook`.
- Make `should_delete_all_downloaded_messages` non-async.
### Tests
- Test IPv6 addresses in HTTP(S) proxies.
- Test `bcc_self` in `test_delete_expired_imap_messages`.
- Test encrypted messages in `test_delete_expired_imap_messages`.
### Miscellaneous Tasks
- Bump version to 2.51.0-dev.
- deps: bump zizmorcore/zizmor-action from 0.5.3 to 0.5.6.
- deps: bump taiki-e/install-action from 2.78.1 to 2.79.2.
## [2.50.0] - 2026-05-22
### API-Changes
- Add JSON-RPC APIs for location streaming.
- [**breaking**] Remove unused config `smtp_certificate_checks`.
- Deprecate old server config keys that were replaced by `add_or_update_transport()`.
- [**breaking**] remove `dc_delete_all_locations`.
- [**breaking**] Remove unused `info_only` option when loading a chatlist ([#8171](https://github.com/chatmail/core/pull/8171)).
- [**breaking**] location: avoid repeating module name in function names
- [**breaking**] deltachat-rpc-client: remove deprecated `get_fresh_messages_in_arrival_order()`.
- Remove unused `set_draft_vcard()` JSON-RPC API.
- Remove mostly-unused `sign_unencrypted` config ([#8190](https://github.com/chatmail/core/pull/8190)).
### Features / Changes
- [**breaking**] Remove `mvbox_move` and `only_fetch_mvbox` configs. Transports have `folder` configuration to watch the folder other than `INBOX` as a replacement for `only_fetch_mvbox`. Non-chatmail transports no longer watch mvbox, each transport watches exactly one folder now.
- Add `force_encryption` config to ignore incoming unencrypted messages and enforce encryption for outgoing messages.
- Remove "Delete Messages from Server" (`delete_server_after`) config ([#8240](https://github.com/chatmail/core/pull/8240)).
- Remove `show_emails` config.
- Remove non-sticker heuristics and `force_sticker()`. UIs should make sure not to send images from gallery such as screenshots as stickers.
- Enable PQC (Post-Quantum Cryptography) support for OpenPGP. We do not generate PQC keys yet, this step is needed for forward compatibility.
- Resend the last 10 messages to new broadcast member ([#8151](https://github.com/chatmail/core/pull/8151)).
- Allow TLS connections with invalid certificate if the key is unchanged.
- Add `is_app_sender` and `is_broadcast` contexts for webxdc.
- Increase the resolution-limit `WORSE_AVATAR_SIZE` from 128 to 256.
- Change multiplier to 7/8 when scaling down avatars.
- Add error cause to connectivity view for IMAP errors.
- Remove the largely-unused ability to send multiple reactions to one message ([#8131](https://github.com/chatmail/core/pull/8131)).
- Don't show non-delivery-notfications in broadcast channels ([#8159](https://github.com/chatmail/core/pull/8159)).
- Adapt quota warning to automatic cleanup.
- Remove `Content-Description` and `Content-Disposition` from `multipart/encrypted` parts.
- Log all connection attempt errors instead of the first one.
- Remove workaround for old filtermail (part of chatmail relay) which expected exact number of newlines in OpenPGP messages.
- Remove key fingerprint from `Context.get_info()`.
- Mask local part of email addresses in `used_transport_settings`.
### Fixes
- Trash no-op messages about self being added to groups.
- `decide_chat_assignment`: Log correct `post_msg_exists` value.
- Don't send `Chat-Group-Name*` headers for InBroadcast-s.
- Restart io on transport deletion.
- Never remove primary transport when applying `SyncTransports` message.
- Set Param::GuaranteeE2ee before preparing message blob ([#8090](https://github.com/chatmail/core/pull/8090)).
- `fetch_single_msg()`: Lock `fetch_msgs_mutex` before fetching.
- Set dir to "auto" in body tag when converting plain-text to HTML ([#8227](https://github.com/chatmail/core/pull/8227)).
- Scale up contacts messaged in groups to `IncomingTo`.
- Do not sort prefetched messages by INTERNALDATE.
- Don't resort re-sent message to the bottom ([#8145](https://github.com/chatmail/core/pull/8145)).
- Ensure that message being sent is added to the bottom ([#8027](https://github.com/chatmail/core/pull/8027)).
- Don't receive message if a deletion request was received before ([#8143](https://github.com/chatmail/core/pull/8143)).
- Emit `MsgsChanged`, not `IncomingMsg`, for messages only having special parts ([#8157](https://github.com/chatmail/core/pull/8157)).
- Generate new pre-message `Message-ID` when forwarding.
- use correct dir converting plaintext to HTML ([#8248](https://github.com/chatmail/core/pull/8248)).
- hide connectivity HTML quota if not supported.
- Delete pre-messages on the server for single-device chatmail transports ([#8240](https://github.com/chatmail/core/pull/8240)).
### Build system
- Upgrade rustls-webpki to 0.103.12.
- Remove coredeps `Dockerfile`.
- Increase MSRV to 1.89.
### CI
- Remove Concourse CI pipelines.
- Update Rust to 1.95.0.
- Do not store Rust cache from PRs.
- Set cache-bin to "false" for `swatinem/rust-cache` action.
- Use `--locked` flag with `cargo build`.
- Upgrade `cargo-deny-action` to v2.0.17.
### Documentation
- Update `echobot_no_hooks.py` example.
- Discourage `into()`, `try_into()` and `parse()` ([#8180](https://github.com/chatmail/core/pull/8180)).
- Remove outdated comment about "quota warning" device message.
- Update README.md: Use ci-chatmail instead of nine ([#8238](https://github.com/chatmail/core/pull/8238)).
- spec: remove AEAP section.
### Performance
- Enable `clippy::large_futures` lint.
- Stop sending locations concurrently.
- Set location for all accounts in parallel.
- `is_self_addr()`: Employ the config cache to optimize for `ConfiguredAddr` passed.
### Refactor
- Get rid of `MessageState::{OutPreparing,OutMdnRcvd}` in the db.
- Make HTML parser non-async.
- Replace `HashSet` with `BTreeSet`.
- Rename `EnteredLoginParam::load()` and save() to `load_legacy()` and `save_legacy()`.
- Remove unnecessary async block in `dc_set_location`.
- Remove unused Authentication-Results parsing ([#8172](https://github.com/chatmail/core/pull/8172)).
- Remove mostly-unused function `get_secondary_self_addrs()` ([#8173](https://github.com/chatmail/core/pull/8173)).
- Use `self_fingerprint()` where it makes sense ([#8174](https://github.com/chatmail/core/pull/8174)).
- Split `is_sending_locations_to_chat()` into two functions.
- Use regular functions rather than FromStr impls ([#8178](https://github.com/chatmail/core/pull/8178)).
- Make `Fingerprint` not implement `Display` ([#8177](https://github.com/chatmail/core/pull/8177)).
- Don't temporarily extend `signatures` for signed-only messages.
- Use some more let..else.
- Remove outdated comment.
- Un-nest `handle_edit_delete`.
- Drop support for replacing partial download stubs.
### Tests
- Use `displayname` instead of `show_emails` for config cache test.
- Remove unused test data related to Authentication-Result parsing ([#8175](https://github.com/chatmail/core/pull/8175)).
- `EventTracker::get_matching_opt`: Return the first matching event, not last.
- Set email addresses explicitly for the test accounts.
- Use encrypted messages in more tests.
- Add `TestContext.allow_unencrypted()`.
- Online test for legacy Secure-Join key request.
### Miscellaneous Tasks
- cargo: bump rand from 0.9.2 to 0.9.3.
- deps: bump taiki-e/install-action from 2.64.0 to 2.74.0.
- add exception for RUSTSEC-2026-0097.
- deps: bump swatinem/rust-cache from 2.8.2 to 2.9.1.
- cargo: upgrade rand 0.8.5 to rand 0.8.6.
- deps: bump zizmorcore/zizmor-action from 0.5.2 to 0.5.3.
- deps: bump pypa/gh-action-pypi-publish from 1.13.0 to 1.14.0.
- update provider database.
- cargo: update rustls-webpki to 0.103.13.
- cargo: bump openssl from 0.10.72 to 0.10.78.
- Apply rustmft after the previous commit.
- json-rpc: deprecate `send_sticker` ([#8189](https://github.com/chatmail/core/pull/8189)).
- deps: bump cachix/install-nix-action from 31.9.1 to 31.10.5.
- deps: bump taiki-e/install-action from 2.75.10 to 2.75.19.
- update astral-tokio-tar from 0.6.0 to 0.6.1.
- add exceptions for hickory-proto 0.25.2 in deny.toml.
- cargo: bump blake3 from 1.8.3 to 1.8.5.
- deny.toml: add cpufeatures duplicate dependency exception.
- cargo: bump hyper from 1.8.1 to 1.9.0.
- cargo: bump tokio from 1.50.0 to 1.52.1.
- cargo: bump libc from 0.2.184 to 0.2.186.
- cargo: bump colorutils-rs from 0.7.6 to 0.8.0.
- cargo: bump data-encoding from 2.10.0 to 2.11.0.
- cargo: bump openssl from 0.10.78 to 0.10.79.
- deps: bump taiki-e/install-action from 2.75.19 to 2.77.1.
- deps: bump cachix/install-nix-action from 31.10.5 to 31.10.6.
- allow passing arguments to scripts/clippy.sh.
- clippy::useless-borrows-in-formatting fixes.
- update zerocopy from 0.7.32 to 0.7.35.
- upgrade astral-tokio-tar to 0.6.2 ([#8255](https://github.com/chatmail/core/pull/8255)).
- deps: bump EmbarkStudios/cargo-deny-action from 2.0.17 to 2.0.18.
- cargo: bump openssl from 0.10.79 to 0.10.80.
- deps: bump taiki-e/install-action from 2.77.1 to 2.78.1.
## [2.49.0] - 2026-04-13
### Features / Changes
- Flipped Exif orientations ([#8057](https://github.com/chatmail/core/pull/8057)).
### Fixes
- Determine whether a message is an own message by looking at signature. multiple devices can temporarly have different sets of self addresses, and still need to properly recognize incoming versus outgoing messages. Disclaimer: some LLM tooling was initially involved but i went over everything by hand, and also addressed review comments..
- Mark a message as delivered only after it has been fully sent out ([#8062](https://github.com/chatmail/core/pull/8062)).
- Do not create 1:1 chat on second device when scanning a QR code.
- Do not URL-encode proxy hostnames.
- Assign webxdc updates from post-message to webxdc instance.
- Let search also return hidden contacts if search value is an email address.
- Add missing `extern "C"` to `dc_array_is_independent`.
- Make start messages stick to the top of the chat.
- For bots, wait with emitting IncomingMsg until the Post-Msg arrived ([#8104](https://github.com/chatmail/core/pull/8104)).
- Trash message about group name change from non-member.
### API-Changes
- [**breaking**] remove `dc_msg_force_plaintext`.
- @deltachat/stdio-rpc-server: also export a class.
### CI
- Make sure `-dev` version suffix is not forgotten after release.
### Documentation
- Document that events are broadcasted to all event emitters.
- Fix broken link for i-d "Common PGP/MIME Message Mangling".
### Refactor
- ignore ForcePlaintext in saved messages chat.
- @deltachat/stdio-rpc-server: make `getRPCServerPath` and `startDeltaChat` synchronous.
- @deltachat/stdio-rpc-server: remove `await` from README example.
- less nested `remove_contact_from_chat`.
### Tests
- Add test for `tweak_sort_timestamp()`.
- Test that messages are only marked as delivered after being fully sent out ([#8077](https://github.com/chatmail/core/pull/8077)).
- Fix flaky `test_no_old_msg_is_fresh`: Wait for incoming message before sending outgoing one.
- Use TestContextManager in `test_keep_member_list_if_possibly_nomember`.
### Miscellaneous Tasks
- cargo: bump chrono from 0.4.43 to 0.4.44.
- cargo: bump tracing-subscriber from 0.3.22 to 0.3.23.
- cargo: bump tempfile from 3.26.0 to 3.27.0.
- cargo: bump pin-project from 1.1.10 to 1.1.11.
- cargo: bump tokio from 1.49.0 to 1.50.0.
- cargo: bump libc from 0.2.182 to 0.2.183.
- cargo: bump quote from 1.0.44 to 1.0.45.
- cargo: bump image from 0.25.9 to 0.25.10.
- cargo: bump proptest from 1.10.0 to 1.11.0.
- deps: bump dependabot/fetch-metadata from 2.4.0 to 3.0.0.
- bump version to 2.49.0-dev.
## [2.48.0] - 2026-03-30
### Fixes
@@ -8294,6 +8041,3 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.46.0]: https://github.com/chatmail/core/compare/v2.45.0..v2.46.0
[2.47.0]: https://github.com/chatmail/core/compare/v2.46.0..v2.47.0
[2.48.0]: https://github.com/chatmail/core/compare/v2.47.0..v2.48.0
[2.49.0]: https://github.com/chatmail/core/compare/v2.48.0..v2.49.0
[2.50.0]: https://github.com/chatmail/core/compare/v2.49.0..v2.50.0
[2.51.0]: https://github.com/chatmail/core/compare/v2.50.0..v2.51.0

343
Cargo.lock generated
View File

@@ -36,7 +36,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
"cpufeatures",
]
[[package]]
@@ -136,7 +136,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"cpufeatures",
"password-hash",
"zeroize",
]
@@ -194,9 +194,9 @@ dependencies = [
[[package]]
name = "astral-tokio-tar"
version = "0.6.2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb50a7aae84a03bf55b067832bc376f4961b790c97e64d3eacee97d389b90277"
checksum = "3c23f3af104b40a3430ccb90ed5f7bd877a8dc5c26fc92fde51a22b40890dcf9"
dependencies = [
"filetime",
"futures-core",
@@ -391,28 +391,6 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "aws-lc-rs"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "backon"
version = "1.5.0"
@@ -519,16 +497,16 @@ dependencies = [
[[package]]
name = "blake3"
version = "1.8.5"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq 0.4.2",
"cpufeatures 0.3.0",
"cpufeatures",
]
[[package]]
@@ -572,7 +550,7 @@ dependencies = [
"bolero-kani",
"bolero-libfuzzer",
"cfg-if",
"rand 0.9.4",
"rand 0.9.2",
]
[[package]]
@@ -595,7 +573,7 @@ dependencies = [
"bolero-generator",
"lazy_static",
"pretty-hex",
"rand 0.9.4",
"rand 0.9.2",
"rand_xoshiro",
]
@@ -785,13 +763,10 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.63"
version = "1.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
checksum = "0c3d1b2e905a3a7b00a6141adb0e4c0bb941d11caf55349d863942a1cc44e3c9"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -824,7 +799,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
"cpufeatures",
]
[[package]]
@@ -945,15 +920,6 @@ dependencies = [
"digest",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "cobs"
version = "0.2.3"
@@ -968,9 +934,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorutils-rs"
version = "0.8.0"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69abc9a8ed011e2b7946769f460b9e76e8b659ece9ef4001b9d8bba3489f796d"
checksum = "6e2fc25857fa523662de5cae84225b0e7bfb24a2a3f9ed8802fecf03df7252b1"
dependencies = [
"erydanos",
"half",
@@ -1045,15 +1011,6 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.2.1"
@@ -1259,7 +1216,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
@@ -1335,9 +1292,9 @@ dependencies = [
[[package]]
name = "data-encoding"
version = "2.11.0"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "dbl"
@@ -1350,7 +1307,7 @@ dependencies = [
[[package]]
name = "deltachat"
version = "2.51.0-dev"
version = "2.49.0-dev"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -1403,8 +1360,8 @@ dependencies = [
"proptest",
"qrcodegen",
"quick-xml",
"rand 0.8.6",
"rand 0.9.4",
"rand 0.8.5",
"rand 0.9.2",
"ratelimit",
"regex",
"rusqlite",
@@ -1459,7 +1416,7 @@ dependencies = [
[[package]]
name = "deltachat-jsonrpc"
version = "2.51.0-dev"
version = "2.49.0-dev"
dependencies = [
"anyhow",
"async-channel 2.5.0",
@@ -1480,7 +1437,7 @@ dependencies = [
[[package]]
name = "deltachat-repl"
version = "2.51.0-dev"
version = "2.49.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1496,7 +1453,7 @@ dependencies = [
[[package]]
name = "deltachat-rpc-server"
version = "2.51.0-dev"
version = "2.49.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1525,7 +1482,7 @@ dependencies = [
[[package]]
name = "deltachat_ffi"
version = "2.51.0-dev"
version = "2.49.0-dev"
dependencies = [
"anyhow",
"deltachat",
@@ -1533,7 +1490,7 @@ dependencies = [
"human-panic",
"libc",
"num-traits",
"rand 0.9.4",
"rand 0.9.2",
"serde_json",
"thiserror 2.0.18",
"tokio",
@@ -1710,7 +1667,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -1760,12 +1717,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.18"
@@ -2091,12 +2042,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
@@ -2154,12 +2099,6 @@ dependencies = [
name = "format-flowed"
version = "1.0.0"
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "funty"
version = "2.0.0"
@@ -2490,7 +2429,7 @@ dependencies = [
"idna",
"ipnet",
"once_cell",
"rand 0.9.4",
"rand 0.9.2",
"ring",
"thiserror 2.0.18",
"tinyvec",
@@ -2512,7 +2451,7 @@ dependencies = [
"moka",
"once_cell",
"parking_lot",
"rand 0.9.4",
"rand 0.9.2",
"resolv-conf",
"smallvec",
"thiserror 2.0.18",
@@ -2660,30 +2599,11 @@ dependencies = [
"libm",
]
[[package]]
name = "hybrid-array"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9"
dependencies = [
"typenum",
]
[[package]]
name = "hybrid-array"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d15931895091dea5c47afa5b3c9a01ba634b311919fd4d41388fa0e3d76af"
dependencies = [
"typenum",
"zeroize",
]
[[package]]
name = "hyper"
version = "1.9.0"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
"atomic-waker",
"bytes",
@@ -2696,6 +2616,7 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
"smallvec",
"tokio",
"want",
@@ -2733,7 +2654,7 @@ dependencies = [
"hyper",
"libc",
"pin-project-lite",
"socket2 0.5.9",
"socket2 0.6.0",
"tokio",
"tower-service",
"tracing",
@@ -2931,7 +2852,7 @@ dependencies = [
"hyper",
"hyper-util",
"log",
"rand 0.9.4",
"rand 0.9.2",
"tokio",
"url",
"xmltree",
@@ -3060,7 +2981,7 @@ dependencies = [
"pin-project",
"pkarr",
"portmapper",
"rand 0.8.6",
"rand 0.8.5",
"rcgen",
"reqwest",
"ring",
@@ -3135,7 +3056,7 @@ dependencies = [
"iroh-metrics",
"n0-future",
"postcard",
"rand 0.8.6",
"rand 0.8.5",
"rand_core 0.6.4",
"serde",
"serde-error",
@@ -3198,7 +3119,7 @@ checksum = "929d5d8fa77d5c304d3ee7cae9aede31f13908bd049f9de8c7c0094ad6f7c535"
dependencies = [
"bytes",
"getrandom 0.2.16",
"rand 0.8.6",
"rand 0.8.5",
"ring",
"rustc-hash",
"rustls",
@@ -3251,7 +3172,7 @@ dependencies = [
"pin-project",
"pkarr",
"postcard",
"rand 0.8.6",
"rand 0.8.5",
"reqwest",
"rustls",
"rustls-webpki 0.102.8",
@@ -3286,16 +3207,6 @@ version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.77"
@@ -3335,17 +3246,7 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "kem"
version = "0.3.0-pre.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f"
dependencies = [
"rand_core 0.6.4",
"zeroize",
"cpufeatures",
]
[[package]]
@@ -3359,9 +3260,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.186"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libm"
@@ -3436,9 +3337,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.31"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "loom"
@@ -3552,42 +3453,13 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.0"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.61.1",
]
[[package]]
name = "ml-dsa"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac4a46643af2001eafebcc37031fc459eb72d45057aac5d7a15b00046a2ad6db"
dependencies = [
"const-oid",
"hybrid-array 0.3.1",
"num-traits",
"pkcs8",
"rand_core 0.6.4",
"sha3",
"signature",
"zeroize",
]
[[package]]
name = "ml-kem"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f"
dependencies = [
"hybrid-array 0.2.3",
"kem",
"rand_core 0.6.4",
"sha3",
"zeroize",
"windows-sys 0.52.0",
]
[[package]]
@@ -3880,7 +3752,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -3904,7 +3776,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.6",
"rand 0.8.5",
"serde",
"smallvec",
"zeroize",
@@ -4061,14 +3933,15 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.80"
version = "0.10.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
@@ -4101,9 +3974,9 @@ dependencies = [
[[package]]
name = "openssl-sys"
version = "0.9.116"
version = "0.9.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07"
dependencies = [
"cc",
"libc",
@@ -4325,8 +4198,6 @@ dependencies = [
"k256",
"log",
"md-5",
"ml-dsa",
"ml-kem",
"nom 8.0.0",
"num-bigint-dig",
"num-traits",
@@ -4335,7 +4206,7 @@ dependencies = [
"p256",
"p384",
"p521",
"rand 0.8.6",
"rand 0.8.5",
"regex",
"replace_with",
"ripemd",
@@ -4345,7 +4216,6 @@ dependencies = [
"sha2",
"sha3",
"signature",
"slh-dsa",
"smallvec",
"snafu",
"twofish",
@@ -4365,18 +4235,18 @@ dependencies = [
[[package]]
name = "pin-project"
version = "1.1.13"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
dependencies = [
"proc-macro2",
"quote",
@@ -4542,7 +4412,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
@@ -4554,7 +4424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
@@ -4583,7 +4453,7 @@ dependencies = [
"nested_enum_utils",
"netwatch",
"num_enum",
"rand 0.8.6",
"rand 0.8.5",
"serde",
"smallvec",
"snafu",
@@ -4751,7 +4621,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bitflags 2.11.0",
"num-traits",
"rand 0.9.4",
"rand 0.9.2",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
@@ -4831,7 +4701,7 @@ dependencies = [
"bytes",
"getrandom 0.3.3",
"lru-slab",
"rand 0.9.4",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
@@ -4906,9 +4776,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.8.6"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha 0.3.1",
@@ -4917,9 +4787,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.4"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
@@ -5281,7 +5151,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -5290,12 +5160,11 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.13",
"rustls-webpki 0.103.10",
"subtle",
"zeroize",
]
@@ -5332,11 +5201,10 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -5454,7 +5322,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22c3b0257608d7de4de4c4ea650ccc2e6e3e45e3cd80039fcdee768bcb449253"
dependencies = [
"rand 0.9.4",
"rand 0.9.2",
"substring",
"thiserror 1.0.69",
"url",
@@ -5581,9 +5449,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.150"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
@@ -5640,7 +5508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"digest",
]
@@ -5651,7 +5519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"digest",
]
@@ -5679,7 +5547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures",
"digest",
]
@@ -5713,7 +5581,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project",
"rand 0.9.4",
"rand 0.9.2",
"sendfd",
"serde",
"serde_json",
@@ -5743,7 +5611,7 @@ dependencies = [
"chacha20poly1305",
"hkdf",
"md-5",
"rand 0.9.4",
"rand 0.9.2",
"ring-compat",
"sha1",
]
@@ -5759,9 +5627,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "2.0.1"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
@@ -5812,25 +5680,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "slh-dsa"
version = "0.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2f20f4049197e03db1104a6452f4d9e96665d79f880198dce4a7026ba5f267"
dependencies = [
"const-oid",
"digest",
"hmac",
"hybrid-array 0.3.1",
"pkcs8",
"rand_core 0.6.4",
"sha2",
"sha3",
"signature",
"typenum",
"zerocopy",
]
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -5876,12 +5725,12 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.6.3"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
dependencies = [
"libc",
"windows-sys 0.61.1",
"windows-sys 0.59.0",
]
[[package]]
@@ -5994,7 +5843,7 @@ dependencies = [
"precis-core",
"precis-profiles",
"quoted-string-parser",
"rand 0.9.4",
"rand 0.9.2",
]
[[package]]
@@ -6021,7 +5870,7 @@ dependencies = [
"hex",
"parking_lot",
"pnet_packet",
"rand 0.8.6",
"rand 0.8.5",
"socket2 0.5.9",
"thiserror 1.0.69",
"tokio",
@@ -6147,7 +5996,7 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.61.1",
]
[[package]]
@@ -6295,9 +6144,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
@@ -6305,7 +6154,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.3",
"socket2 0.6.0",
"tokio-macros",
"windows-sys 0.61.1",
]
@@ -6322,9 +6171,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.7.0"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
@@ -6398,7 +6247,7 @@ dependencies = [
"getrandom 0.3.3",
"http 1.1.0",
"httparse",
"rand 0.9.4",
"rand 0.9.2",
"ring",
"rustls-pki-types",
"simdutf8",
@@ -7569,9 +7418,9 @@ checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f"
[[package]]
name = "zerocopy"
version = "0.7.35"
version = "0.7.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be"
dependencies = [
"byteorder",
"zerocopy-derive",
@@ -7579,9 +7428,9 @@ dependencies = [
[[package]]
name = "zerocopy-derive"
version = "0.7.35"
version = "0.7.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
dependencies = [
"proc-macro2",
"quote",

View File

@@ -1,9 +1,9 @@
[package]
name = "deltachat"
version = "2.51.0-dev"
version = "2.49.0-dev"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.89"
rust-version = "1.88"
repository = "https://github.com/chatmail/core"
[profile.dev]
@@ -53,7 +53,7 @@ blake3 = "1.8.2"
brotli = { version = "8", default-features=false, features = ["std"] }
bytes = "1"
chrono = { workspace = true, features = ["alloc", "clock", "std"] }
colorutils-rs = { version = "0.8.0", default-features = false }
colorutils-rs = { version = "0.7.5", default-features = false }
data-encoding = "2.9.0"
escaper = "0.1"
fast-socks5 = "1"
@@ -78,7 +78,7 @@ num-derive = "0.4"
num-traits = { workspace = true }
parking_lot = "0.12.4"
percent-encoding = "2.3"
pgp = { version = "0.19.0", features = ["draft-pqc"], default-features = false }
pgp = { version = "0.19.0", default-features = false }
pin-project = "1"
qrcodegen = "1.7.0"
quick-xml = { version = "0.39", features = ["escape-html"] }
@@ -101,9 +101,9 @@ tagger = "4.3.4"
textwrap = "0.16.2"
thiserror = { workspace = true }
tokio-io-timeout = "1.2.1"
tokio-rustls = { version = "0.26.2", default-features = false, features = ["aws-lc-rs", "tls12"] }
tokio-rustls = { version = "0.26.2", default-features = false }
tokio-stream = { version = "0.1.17", features = ["fs"] }
astral-tokio-tar = { version = "0.6.2", default-features = false }
astral-tokio-tar = { version = "0.6", default-features = false }
tokio-util = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.9"

View File

@@ -161,16 +161,3 @@ are documented.
Follow Rust guidelines for the documentation comments:
<https://rust-lang.github.io/rfcs/1574-more-api-documentation-conventions.html#summary-sentence>
## Do not use `into()`, `try_into()` or `parse()`
For internal types, implementing `From`, `TryFrom` or `FromStr` is discouraged.
Instead, a `new()` function is recommended.
For external types, prefer using `Type::from()`, `Type::try_from()` or `Type::from_str()`
over `into()`, `try_into()` or `parse()`.
Calling `into()`, `try_into()` or `parse()`
creates an indirection,
which is hard to follow for people who are not familiar with Rust,
or who are not using rust-analyzer.

View File

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

View File

@@ -390,9 +390,27 @@ char* dc_get_blobdir (const dc_context_t* context);
/**
* Configure the context. The configuration is handled by key=value pairs as:
*
* - `configured_addr` = Email address in use.
* - `addr` = Email address to use for configuration.
* If dc_configure() fails this is not the email address actually in use.
* Use `configured_addr` to find out the email address actually in use.
* - `configured_addr` = Email address actually in use.
* Unless for testing, do not set this value using dc_set_config().
* Instead, set `addr` and call dc_configure().
* - `mail_server` = IMAP-server, guessed if left out
* - `mail_user` = IMAP-username, guessed if left out
* - `mail_pw` = IMAP-password (always needed)
* - `mail_port` = IMAP-port, guessed if left out
* - `mail_security`= IMAP-socket, one of @ref DC_SOCKET, defaults to #DC_SOCKET_AUTO
* - `send_server` = SMTP-server, guessed if left out
* - `send_user` = SMTP-user, guessed if left out
* - `send_pw` = SMTP-password, guessed if left out
* - `send_port` = SMTP-port, guessed if left out
* - `send_security`= SMTP-socket, one of @ref DC_SOCKET, defaults to #DC_SOCKET_AUTO
* - `server_flags` = IMAP-/SMTP-flags as a combination of @ref DC_LP flags, guessed if left out
* - `proxy_enabled` = Proxy enabled. Disabled by default.
* - `proxy_url` = Proxy URL. May contain multiple URLs separated by newline, but only the first one is used.
* - `imap_certificate_checks` = how to check IMAP certificates, one of the @ref DC_CERTCK flags, defaults to #DC_CERTCK_AUTO (0)
* - `smtp_certificate_checks` = deprecated option, should be set to the same value as `imap_certificate_checks` but ignored by the new core
* - `displayname` = Own name to use when sending messages. MUAs are allowed to spread this way e.g. using CC, defaults to empty
* - `selfstatus` = Own status to display, e.g. in e-mail footers, defaults to empty
* - `selfavatar` = File containing avatar. Will immediately be copied to the
@@ -408,11 +426,31 @@ char* dc_get_blobdir (const dc_context_t* context);
* 1=send a copy of outgoing messages to self (default).
* Sending messages to self is needed for a proper multi-account setup,
* however, on the other hand, may lead to unwanted notifications in non-delta clients.
* - `mvbox_move` = 1=detect chat messages,
* move them to the `DeltaChat` folder,
* and watch the `DeltaChat` folder for updates (default),
* 0=do not move chat-messages
* - `only_fetch_mvbox` = 1=Do not fetch messages from folders other than the
* `DeltaChat` folder. Messages will still be fetched from the
* spam folder.
* 0=watch all folders normally (default)
* - `show_emails` = DC_SHOW_EMAILS_OFF (0)=
* show direct replies to chats only,
* DC_SHOW_EMAILS_ACCEPTED_CONTACTS (1)=
* also show all mails of confirmed contacts,
* DC_SHOW_EMAILS_ALL (2)=
* also show mails of unconfirmed contacts (default).
* - `delete_device_after` = 0=do not delete messages from device automatically (default),
* >=1=seconds, after which messages are deleted automatically from the device.
* Messages in the "saved messages" chat (see dc_chat_is_self_talk()) are skipped.
* Messages are deleted whether they were seen or not, the UI should clearly point that out.
* See also dc_estimate_deletion_cnt().
* - `delete_server_after` = 0=do not delete messages from server automatically (default),
* 1=delete messages directly after receiving from server, mvbox is skipped.
* >1=seconds, after which messages are deleted automatically from the server, mvbox is used as defined.
* "Saved messages" are deleted from the server as well as
* e-mails matching the `show_emails` settings above, the UI should clearly point that out.
* See also dc_estimate_deletion_cnt().
* - `media_quality` = DC_MEDIA_QUALITY_BALANCED (0) =
* good outgoing images/videos/voice quality at reasonable sizes (default)
* DC_MEDIA_QUALITY_WORSE (1)
@@ -482,29 +520,7 @@ char* dc_get_blobdir (const dc_context_t* context);
* 0 = Everybody (except explicitly blocked contacts),
* 1 = Contacts (default, does not include contact requests),
* 2 = Nobody (calls never result in a notification).
* - `force_encryption` = 1 (default) to force encryption, 0 to allow unencrypted messages.
*
* Also, there are configs that are only needed
* if you want to use the deprecated dc_configure() API, such as:
*
* - `addr` = Email address to use for configuration.
* If dc_configure() fails this is not the email address actually in use.
* Use `configured_addr` to find out the email address actually in use.
* - `mail_server` = IMAP-server, guessed if left out
* - `mail_user` = IMAP-username, guessed if left out
* - `mail_pw` = IMAP-password (always needed)
* - `mail_port` = IMAP-port, guessed if left out
* - `mail_security`= IMAP-socket, one of @ref DC_SOCKET, defaults to #DC_SOCKET_AUTO
* - `send_server` = SMTP-server, guessed if left out
* - `send_user` = SMTP-user, guessed if left out
* - `send_pw` = SMTP-password, guessed if left out
* - `send_port` = SMTP-port, guessed if left out
* - `send_security`= SMTP-socket, one of @ref DC_SOCKET, defaults to #DC_SOCKET_AUTO
* - `server_flags` = IMAP-/SMTP-flags as a combination of @ref DC_LP flags, guessed if left out
* - `proxy_enabled` = Proxy enabled. Disabled by default.
* - `proxy_url` = Proxy URL. May contain multiple URLs separated by newline, but only the first one is used.
* - `imap_certificate_checks` = how to check IMAP and SMTP certificates, one of the @ref DC_CERTCK flags, defaults to #DC_CERTCK_AUTO (0)
* If you want to retrieve a value, use dc_get_config().
*
* @memberof dc_context_t
@@ -530,6 +546,9 @@ int dc_set_config (dc_context_t* context, const char*
* an error (no warning as it should be shown to the user) is logged but the attachment is sent anyway.
* - `sys.config_keys` = get a space-separated list of all config-keys available.
* The config-keys are the keys that can be passed to the parameter `key` of this function.
* - `quota_exceeding` = 0: quota is unknown or in normal range;
* >=80: quota is about to exceed, the value is the concrete percentage,
* a device message is added when that happens, however, that value may still be interesting for bots.
*
* @memberof dc_context_t
* @param context The context object. For querying system values, this can be NULL.
@@ -688,12 +707,6 @@ int dc_get_push_state (dc_context_t* context);
/**
* Configure a context.
*
* This way of configuring a context is deprecated,
* and does not allow to configure multiple transports.
* If you can, use the JSON-RPC API (../deltachat-jsonrpc/src/api.rs)
* `add_or_update_transport()`/`addOrUpdateTransport()` instead.
*
* During configuration IO must not be started,
* if needed stop IO using dc_accounts_stop_io() or dc_stop_io() first.
* If the context is already configured,
@@ -1383,6 +1396,7 @@ dc_msg_t* dc_get_draft (dc_context_t* context, uint32_t ch
#define DC_GCM_ADDDAYMARKER 0x01
#define DC_GCM_INFO_ONLY 0x02
/**
@@ -1403,6 +1417,7 @@ dc_msg_t* dc_get_draft (dc_context_t* context, uint32_t ch
* @param flags If set to DC_GCM_ADDDAYMARKER, the marker DC_MSG_ID_DAYMARKER will
* be added before each day (regarding the local timezone). Set this to 0 if you do not want this behaviour.
* The day marker timestamp is the midnight one for the corresponding (following) day in the local timezone.
* If set to DC_GCM_INFO_ONLY, only system messages will be returned, can be combined with DC_GCM_ADDDAYMARKER.
* @param marker1before Deprecated, set this to 0.
* @return Array of message IDs, must be dc_array_unref()'d when no longer used.
*/
@@ -1457,16 +1472,17 @@ dc_chatlist_t* dc_get_similar_chatlist (dc_context_t* context, uint32_t ch
/**
* Estimate the number of messages that will be deleted
* by the dc_set_config()-option `delete_device_after`.
* by the dc_set_config()-options `delete_device_after` or `delete_server_after`.
* This is typically used to show the estimated impact to the user
* before actually enabling deletion of old messages.
*
* @memberof dc_context_t
* @param context The context object as returned from dc_context_new().
* @param from_server Deprecated, pass 0 here
* @param from_server 1=Estimate deletion count for server, 0=Estimate deletion count for device
* @param seconds Count messages older than the given number of seconds.
* @return Number of messages that are older than the given number of seconds.
* Messages in the "Saved Messages" chat are not counted as they will not be deleted automatically.
* This includes e-mails downloaded due to the `show_emails` option.
* Messages in the "saved messages" folder are not counted as they will not be deleted automatically.
*/
int dc_estimate_deletion_cnt (dc_context_t* context, int from_server, int64_t seconds);
@@ -2811,6 +2827,19 @@ int dc_set_location (dc_context_t* context, double latit
dc_array_t* dc_get_locations (dc_context_t* context, uint32_t chat_id, uint32_t contact_id, int64_t timestamp_begin, int64_t timestamp_end);
/**
* Delete all locations on the current device.
* Locations already sent cannot be deleted.
*
* Typically results in the event #DC_EVENT_LOCATION_CHANGED
* with contact_id set to 0.
*
* @memberof dc_context_t
* @param context The context object.
*/
void dc_delete_all_locations (dc_context_t* context);
// misc
/**
@@ -3999,6 +4028,8 @@ int dc_msg_get_viewtype (const dc_msg_t* msg);
* Marked as read on IMAP and MDN may be sent. Use dc_markseen_msgs() to mark messages as being seen.
*
* Outgoing message states:
* - @ref DC_STATE_OUT_PREPARING - For files which need time to be prepared before they can be sent,
* the message enters this state before @ref DC_STATE_OUT_PENDING. Deprecated.
* - @ref DC_STATE_OUT_DRAFT - Message saved as draft using dc_set_draft()
* - @ref DC_STATE_OUT_PENDING - The user has pressed the "send" button but the
* message is not yet sent and is pending in some way. Maybe we're offline (no checkmark).
@@ -4211,8 +4242,6 @@ char* dc_msg_get_webxdc_blob (const dc_msg_t* msg, const char*
* true if the Webxdc should get internet access;
* this is the case i.e. for experimental maps integration.
* - self_addr: address to be used for `window.webxdc.selfAddr` in JS land.
* - is_app_sender: Define if the local user is the one who initially shared the webxdc application in the chat.
* - is_broadcast: Define if the app runs in a broadcasting context.
* - send_update_interval: Milliseconds to wait before calling `sendUpdate()` again since the last call.
* Should be exposed to `webxdc.sendUpdateInterval` in JS land.
* - send_update_max_size: Maximum number of bytes accepted for a serialized update object.
@@ -5583,6 +5612,13 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot);
*/
#define DC_STATE_IN_SEEN 16
/**
* Outgoing message being prepared. See dc_msg_get_state() for details.
*
* @deprecated 2024-12-07
*/
#define DC_STATE_OUT_PREPARING 18
/**
* Outgoing message drafted. See dc_msg_get_state() for details.
*/
@@ -5774,7 +5810,7 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot);
* These constants configure TLS certificate checks for IMAP and SMTP connections.
*
* These constants are set via dc_set_config()
* using key "imap_certificate_checks".
* using keys "imap_certificate_checks" and "smtp_certificate_checks".
*
* @addtogroup DC_CERTCK
* @{
@@ -6387,7 +6423,8 @@ void dc_event_unref(dc_event_t* event);
* Location of one or more contact has changed.
*
* @param data1 (int) contact_id of the contact for which the location has changed.
* If the locations of several contacts have been changed, this parameter is set to 0.
* If the locations of several contacts have been changed,
* e.g. after calling dc_delete_all_locations(), this parameter is set to 0.
* @param data2 0
*/
#define DC_EVENT_LOCATION_CHANGED 2035
@@ -6624,6 +6661,7 @@ void dc_event_unref(dc_event_t* event);
* UI usually only takes action in case call UI was opened before, otherwise the event should be ignored.
*
* @param data1 (int) msg_id ID of the message referring to the call
* @param data2 (int) chat_id ID of the chat which the message belongs to
* @param data2 (char*) accept_call_info, text passed to dc_accept_incoming_call()
*/
#define DC_EVENT_OUTGOING_CALL_ACCEPTED 2570
@@ -6635,9 +6673,23 @@ void dc_event_unref(dc_event_t* event);
* UI usually only takes action in case call UI was opened before, otherwise the event should be ignored.
*
* @param data1 (int) msg_id ID of the message referring to the call
* @param data2 (int) chat_id ID of the chat which the message belongs to
*/
#define DC_EVENT_CALL_ENDED 2580
/**
* An incoming call was missed. Only emitted if the caller is allowed to call us. This happens when:
* - A call timed out (not accepted by us on time).
* - A call was canceled by the caller.
* - A stale call message was received, i.e. it is older than the timeout.
*
* This should trigger a UI notification.
*
* @param data1 (int) msg_id ID of the message referring to the call
* @param data2 (int) chat_id ID of the chat which the message belongs to
*/
#define DC_EVENT_CALL_MISSED 2590
/**
* Transport relay added/deleted or default has changed.
* UI should update the list.
@@ -6658,6 +6710,14 @@ void dc_event_unref(dc_event_t* event);
#define DC_EVENT_DATA2_IS_STRING(e) ((e)==DC_EVENT_CONFIGURE_PROGRESS || (e)==DC_EVENT_IMEX_FILE_WRITTEN || ((e)>=100 && (e)<=499))
/*
* Values for dc_get|set_config("show_emails")
*/
#define DC_SHOW_EMAILS_OFF 0
#define DC_SHOW_EMAILS_ACCEPTED_CONTACTS 1
#define DC_SHOW_EMAILS_ALL 2
/*
* Values for dc_get|set_config("media_quality")
*/
@@ -6992,7 +7052,11 @@ void dc_event_unref(dc_event_t* event);
/// Used in message summary text for notifications and chatlist.
#define DC_STR_FORWARDED 97
/// @deprecated 2026-04-25
/// "Quota exceeding, already %1$s%% used."
///
/// Used as device message text.
///
/// `%1$s` will be replaced by the percentage used
#define DC_STR_QUOTA_EXCEEDING_MSG_BODY 98
/// "Multi Device Synchronization"
@@ -7052,6 +7116,11 @@ void dc_event_unref(dc_event_t* event);
/// `%1$s` will be replaced by a possibly more detailed, typically english, error description.
#define DC_STR_ERROR 112
/// "Not supported by your provider."
///
/// Used in the connectivity view.
#define DC_STR_NOT_SUPPORTED_BY_PROVIDER 113
/// "Messages"
///
/// Used as a subtitle in quota context; can be plural always.

View File

@@ -60,6 +60,7 @@ use self::string::*;
// - finally, this behaviour matches the old core-c API and UIs already depend on it
const DC_GCM_ADDDAYMARKER: u32 = 0x01;
const DC_GCM_INFO_ONLY: u32 = 0x02;
// dc_context_t
@@ -275,7 +276,7 @@ pub unsafe extern "C" fn dc_get_config(
.strdup()
} else {
match config::Config::from_str(&key)
.with_context(|| format!("Invalid key {key:?}"))
.with_context(|| format!("Invalid key {:?}", &key))
.log_err(ctx)
{
Ok(key) => ctx
@@ -555,6 +556,7 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
EventType::IncomingCallAccepted { .. } => 2560,
EventType::OutgoingCallAccepted { .. } => 2570,
EventType::CallEnded { .. } => 2580,
EventType::CallMissed { .. } => 2590,
EventType::TransportsModified => 2600,
#[allow(unreachable_patterns)]
#[cfg(test)]
@@ -624,6 +626,7 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
| EventType::IncomingCall { msg_id, .. }
| EventType::IncomingCallAccepted { msg_id, .. }
| EventType::OutgoingCallAccepted { msg_id, .. }
| EventType::CallMissed { msg_id, .. }
| EventType::CallEnded { 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
@@ -676,10 +679,11 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::ChatModified(_)
| EventType::ChatDeleted { .. }
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::OutgoingCallAccepted { .. }
| EventType::CallEnded { .. }
| EventType::EventChannelOverflow { .. }
| EventType::TransportsModified => 0,
EventType::OutgoingCallAccepted { chat_id, .. }
| EventType::CallEnded { chat_id, .. }
| EventType::CallMissed { chat_id, .. } => chat_id.to_u32() as libc::c_int,
EventType::MsgsChanged { msg_id, .. }
| EventType::ReactionsChanged { msg_id, .. }
| EventType::IncomingReaction { msg_id, .. }
@@ -795,7 +799,9 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
let data2 = accept_call_info.to_c_string().unwrap_or_default();
data2.into_raw()
}
EventType::CallEnded { .. } | EventType::EventChannelOverflow { .. } => ptr::null_mut(),
EventType::CallEnded { .. }
| EventType::CallMissed { .. }
| EventType::EventChannelOverflow { .. } => ptr::null_mut(),
EventType::ConfigureProgress { comment, .. } => {
if let Some(comment) = comment {
comment.to_c_string().unwrap_or_default().into_raw()
@@ -1337,13 +1343,17 @@ pub unsafe extern "C" fn dc_get_chat_msgs(
}
let ctx = &*context;
let info_only = (flags & DC_GCM_INFO_ONLY) != 0;
let add_daymarker = (flags & DC_GCM_ADDDAYMARKER) != 0;
block_on(async move {
Box::into_raw(Box::new(
chat::get_chat_msgs_ex(
ctx,
ChatId::new(chat_id),
MessageListOptions { add_daymarker },
MessageListOptions {
info_only,
add_daymarker,
},
)
.await
.unwrap_or_log_default(ctx, "failed to get chat msgs")
@@ -2541,7 +2551,7 @@ pub unsafe extern "C" fn dc_send_locations_to_chat(
}
let ctx = &*context;
block_on(location::send_to_chat(
block_on(location::send_locations_to_chat(
ctx,
ChatId::new(chat_id),
seconds as i64,
@@ -2561,14 +2571,14 @@ pub unsafe extern "C" fn dc_is_sending_locations_to_chat(
return 0;
}
let ctx = &*context;
if chat_id == 0 {
block_on(location::is_sending(ctx))
.unwrap_or_log_default(ctx, "Failed is_sending_locations()") as libc::c_int
let chat_id = if chat_id == 0 {
None
} else {
block_on(location::is_sending_to_chat(ctx, ChatId::new(chat_id)))
.unwrap_or_log_default(ctx, "Failed is_sending_locations_to_chat()")
as libc::c_int
}
Some(ChatId::new(chat_id))
};
block_on(location::is_sending_locations_to_chat(ctx, chat_id))
.unwrap_or_log_default(ctx, "Failed dc_is_sending_locations_to_chat()") as libc::c_int
}
#[no_mangle]
@@ -2584,9 +2594,12 @@ pub unsafe extern "C" fn dc_set_location(
}
let ctx = &*context;
block_on(location::set(ctx, latitude, longitude, accuracy))
.log_err(ctx)
.unwrap_or_default() as libc::c_int
block_on(async move {
location::set(ctx, latitude, longitude, accuracy)
.await
.log_err(ctx)
.unwrap_or_default()
}) as libc::c_int
}
#[no_mangle]
@@ -2621,6 +2634,23 @@ pub unsafe extern "C" fn dc_get_locations(
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_delete_all_locations(context: *mut dc_context_t) {
if context.is_null() {
eprintln!("ignoring careless call to dc_delete_all_locations()");
return;
}
let ctx = &*context;
block_on(async move {
location::delete_all(ctx)
.await
.context("Failed to delete locations")
.log_err(ctx)
.ok()
});
}
#[no_mangle]
pub unsafe extern "C" fn dc_create_qr_svg(payload: *const libc::c_char) -> *mut libc::c_char {
if payload.is_null() {

View File

@@ -230,6 +230,7 @@ pub enum LotState {
MsgInFresh = 10,
MsgInNoticed = 13,
MsgInSeen = 16,
MsgOutPreparing = 18,
MsgOutDraft = 19,
MsgOutPending = 20,
MsgOutFailed = 24,
@@ -245,6 +246,7 @@ impl From<MessageState> for LotState {
InFresh => LotState::MsgInFresh,
InNoticed => LotState::MsgInNoticed,
InSeen => LotState::MsgInSeen,
OutPreparing => LotState::MsgOutPreparing,
OutDraft => LotState::MsgOutDraft,
OutPending => LotState::MsgOutPending,
OutFailed => LotState::MsgOutFailed,

View File

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

View File

@@ -678,7 +678,7 @@ impl CommandApi {
ChatId::new(chat_id).get_fresh_msg_cnt(&ctx).await
}
/// (deprecated) Gets messages to be processed by the bot and returns their IDs.
/// Gets messages to be processed by the bot and returns their IDs.
///
/// Only messages with database ID higher than `last_msg_id` config value
/// are returned. After processing the messages, the bot should
@@ -686,13 +686,6 @@ impl CommandApi {
/// or manually updating the value to avoid getting already
/// processed messages.
///
/// Deprecated 2026-04: This returns the message's id as soon as the first part arrives,
/// even if it is not fully downloaded yet.
/// The bot needs to wait for the message to be fully downloaded.
/// Since this is usually not the desired behavior,
/// bots should instead use the #DC_EVENT_INCOMING_MSG / [`types::events::EventType::IncomingMsg`]
/// event for getting notified about new messages.
///
/// [`markseen_msgs`]: Self::markseen_msgs
async fn get_next_msgs(&self, account_id: u32) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?;
@@ -705,7 +698,7 @@ impl CommandApi {
Ok(msg_ids)
}
/// (deprecated) Waits for messages to be processed by the bot and returns their IDs.
/// Waits for messages to be processed by the bot and returns their IDs.
///
/// This function is similar to [`get_next_msgs`],
/// but waits for internal new message notification before returning.
@@ -716,13 +709,6 @@ impl CommandApi {
/// To shutdown the bot, stopping I/O can be used to interrupt
/// pending or next `wait_next_msgs` call.
///
/// Deprecated 2026-04: This returns the message's id as soon as the first part arrives,
/// even if it is not fully downloaded yet.
/// The bot needs to wait for the message to be fully downloaded.
/// Since this is usually not the desired behavior,
/// bots should instead use the #DC_EVENT_INCOMING_MSG / [`types::events::EventType::IncomingMsg`]
/// event for getting notified about new messages.
///
/// [`get_next_msgs`]: Self::get_next_msgs
async fn wait_next_msgs(&self, account_id: u32) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?;
@@ -735,19 +721,10 @@ impl CommandApi {
Ok(msg_ids)
}
/// Estimates the number of messages that will be deleted
/// by the `set_config()`-option `delete_device_after`.
///
/// Estimate the number of messages that will be deleted
/// by the set_config()-options `delete_device_after` or `delete_server_after`.
/// This is typically used to show the estimated impact to the user
/// before actually enabling deletion of old messages.
///
/// Messages in the "Saved Messages" chat are not counted as they will not be deleted automatically.
///
/// Parameters:
/// - `from_server`: Deprecated, pass `false` here
/// - `seconds`: Count messages older than the given number of seconds.
///
/// Returns the number of messages that are older than the given number of seconds.
async fn estimate_auto_deletion_count(
&self,
account_id: u32,
@@ -1106,6 +1083,9 @@ impl CommandApi {
/// because the word "channel" already appears a lot in the code,
/// which would make it hard to grep for it.
///
/// After creation, the chat contains no recipients and is in _unpromoted_ state;
/// see [`CommandApi::create_group_chat`] for more information on the unpromoted state.
///
/// Returns the created chat's id.
async fn create_broadcast(&self, account_id: u32, chat_name: String) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
@@ -1372,22 +1352,8 @@ impl CommandApi {
markseen_msgs(&ctx, msg_ids.into_iter().map(MsgId::new).collect()).await
}
/// Get all message IDs belonging to a chat.
/// Returns all messages of a particular chat.
///
/// The list is already sorted and starts with the oldest message.
/// Clients should not try to re-sort the list as this would be an expensive action
/// and would result in inconsistencies between clients.
/// Note that the messages are not necessarily sorted by their ID or by their displayed timestamp;
/// UIs need to handle both the case of descending message IDs
/// and of decreasing timestamps.
///
/// Optionally, 'daymarkers' added to the ID array may help to
/// implement virtual lists.
///
/// Parameters:
///
/// * chat_id The chat ID of which the messages IDs should be queried.
/// * _info_only: Deprecated, pass `false` here.
/// * `add_daymarker` - If `true`, add day markers as `DC_MSG_ID_DAYMARKER` to the result,
/// e.g. [1234, 1237, 9, 1239]. The day marker timestamp is the midnight one for the
/// corresponding (following) day in the local timezone.
@@ -1395,14 +1361,17 @@ impl CommandApi {
&self,
account_id: u32,
chat_id: u32,
_info_only: bool,
info_only: bool,
add_daymarker: bool,
) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?;
let msg = get_chat_msgs_ex(
&ctx,
ChatId::new(chat_id),
MessageListOptions { add_daymarker },
MessageListOptions {
info_only,
add_daymarker,
},
)
.await?;
Ok(msg
@@ -1434,24 +1403,21 @@ impl CommandApi {
}
}
/// Get all messages belonging to a chat.
///
/// Similar to `get_message_ids` / `getMessageIds`,
/// see that function for details.
/// The difference is that this function here returns a list of `MessageListItem`,
/// which is an enum of a message or a daymarker.
async fn get_message_list_items(
&self,
account_id: u32,
chat_id: u32,
_info_only: bool,
info_only: bool,
add_daymarker: bool,
) -> Result<Vec<JsonrpcMessageListItem>> {
let ctx = self.get_context(account_id).await?;
let msg = get_chat_msgs_ex(
&ctx,
ChatId::new(chat_id),
MessageListOptions { add_daymarker },
MessageListOptions {
info_only,
add_daymarker,
},
)
.await?;
Ok(msg
@@ -1888,6 +1854,20 @@ impl CommandApi {
deltachat::contact::make_vcard(&ctx, &contacts).await
}
/// Sets vCard containing the given contacts to the message draft.
async fn set_draft_vcard(
&self,
account_id: u32,
msg_id: u32,
contacts: Vec<u32>,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
let contacts: Vec<_> = contacts.iter().map(|&c| ContactId::new(c)).collect();
let mut msg = Message::load_from_db(&ctx, MsgId::new(msg_id)).await?;
msg.make_vcard(&ctx, &contacts).await?;
msg.get_chat_id().set_draft(&ctx, Some(&mut msg)).await
}
// ---------------------------------------------
// chat
// ---------------------------------------------
@@ -2112,21 +2092,6 @@ impl CommandApi {
// locations
// ---------------------------------------------
/// Sets current location.
///
/// Returns true if location streaming is currently
/// enabled and locations should be updated.
///
/// Location is represented as latitude and longitude in degrees
/// and horizontal accuracy in meters.
async fn set_location(&self, latitude: f64, longitude: f64, accuracy: f64) -> Result<bool> {
self.accounts
.read()
.await
.set_location(latitude, longitude, accuracy)
.await
}
async fn get_locations(
&self,
account_id: u32,
@@ -2149,39 +2114,6 @@ impl CommandApi {
Ok(locations.into_iter().map(|l| l.into()).collect())
}
/// Enables location streaming in chat identified by `chat_id` for `seconds` seconds.
///
/// Pass 0 as the number of seconds to disable location streaming in the chat.
async fn send_locations_to_chat(
&self,
account_id: u32,
chat_id: u32,
seconds: i64,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
let chat_id = ChatId::new(chat_id);
location::send_to_chat(&ctx, chat_id, seconds).await?;
Ok(())
}
/// Returns whether any chat is sending locations.
async fn is_sending_locations(&self, account_id: u32) -> Result<bool> {
let ctx = self.get_context(account_id).await?;
location::is_sending(&ctx).await
}
/// Returns whether `chat_id` is sending locations.
async fn is_sending_locations_to_chat(&self, account_id: u32, chat_id: u32) -> Result<bool> {
let ctx = self.get_context(account_id).await?;
let chat_id = ChatId::new(chat_id);
location::is_sending_to_chat(&ctx, chat_id).await
}
/// Stops sending locations to all chats.
async fn stop_sending_locations(&self) -> Result<()> {
self.accounts.read().await.stop_sending_locations().await
}
// ---------------------------------------------
// webxdc
// ---------------------------------------------
@@ -2413,7 +2345,6 @@ impl CommandApi {
chat::resend_msgs(&ctx, &message_ids).await
}
/// @deprecated as of 2026-04; use `send_msg` with `Viewtype::Sticker` instead.
async fn send_sticker(
&self,
account_id: u32,
@@ -2425,16 +2356,19 @@ impl CommandApi {
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(&ctx, Path::new(&sticker_path), None, None)?;
// JSON-rpc does not need heuristics to turn [Viewtype::Sticker] into [Viewtype::Image]
msg.force_sticker();
let message_id = deltachat::chat::send_msg(&ctx, ChatId::new(chat_id), &mut msg).await?;
Ok(message_id.to_u32())
}
/// Sends a reaction to message.
/// Send a reaction to message.
///
/// A reaction is a string that represents an emoji.
/// You can call this function again to change the emoji;
/// the last sent reaction overrides all previously sent reactions.
/// It is possible to remove the reaction by sending an empty string.
/// 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.
async fn send_reaction(
&self,
account_id: u32,

View File

@@ -463,6 +463,14 @@ pub enum EventType {
chat_id: u32,
},
/// Call missed.
CallMissed {
/// ID of the info message referring to the call.
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
},
/// One or more transports has changed.
///
/// UI should update the list.
@@ -658,6 +666,10 @@ impl From<CoreEventType> for EventType {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
},
CoreEventType::CallMissed { msg_id, chat_id } => CallMissed {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
},
CoreEventType::TransportsModified => TransportsModified,
#[allow(unreachable_patterns)]

View File

@@ -33,12 +33,6 @@ pub struct EnteredLoginParam {
/// Imap server port.
pub imap_port: Option<u16>,
/// IMAP server folder.
///
/// Defaults to "INBOX" if not set.
/// Should not be an empty string.
pub imap_folder: Option<String>,
/// Imap socket security.
pub imap_security: Option<Socket>,
@@ -91,7 +85,6 @@ impl From<dc::EnteredLoginParam> for EnteredLoginParam {
password: param.imap.password,
imap_server: param.imap.server.into_option(),
imap_port: param.imap.port.into_option(),
imap_folder: param.imap.folder.into_option(),
imap_security: imap_security.into_option(),
imap_user: param.imap.user.into_option(),
smtp_server: param.smtp.server.into_option(),
@@ -111,15 +104,14 @@ impl TryFrom<EnteredLoginParam> for dc::EnteredLoginParam {
fn try_from(param: EnteredLoginParam) -> Result<Self> {
Ok(Self {
addr: param.addr,
imap: dc::EnteredImapLoginParam {
imap: dc::EnteredServerLoginParam {
server: param.imap_server.unwrap_or_default(),
port: param.imap_port.unwrap_or_default(),
folder: param.imap_folder.unwrap_or_default(),
security: param.imap_security.unwrap_or_default().into(),
user: param.imap_user.unwrap_or_default(),
password: param.password,
},
smtp: dc::EnteredSmtpLoginParam {
smtp: dc::EnteredServerLoginParam {
server: param.smtp_server.unwrap_or_default(),
port: param.smtp_port.unwrap_or_default(),
security: param.smtp_security.unwrap_or_default().into(),

View File

@@ -287,6 +287,8 @@ pub enum MessageViewtype {
Gif,
/// Message containing a sticker, similar to image.
/// NB: When sending, the message viewtype may be changed to `Image` by some heuristics like
/// checking for transparent pixels. Use `Message::force_sticker()` to disable them.
///
/// If possible, the ui should display the image without borders in a transparent way.
/// A click on a sticker will offer to install the sticker set in some future.

View File

@@ -238,7 +238,7 @@ impl From<Qr> for QrObject {
is_v3,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::AskVerifyContact {
contact_id,
fingerprint,
@@ -257,7 +257,7 @@ impl From<Qr> for QrObject {
is_v3,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::AskVerifyGroup {
grpname,
grpid,
@@ -278,7 +278,7 @@ impl From<Qr> for QrObject {
is_v3,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::AskJoinBroadcast {
name,
grpid,
@@ -321,7 +321,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::WithdrawVerifyContact {
contact_id,
fingerprint,
@@ -338,7 +338,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::WithdrawVerifyGroup {
grpname,
grpid,
@@ -357,7 +357,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::WithdrawJoinBroadcast {
name,
grpid,
@@ -374,7 +374,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::ReviveVerifyContact {
contact_id,
fingerprint,
@@ -391,7 +391,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::ReviveVerifyGroup {
grpname,
grpid,
@@ -410,7 +410,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::ReviveJoinBroadcast {
name,
grpid,

View File

@@ -24,8 +24,6 @@ pub struct JsonrpcReaction {
#[serde(rename = "Reactions", rename_all = "camelCase")]
pub struct JsonrpcReactions {
/// Map from a contact to it's reaction to message.
/// There is only a single reaction per contact,
/// but this contains a list of reactions for historical reasons.
reactions_by_contact: BTreeMap<u32, Vec<String>>,
/// Unique reactions and their count, sorted in descending order.
reactions: Vec<JsonrpcReaction>,
@@ -33,16 +31,27 @@ pub struct JsonrpcReactions {
impl From<Reactions> for JsonrpcReactions {
fn from(reactions: Reactions) -> Self {
let reactions_by_contact: BTreeMap<u32, Vec<String>> = reactions
.iter()
.map(|(key, value)| (key.to_u32(), vec![value.as_str().to_string()]))
.collect();
let self_reaction = reactions_by_contact.get(&ContactId::SELF.to_u32());
let mut reactions_by_contact: BTreeMap<u32, Vec<String>> = BTreeMap::new();
for contact_id in reactions.contacts() {
let reaction = reactions.get(contact_id);
if reaction.is_empty() {
continue;
}
let emojis: Vec<String> = reaction
.emojis()
.into_iter()
.map(|emoji| emoji.to_owned())
.collect();
reactions_by_contact.insert(contact_id.to_u32(), emojis.clone());
}
let self_reactions = reactions_by_contact.get(&ContactId::SELF.to_u32());
let mut reactions_v = Vec::new();
for (emoji, count) in reactions.emoji_sorted_by_frequency() {
let is_from_self = if let Some(self_reaction) = self_reaction {
self_reaction.contains(&emoji)
let is_from_self = if let Some(self_reactions) = self_reactions {
self_reactions.contains(&emoji)
} else {
false
};

View File

@@ -37,10 +37,6 @@ pub struct WebxdcMessageInfo {
internet_access: bool,
/// Address to be used for `window.webxdc.selfAddr` in JS land.
self_addr: String,
/// Define if the local user is the one who initially shared the webxdc application in the chat.
is_app_sender: bool,
/// Define if the app runs in a broadcasting context.
is_broadcast: bool,
/// Milliseconds to wait before calling `sendUpdate()` again since the last call.
/// Should be exposed to `window.sendUpdateInterval` in JS land.
send_update_interval: usize,
@@ -64,8 +60,6 @@ impl WebxdcMessageInfo {
request_integration: _,
internet_access,
self_addr,
is_app_sender,
is_broadcast,
send_update_interval,
send_update_max_size,
} = message.get_webxdc_info(context).await?;
@@ -78,8 +72,6 @@ impl WebxdcMessageInfo {
source_code_url: maybe_empty_string_to_option(source_code_url),
internet_access,
self_addr,
is_app_sender,
is_broadcast,
send_update_interval,
send_update_max_size,
})

View File

@@ -85,7 +85,7 @@ mod tests {
assert_eq!(result, response.to_owned());
}
{
let request = r#"{"jsonrpc":"2.0","method":"batch_set_config","id":2,"params":[1,{"addr":"","mail_user":"","mail_pw":"","mail_server":"","mail_port":"","mail_security":"","imap_certificate_checks":"","send_user":"","send_pw":"","send_server":"","send_port":"","send_security":""}]}"#;
let request = r#"{"jsonrpc":"2.0","method":"batch_set_config","id":2,"params":[1,{"addr":"","mail_user":"","mail_pw":"","mail_server":"","mail_port":"","mail_security":"","imap_certificate_checks":"","send_user":"","send_pw":"","send_server":"","send_port":"","send_security":"","smtp_certificate_checks":""}]}"#;
let response = r#"{"jsonrpc":"2.0","id":2,"result":null}"#;
session.handle_incoming(request).await;
let result = receiver.recv().await?;

View File

@@ -54,5 +54,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.51.0-dev"
"version": "2.49.0-dev"
}

View File

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

View File

@@ -122,7 +122,7 @@ async fn poke_spec(context: &Context, spec: Option<&str>) -> bool {
let name_f = entry.file_name();
let name = name_f.to_string_lossy();
if name.ends_with(".eml") {
let path_plus_name = format!("{real_spec}/{name}");
let path_plus_name = format!("{}/{}", &real_spec, name);
println!("Import: {path_plus_name}");
if poke_eml_file(context, Path::new(&path_plus_name))
.await
@@ -133,11 +133,11 @@ async fn poke_spec(context: &Context, spec: Option<&str>) -> bool {
}
}
} else {
eprintln!("Import: Cannot open directory {real_spec:?}.");
eprintln!("Import: Cannot open directory \"{}\".", &real_spec);
return false;
}
}
println!("Import: {read_cnt} items read from {real_spec:?}.");
println!("Import: {} items read from \"{}\".", read_cnt, &real_spec);
if read_cnt > 0 {
context.emit_msgs_changed_without_ids();
}
@@ -179,7 +179,7 @@ async fn log_msg(context: &Context, prefix: impl AsRef<str>, msg: &Message) {
msg.get_id(),
if msg.get_showpadlock() { "🔒" } else { "" },
if msg.has_location() { "📍" } else { "" },
contact_name,
&contact_name,
contact_id,
msgtext,
if msg.has_html() { "[HAS-HTML]" } else { "" },
@@ -221,7 +221,7 @@ async fn log_msg(context: &Context, prefix: impl AsRef<str>, msg: &Message) {
},
statestr,
downloadstate,
temp2,
&temp2,
);
}
@@ -345,6 +345,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
chatinfo\n\
sendlocations <seconds>\n\
setlocation <lat> <lng>\n\
dellocations\n\
getlocations [<contact-id>]\n\
send <text>\n\
send-sync <text>\n\
@@ -561,7 +562,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
.map_or_else(String::new, |prefix| format!("{prefix}: ")),
summary.text,
statestr,
timestr,
&timestr,
if chat.is_sending_locations() {
"📍"
} else {
@@ -573,7 +574,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
);
}
}
if location::is_sending(&context).await? {
if location::is_sending_locations_to_chat(&context, None).await? {
println!("Location streaming enabled.");
}
println!("{cnt} chats");
@@ -622,6 +623,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
&context,
sel_chat.get_id(),
chat::MessageListOptions {
info_only: false,
add_daymarker: true,
},
)
@@ -780,7 +782,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!(
"Location streaming: {}",
location::is_sending_to_chat(&context, sel_chat.as_ref().unwrap().get_id()).await?,
location::is_sending_locations_to_chat(
&context,
Some(sel_chat.as_ref().unwrap().get_id())
)
.await?,
);
}
"getlocations" => {
@@ -820,7 +826,12 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
ensure!(!arg1.is_empty(), "No timeout given.");
let seconds = arg1.parse()?;
location::send_to_chat(&context, sel_chat.as_ref().unwrap().get_id(), seconds).await?;
location::send_locations_to_chat(
&context,
sel_chat.as_ref().unwrap().get_id(),
seconds,
)
.await?;
println!(
"Locations will be sent to Chat#{} for {} seconds. Use 'setlocation <lat> <lng>' to play around.",
sel_chat.as_ref().unwrap().get_id(),
@@ -842,6 +853,9 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Success, streaming can be stopped.");
}
}
"dellocations" => {
location::delete_all(&context).await?;
}
"send" => {
ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "No message text given.");
@@ -1236,9 +1250,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"estimatedeletion" => {
ensure!(!arg1.is_empty(), "Argument <seconds> missing");
let seconds = arg1.parse()?;
let from_server = false;
let device_cnt = message::estimate_deletion_cnt(&context, from_server, seconds).await?;
println!("estimated count of messages older than {seconds} seconds: {device_cnt}");
let device_cnt = message::estimate_deletion_cnt(&context, false, seconds).await?;
let server_cnt = message::estimate_deletion_cnt(&context, true, seconds).await?;
println!(
"estimated count of messages older than {seconds} seconds:\non device: {device_cnt}\non server: {server_cnt}"
);
}
"" => (),
_ => bail!("Unknown command: \"{arg0}\" type ? for help."),

View File

@@ -176,7 +176,7 @@ const DB_COMMANDS: [&str; 11] = [
"housekeeping",
];
const CHAT_COMMANDS: [&str; 39] = [
const CHAT_COMMANDS: [&str; 40] = [
"listchats",
"listarchived",
"start-realtime",
@@ -194,6 +194,7 @@ const CHAT_COMMANDS: [&str; 39] = [
"chatinfo",
"sendlocations",
"setlocation",
"dellocations",
"getlocations",
"send",
"send-sync",
@@ -432,7 +433,7 @@ async fn handle_cmd(
{
println!("Open the following url, set mail_pw to the generated token and server_flags to 2:\n{oauth2_url}");
} else {
println!("OAuth2 not available for {addr}.");
println!("OAuth2 not available for {}.", &addr);
}
} else {
println!("oauth2: set addr first.");

View File

@@ -29,7 +29,7 @@ $ pip install .
1. Build `deltachat-rpc-server` with `cargo build -p deltachat-rpc-server`.
2. Install tox `pip install -U tox`
3. Run `CHATMAIL_DOMAIN=ci-chatmail.testrun.org PATH="../target/debug:$PATH" tox`.
3. Run `CHATMAIL_DOMAIN=nine.testrun.org PATH="../target/debug:$PATH" tox`.
Additional arguments to `tox` are passed to pytest, e.g. `tox -- -s` does not capture test output.

View File

@@ -13,7 +13,7 @@ def main():
with Rpc() as rpc:
deltachat = DeltaChat(rpc)
system_info = deltachat.get_system_info()
logging.info(f"Running deltachat core {system_info['deltachat_core_version']}")
logging.info("Running deltachat core %s", system_info["deltachat_core_version"])
accounts = deltachat.get_all_accounts()
account = accounts[0] if accounts else deltachat.add_account()
@@ -21,30 +21,36 @@ def main():
account.set_config("bot", "1")
if not account.is_configured():
logging.info("Account is not configured, configuring")
account.add_or_update_transport({"addr": sys.argv[1], "password": sys.argv[2]})
account.set_config("addr", sys.argv[1])
account.set_config("mail_pw", sys.argv[2])
account.configure()
logging.info("Configured")
else:
logging.info("Account is already configured")
deltachat.start_io()
qr = account.get_qr_code()
logging.info(f"Invite link: {qr}")
while True:
event = account.wait_for_event()
if event.kind == EventType.INFO:
logging.info(event["msg"])
elif event.kind == EventType.WARNING:
logging.warning(event["msg"])
elif event.kind == EventType.ERROR:
logging.error(event["msg"])
elif event.kind == EventType.INCOMING_MSG:
logging.info("Got an incoming message")
message = account.get_message_by_id(event.msg_id)
def process_messages():
for message in account.get_next_messages():
snapshot = message.get_snapshot()
if snapshot.from_id != SpecialContactId.SELF and not snapshot.is_bot and not snapshot.is_info:
snapshot.chat.send_text(snapshot.text)
snapshot.message.mark_seen()
# Process old messages.
process_messages()
while True:
event = account.wait_for_event()
if event["kind"] == EventType.INFO:
logging.info("%s", event["msg"])
elif event["kind"] == EventType.WARNING:
logging.warning("%s", event["msg"])
elif event["kind"] == EventType.ERROR:
logging.error("%s", event["msg"])
elif event["kind"] == EventType.INCOMING_MSG:
logging.info("Got an incoming message")
process_messages()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)

View File

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

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, Union
from warnings import warn
from ._utils import AttrDict, futuremethod
from .chat import Chat
@@ -340,6 +341,9 @@ class Account:
because the word "channel" already appears a lot in the code,
which would make it hard to grep for it.
After creation, the chat contains no recipients and is in _unpromoted_ state;
see `create_group()` for more information on the unpromoted state.
Returns the created chat.
"""
return Chat(self, self._rpc.create_broadcast(self.id, name))
@@ -388,7 +392,8 @@ class Account:
"""Return the list of fresh messages, newest messages first.
This call is intended for displaying notifications.
If you are writing a bot, process "incoming message" events instead.
If you are writing a bot, use `get_fresh_messages_in_arrival_order()` instead,
to process oldest messages first.
"""
fresh_msg_ids = self._rpc.get_fresh_msgs(self.id)
return [Message(self, msg_id) for msg_id in fresh_msg_ids]
@@ -400,15 +405,7 @@ class Account:
@futuremethod
def wait_next_messages(self) -> list[Message]:
"""(deprecated) Wait for new messages and return a list of them. Meant for bots.
Deprecated 2026-04: This returns the message's id as soon as the first part arrives,
even if it is not fully downloaded yet.
The bot needs to wait for the message to be fully downloaded.
Since this is usually not the desired behavior,
bots should instead use the `EventType.INCOMING_MSG`
event for getting notified about new messages.
"""
"""Wait for new messages and return a list of them."""
next_msg_ids = yield self._rpc.wait_next_msgs.future(self.id)
return [Message(self, msg_id) for msg_id in next_msg_ids]
@@ -458,6 +455,16 @@ class Account:
"""Wait for reaction change event."""
return self.wait_for_event(EventType.REACTIONS_CHANGED)
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.",
DeprecationWarning,
stacklevel=2,
)
fresh_msg_ids = sorted(self._rpc.get_fresh_msgs(self.id))
return [Message(self, msg_id) for msg_id in fresh_msg_ids]
def export_backup(self, path, passphrase: str = "") -> None:
"""Export backup."""
self._rpc.export_backup(self.id, str(path), passphrase)
@@ -480,7 +487,3 @@ class Account:
"""Return ICE servers for WebRTC configuration."""
ice_servers_json = self._rpc.ice_servers(self.id)
return json.loads(ice_servers_json)
def is_sending_locations(self) -> bool:
"""Return True if sending locations to any chat."""
return self._rpc.is_sending_locations(self.id)

View File

@@ -164,7 +164,7 @@ class Chat:
return Message(self.account, msg_id)
def send_sticker(self, path: str) -> Message:
"""Deprecated as of 2026-04; use `send_message` with `Viewtype.STICKER` instead."""
"""Send an sticker and return the resulting Message instance."""
msg_id = self._rpc.send_sticker(self.account.id, self.id, path)
return Message(self.account, msg_id)
@@ -206,9 +206,9 @@ class Chat:
snapshot["message"] = Message(self.account, snapshot.id)
return snapshot
def get_messages(self, 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, False, add_daymarker)
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]
def get_fresh_message_count(self) -> int:
@@ -277,16 +277,6 @@ class Chat:
"""Remove profile image of this chat."""
self._rpc.set_chat_profile_image(self.account.id, self.id, None)
def send_locations(self, seconds) -> None:
"""Enable location streaming in the chat for the given number of seconds.
Pass 0 to disable location streaming."""
self._rpc.send_locations_to_chat(self.account.id, self.id, seconds)
def is_sending_locations(self) -> bool:
"""Return True if sending locations to this chat."""
return self._rpc.is_sending_locations_to_chat(self.account.id, self.id)
def get_locations(
self,
contact: Optional[Contact] = None,

View File

@@ -190,6 +190,7 @@ class MessageState(IntEnum):
IN_FRESH = 10
IN_NOTICED = 13
IN_SEEN = 16
OUT_PREPARING = 18
OUT_DRAFT = 19
OUT_PENDING = 20
OUT_FAILED = 24

View File

@@ -59,11 +59,3 @@ class DeltaChat:
def set_translations(self, translations: dict[str, str]) -> None:
"""Set stock translation strings."""
self.rpc.set_stock_strings(translations)
def set_location(self, latitude, longitude, accuracy) -> bool:
"""Set location, return True if location streaming should continue."""
return self.rpc.set_location(latitude, longitude, accuracy)
def stop_sending_locations(self) -> None:
"""Stop sending locations to all chats."""
return self.rpc.stop_sending_locations()

View File

@@ -25,14 +25,7 @@ class Message:
return self.account._rpc
def send_reaction(self, *reaction: str) -> "Message":
"""
Sends a reaction to message.
A reaction is a string that represents an emoji.
You can call this function again to change the emoji;
the last sent reaction overrides all previously sent reactions.
It is possible to remove the reaction by sending an empty string.
"""
"""Send a reaction to this message."""
msg_id = self._rpc.send_reaction(self.account.id, self.id, reaction)
return Message(self.account, msg_id)

View File

@@ -87,7 +87,6 @@ def test_delivery_status_failed(acfactory: ACFactory) -> None:
Test change status on chatlistitem when status changes failed
"""
(alice,) = acfactory.get_online_accounts(1)
alice.set_config("force_encryption", "0")
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))

View File

@@ -16,7 +16,7 @@ def test_install_venv_and_use_other_core(tmp_path, get_core_python_env):
@pytest.mark.parametrize("version", ["2.24.0"])
def test_qr_setup_contact(acfactory, alice_and_remote_bob, version) -> None:
def test_qr_setup_contact(alice_and_remote_bob, version) -> None:
"""Test other-core Bob profile can do securejoin with Alice on current core."""
alice, alice_contact_bob, remote_eval = alice_and_remote_bob(version)
@@ -33,15 +33,6 @@ def test_qr_setup_contact(acfactory, alice_and_remote_bob, version) -> None:
# Test that Bob verified Alice's profile.
assert remote_eval("bob_contact_alice.get_snapshot().is_verified")
# Test that Bob can also scan a QR code
# of Alice for which the key is not known yet.
# For the test above Bob already knew the key from a vCard.
alice2 = acfactory.get_online_account()
qr_code = alice2.get_qr_code()
remote_eval(f"bob.secure_join({qr_code!r})")
remote_eval("bob.wait_for_securejoin_joiner_success()")
alice2.wait_for_securejoin_inviter_success()
def test_send_and_receive_message(alice_and_remote_bob) -> None:
"""Test other-core Bob profile can send a message to Alice on current core."""

View File

@@ -1,29 +1,104 @@
import logging
import re
import time
import pytest
from imap_tools import AND, U
from deltachat_rpc_client import EventType
from deltachat_rpc_client import Contact, EventType, Message
def test_moved_markseen(acfactory, direct_imap, log):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1 = acfactory.get_online_account()
def test_move_works(acfactory, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("mvbox_move", "1")
ac2.bring_online()
chat = ac1.create_chat(ac2)
chat.send_text("message1")
# Message is moved to the movebox
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
# Message is downloaded
msg = ac2.wait_for_incoming_msg().get_snapshot()
assert msg.text == "message1"
def test_reactions_for_a_reordering_move(acfactory, direct_imap):
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
messages they refer to and thus dropped.
"""
(ac1,) = acfactory.get_online_accounts(1)
addr, password = acfactory.get_credentials()
ac2 = acfactory.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password})
ac2.bring_online()
# Make sure that messages are not immediately auto-deleted on the server:
ac1.set_config("bcc_self", "1")
ac2.set_config("bcc_self", "1")
log.section("ac2: creating DeltaChat folder")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("sync_msgs", "0") # Do not send a sync message when accepting a contact request.
ac2.set_config("mvbox_move", "1")
assert ac2.is_configured()
ac2.add_or_update_transport({"addr": addr, "password": password, "imapFolder": "DeltaChat"})
ac2.bring_online()
chat1 = acfactory.get_accepted_chat(ac1, ac2)
ac2.stop_io()
logging.info("sending message + reaction from ac1 to ac2")
msg1 = chat1.send_text("hi")
msg1.wait_until_delivered()
# It's is sad, but messages must differ in their INTERNALDATEs to be processed in the correct
# order by DC, and most (if not all) mail servers provide only seconds precision.
time.sleep(1.1)
react_str = "\N{THUMBS UP SIGN}"
msg1.send_reaction(react_str).wait_until_delivered()
logging.info("moving messages to ac2's DeltaChat folder in the reverse order")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.connect()
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()], reverse=True):
ac2_direct_imap.conn.move(uid, "DeltaChat")
logging.info("receiving messages by ac2")
ac2.start_io()
msg2 = Message(ac2, ac2.wait_for_reactions_changed().msg_id)
assert msg2.get_snapshot().text == msg1.get_snapshot().text
reactions = msg2.get_reactions()
contacts = [Contact(ac2, int(i)) for i in reactions.reactions_by_contact]
assert len(contacts) == 1
assert contacts[0].get_snapshot().address == ac1.get_config("addr")
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
def test_move_works_on_self_sent(acfactory, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
# Create and enable movebox.
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder("DeltaChat")
ac1.set_config("mvbox_move", "1")
ac1.set_config("bcc_self", "1")
ac1.bring_online()
chat = ac1.create_chat(ac2)
chat.send_text("message1")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
chat.send_text("message2")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
chat.send_text("message3")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
def test_moved_markseen(acfactory, direct_imap):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("mvbox_move", "1")
ac2.set_config("delete_server_after", "0")
ac2.set_config("sync_msgs", "0") # Do not send a sync message when accepting a contact request.
ac2.bring_online()
ac2.stop_io()
@@ -33,7 +108,6 @@ def test_moved_markseen(acfactory, direct_imap, log):
idle2.wait_for_new_message()
# Emulate moving of the message to DeltaChat folder by Sieve rule.
log.section("ac2: moving message into DeltaChat folder")
ac2_direct_imap.conn.move(["*"], "DeltaChat")
ac2_direct_imap.select_folder("DeltaChat")
assert len(list(ac2_direct_imap.conn.fetch("*", mark_seen=False))) == 1
@@ -57,18 +131,29 @@ def test_moved_markseen(acfactory, direct_imap, log):
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True, uid=U(1, "*")), mark_seen=False))) == 1
def test_markseen_message_and_mdn(acfactory, direct_imap):
@pytest.mark.parametrize("mvbox_move", [True, False])
def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
ac1, ac2 = acfactory.get_online_accounts(2)
# Make sure that messages are not immediately auto-deleted on the server:
ac1.set_config("bcc_self", "1")
ac2.set_config("bcc_self", "1")
for ac in ac1, ac2:
ac.set_config("delete_server_after", "0")
if mvbox_move:
ac_direct_imap = direct_imap(ac)
ac_direct_imap.create_folder("DeltaChat")
ac.set_config("mvbox_move", "1")
ac.bring_online()
# Do not send BCC to self, we only want to test MDN on ac1.
ac1.set_config("bcc_self", "0")
acfactory.get_accepted_chat(ac1, ac2).send_text("hi")
msg = ac2.wait_for_incoming_msg()
msg.mark_seen()
rex = re.compile("Marked messages [0-9]+ in folder INBOX as seen.")
if mvbox_move:
rex = re.compile("Marked messages [0-9]+ in folder DeltaChat as seen.")
else:
rex = re.compile("Marked messages [0-9]+ in folder INBOX as seen.")
for ac in ac1, ac2:
while True:
@@ -76,24 +161,24 @@ def test_markseen_message_and_mdn(acfactory, direct_imap):
if event.kind == EventType.INFO and rex.search(event.msg):
break
folder = "mvbox" if mvbox_move else "inbox"
ac1_direct_imap = direct_imap(ac1)
ac2_direct_imap = direct_imap(ac2)
ac1_direct_imap.select_folder("INBOX")
ac2_direct_imap.select_folder("INBOX")
ac1_direct_imap.select_config_folder(folder)
ac2_direct_imap.select_config_folder(folder)
# Check that the mdn and original message is marked as seen
assert len(list(ac1_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 2
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 2
# Check that the mdn is marked as seen
assert len(list(ac1_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
# Check original message is marked as seen
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
def test_trash_multiple_messages(acfactory, direct_imap, log):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.stop_io()
# Make sure that messages are not immediately auto-deleted on the server:
ac2.set_config("bcc_self", "1")
ac2.set_config("delete_server_after", "0")
ac2.set_config("sync_msgs", "0")
ac2.start_io()

View File

@@ -1,32 +0,0 @@
def test_set_location(dc, acfactory) -> None:
# Try setting location without any accounts.
assert not dc.set_location(1.0, 2.0, 0.1)
# Create one account that does not stream,
# set location.
acfactory.new_configured_account()
assert not dc.set_location(3.0, 4.0, 0.1)
def test_send_locations_to_chat(dc, acfactory):
alice, bob = acfactory.get_online_accounts(2)
assert not alice.is_sending_locations()
alice_chat_bob = alice.create_chat(bob)
assert not alice_chat_bob.is_sending_locations()
# Test starting and stopping location streaming in a chat.
alice_chat_bob.send_locations(3600)
assert alice.is_sending_locations()
assert alice_chat_bob.is_sending_locations()
alice_chat_bob.send_locations(0)
assert not alice.is_sending_locations()
assert not alice_chat_bob.is_sending_locations()
# Test stop_sending_locations() for all accounts and chats.
alice_chat_bob.send_locations(3600)
assert alice.is_sending_locations()
assert alice_chat_bob.is_sending_locations()
dc.stop_sending_locations()
assert not alice.is_sending_locations()
assert not alice_chat_bob.is_sending_locations()

View File

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

View File

@@ -9,6 +9,12 @@ def test_add_second_address(acfactory) -> None:
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
# When the first transport is created,
# mvbox_move and only_fetch_mvbox should be disabled.
assert account.get_config("mvbox_move") == "0"
assert account.get_config("only_fetch_mvbox") == "0"
assert account.get_config("show_emails") == "2"
qr = acfactory.get_account_qr()
account.add_transport_from_qr(qr)
assert len(account.list_transports()) == 2
@@ -26,6 +32,44 @@ def test_add_second_address(acfactory) -> None:
account.delete_transport(second_addr)
assert len(account.list_transports()) == 2
# Enabling mvbox_move or only_fetch_mvbox
# is not allowed when multi-transport is enabled.
for option in ["mvbox_move", "only_fetch_mvbox"]:
with pytest.raises(JsonRpcError):
account.set_config(option, "1")
# show_emails does not matter for multi-relay, can be set to anything
account.set_config("show_emails", "0")
@pytest.mark.parametrize("key", ["mvbox_move", "only_fetch_mvbox"])
def test_no_second_transport_with_mvbox(acfactory, key) -> None:
"""Test that second transport cannot be configured if mvbox is used."""
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
assert account.get_config("mvbox_move") == "0"
assert account.get_config("only_fetch_mvbox") == "0"
qr = acfactory.get_account_qr()
account.set_config(key, "1")
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
def test_second_transport_without_classic_emails(acfactory) -> None:
"""Test that second transport can be configured if classic emails are not fetched."""
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
assert account.get_config("show_emails") == "2"
qr = acfactory.get_account_qr()
account.set_config("show_emails", "0")
account.add_transport_from_qr(qr)
def test_change_address(acfactory) -> None:
"""Test Alice configuring a second transport and setting it as a primary one."""
@@ -103,13 +147,44 @@ def test_download_on_demand(acfactory) -> None:
assert msg.get_snapshot().download_state == dstate
@pytest.mark.parametrize("is_chatmail", ["0", "1"])
def test_mvbox_move_first_transport(acfactory, is_chatmail) -> None:
"""Test that mvbox_move is disabled by default even for non-chatmail accounts.
Disabling mvbox_move is required to be able to setup a second transport.
"""
account = acfactory.get_unconfigured_account()
account.set_config("fix_is_chatmail", "1")
account.set_config("is_chatmail", is_chatmail)
# The default value when the setting is unset is "1".
# This is not changed for compatibility with old databases
# imported from backups.
assert account.get_config("mvbox_move") == "1"
qr = acfactory.get_account_qr()
account.add_transport_from_qr(qr)
# Once the first transport is set up,
# mvbox_move is disabled.
assert account.get_config("mvbox_move") == "0"
assert account.get_config("is_chatmail") == is_chatmail
def test_reconfigure_transport(acfactory) -> None:
"""Test that reconfiguring the transport works."""
"""Test that reconfiguring the transport works
even if settings not supported for multi-transport
like mvbox_move are enabled."""
account = acfactory.get_online_account()
account.set_config("mvbox_move", "1")
[transport] = account.list_transports()
account.add_or_update_transport(transport)
# Reconfiguring the transport should not reset
# the settings as if when configuring the first transport.
assert account.get_config("mvbox_move") == "1"
def test_transport_synchronization(acfactory, log) -> None:
"""Test synchronization of transports between devices."""

View File

@@ -91,15 +91,7 @@ def test_lowercase_address(acfactory) -> None:
assert account.list_transports()[0]["addr"] == addr
param = account.get_info()["used_transport_settings"]
domain = addr.rsplit("@")[-1]
domain_upper = addr_upper.rsplit("@")[-1]
assert domain in param
assert domain_upper not in param
# Whole address should not appear in the info,
# does not matter if uppercase or lowercase.
assert addr not in param
assert addr in param
assert addr_upper not in param
@@ -1240,12 +1232,10 @@ def test_leave_and_delete_group(acfactory, log):
def test_immediate_autodelete(acfactory, direct_imap, log):
"""
`bcc_self` is off by default,
so that messages are supposed to be immediately autodeleted
"""
ac1, ac2 = acfactory.get_online_accounts(2)
assert ac1.get_config("bcc_self") == "0"
# "1" means delete immediately, while "0" means do not delete
ac2.set_config("delete_server_after", "1")
log.section("ac1: create chat with ac2")
chat1 = ac1.create_chat(ac2)

View File

@@ -18,8 +18,6 @@ def test_webxdc(acfactory) -> None:
"sourceCodeUrl": None,
"summary": None,
"selfAddr": webxdc_info["selfAddr"],
"isAppSender": False,
"isBroadcast": False,
"sendUpdateInterval": 1000,
"sendUpdateMaxSize": 18874368,
}

View File

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

View File

@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "2.51.0-dev"
"version": "2.49.0-dev"
}

View File

@@ -23,32 +23,7 @@ ignore = [
# it is a transitive dependency of iroh 0.35.0
# which depends on ^0.102.
# <https://rustsec.org/advisories/RUSTSEC-2026-0049>
# <https://rustsec.org/advisories/RUSTSEC-2026-0098>
# <https://rustsec.org/advisories/RUSTSEC-2026-0099>
"RUSTSEC-2026-0049",
"RUSTSEC-2026-0098",
"RUSTSEC-2026-0099",
# Panic in CRL signature checks.
# We do not check CRL and cannot update rustls-webpki 0.102.8
# which is a dependency of iroh 0.35.0.
# <https://rustsec.org/advisories/RUSTSEC-2026-0104>
"RUSTSEC-2026-0104",
# hickory-proto 0.25.2 unbounded loop in DNSSEC code.
# Dependency of iroh 0.35.0, cannot be updated as of 2026-05-02.
# <https://rustsec.org/advisories/RUSTSEC-2026-0118>
"RUSTSEC-2026-0118",
# hickory-proto 0.25.2 quadratic complexity issue.
# Dependency of iroh 0.35.0, cannot be updated as of 2026-05-02.
# <https://rustsec.org/advisories/RUSTSEC-2026-0119>
"RUSTSEC-2026-0119",
# Timing side channel in ml-dsa dependency of rPGP.
# We enable PQC for encryption rather than signatures.
# <https://rustsec.org/advisories/RUSTSEC-2025-0144>
"RUSTSEC-2025-0144",
]
[bans]
@@ -60,14 +35,12 @@ skip = [
{ name = "async-channel", version = "1.9.0" },
{ name = "bitflags", version = "1.3.2" },
{ name = "constant_time_eq", version = "0.3.1" },
{ name = "cpufeatures", version = "0.2.17" },
{ name = "derive_more-impl", version = "1.0.0" },
{ name = "derive_more", version = "1.0.0" },
{ name = "event-listener", version = "2.5.3" },
{ name = "getrandom", version = "0.2.12" },
{ name = "heck", version = "0.4.1" },
{ name = "http", version = "0.2.12" },
{ name = "hybrid-array", version = "0.2.3" },
{ name = "linux-raw-sys", version = "0.4.14" },
{ name = "lru", version = "0.12.5" },
{ name = "netlink-packet-route", version = "0.17.1" },

151
flake.lock generated
View File

@@ -3,19 +3,15 @@
"android": {
"inputs": {
"devshell": "devshell",
"flake-utils": [
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
]
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1779918845,
"narHash": "sha256-FbpOOBg15L7X6NWWmTKbSdccnH59Jq53wWmAO37d2Q8=",
"lastModified": 1731356359,
"narHash": "sha256-vYqJnu6jotmWpPT4DgzHVdvNIZcKZCIUqS8QaptsZA0=",
"owner": "tadfisher",
"repo": "android-nixpkgs",
"rev": "105c093afc8c8fbeea98f8e398403f93043eba17",
"rev": "c028ead7e88edb2e94cd7c90ee37593f63ae494a",
"type": "github"
},
"original": {
@@ -32,11 +28,11 @@
]
},
"locked": {
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"lastModified": 1728330715,
"narHash": "sha256-xRJ2nPOXb//u1jaBnDP56M7v5ldavjbtR6lfGqSvcKg=",
"owner": "numtide",
"repo": "devshell",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"rev": "dd6b80932022cea34a019e2bb32f6fa9e494dfef",
"type": "github"
},
"original": {
@@ -47,17 +43,15 @@
},
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"nixpkgs": "nixpkgs_2",
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1779876442,
"narHash": "sha256-O25HomVNmdROO13PEQ3Ran8Hq5EsyLmVn8Gb8JvJtJE=",
"lastModified": 1763361733,
"narHash": "sha256-ka7dpwH3HIXCyD2wl5F7cPLeRbqZoY2ullALsvxdPt8=",
"owner": "nix-community",
"repo": "fenix",
"rev": "2eff81fc84390a35e1565395ae945d9394856824",
"rev": "6c8d48e3b0ae371b19ac1485744687b788e80193",
"type": "github"
},
"original": {
@@ -71,11 +65,29 @@
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"lastModified": 1726560853,
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1726560853,
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"type": "github"
},
"original": {
@@ -86,35 +98,29 @@
},
"naersk": {
"inputs": {
"fenix": [
"fenix"
],
"nixpkgs": [
"nixpkgs"
]
"nixpkgs": "nixpkgs_3"
},
"locked": {
"lastModified": 1779912356,
"narHash": "sha256-yj5O6vmAj+OfhTQMiUwhmQRP0HAII3BxEI6zuY6h/5k=",
"lastModified": 1721727458,
"narHash": "sha256-r/xppY958gmZ4oTfLiHN0ZGuQ+RSTijDblVgVLFi1mw=",
"owner": "nix-community",
"repo": "naersk",
"rev": "33eaf5c72a67db15073322d26cd342c443556214",
"rev": "3fb418eaf352498f6b6c30592e3beb63df42ef11",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "pull/391/head",
"repo": "naersk",
"type": "github"
}
},
"nix-filter": {
"locked": {
"lastModified": 1757882181,
"narHash": "sha256-+cCxYIh2UNalTz364p+QYmWHs0P+6wDhiWR4jDIKQIU=",
"lastModified": 1730207686,
"narHash": "sha256-SCHiL+1f7q9TAnxpasriP6fMarWE5H43t25F5/9e28I=",
"owner": "numtide",
"repo": "nix-filter",
"rev": "59c44d1909c72441144b93cf0f054be7fe764de5",
"rev": "776e68c1d014c3adde193a18db9d738458cd2ba4",
"type": "github"
},
"original": {
@@ -125,16 +131,60 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1779931091,
"narHash": "sha256-gc8NEz7a++7OQPGvMv+zIjXCec1PO38XRXZRa3m97ew=",
"lastModified": 1731139594,
"narHash": "sha256-IigrKK3vYRpUu+HEjPL/phrfh7Ox881er1UEsZvw9Q4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "76612b17c0ce71689921ca12d9ffdc9c23ce40b2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1762977756,
"narHash": "sha256-4PqRErxfe+2toFJFgcRKZ0UI9NSIOJa+7RXVtBhy4KE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "3052ddf0614791c1869384a868248be5607a309f",
"rev": "c5ae371f1a6a7fd27823bc500d9390b38c05fa55",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "master",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 0,
"narHash": "sha256-Dqg6si5CqIzm87sp57j5nTaeBbWhHFaVyG7V6L8k3lY=",
"path": "/nix/store/zq2axpgzd5kykk1v446rkffj3bxa2m2h-source",
"type": "path"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1747179050,
"narHash": "sha256-qhFMmDkeJX9KJwr5H32f1r7Prs7XbQWtO0h3V0a0rFY=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "adaa24fbf46737f3f1b5497bf64bae750f82942e",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
@@ -143,20 +193,20 @@
"inputs": {
"android": "android",
"fenix": "fenix",
"flake-utils": "flake-utils",
"flake-utils": "flake-utils_2",
"naersk": "naersk",
"nix-filter": "nix-filter",
"nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs_4"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1779827300,
"narHash": "sha256-J6pHxKoZzWCrAvOVInwBcYYWix/NWwM10Ad+i29Qc5s=",
"lastModified": 1762860488,
"narHash": "sha256-rMfWMCOo/pPefM2We0iMBLi2kLBAnYoB9thi4qS7uk4=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "c3af07ad84d68adc5e652e86f0c20009caa29014",
"rev": "2efc80078029894eec0699f62ec8d5c1a56af763",
"type": "github"
},
"original": {
@@ -180,6 +230,21 @@
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

View File

@@ -2,16 +2,11 @@
description = "Chatmail core";
inputs = {
fenix.url = "github:nix-community/fenix";
fenix.inputs.nixpkgs.follows = "nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
naersk.url = "github:nix-community/naersk/pull/391/head";
naersk.inputs.nixpkgs.follows = "nixpkgs";
naersk.inputs.fenix.follows = "fenix";
naersk.url = "github:nix-community/naersk";
nix-filter.url = "github:numtide/nix-filter";
nixpkgs.url = "github:nixos/nixpkgs/master";
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
android.url = "github:tadfisher/android-nixpkgs";
android.inputs.nixpkgs.follows = "nixpkgs";
android.inputs.flake-utils.follows = "flake-utils";
};
outputs = { self, nixpkgs, flake-utils, nix-filter, naersk, fenix, android }:
flake-utils.lib.eachDefaultSystem (system:
@@ -138,8 +133,6 @@
];
depsBuildBuild = [
pkgsWin64.stdenv.cc
];
buildInputs = [
pkgsWin64.windows.pthreads
];
auditable = false; # Avoid cargo-auditable failures.
@@ -150,8 +143,6 @@
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${TARGET_CC}"
"-L"
"native=${pkgsWin64.windows.pthreads}/lib"
];
CC = "${pkgsWin64.stdenv.cc}/bin/${pkgsWin64.stdenv.cc.targetPrefix}cc";
@@ -189,8 +180,7 @@
};
})).overrideAttrs (oldAttr: {
configureFlags = oldAttr.configureFlags ++ [
"--disable-sjlj-exceptions"
"--with-dwarf2"
"--disable-sjlj-exceptions --with-dwarf2"
];
})
);
@@ -206,8 +196,6 @@
];
depsBuildBuild = [
winCC
];
buildInputs = [
pkgsWin32.windows.pthreads
];
auditable = false; # Avoid cargo-auditable failures.
@@ -218,8 +206,6 @@
CARGO_BUILD_RUSTFLAGS = [
"-C"
"linker=${TARGET_CC}"
"-L"
"native=${pkgsWin32.windows.pthreads}/lib"
];
CC = "${winCC}/bin/${winCC.targetPrefix}cc";
@@ -518,6 +504,22 @@
'';
};
# Source package for deltachat-rpc-server.
# Fake package that downloads Linux version,
# needed to install deltachat-rpc-server on Android with `pip`.
deltachat-rpc-server-source =
pkgs.stdenv.mkDerivation {
pname = "deltachat-rpc-server-source";
version = manifest.version;
src = pkgs.lib.cleanSource ./.;
nativeBuildInputs = [
pkgs.python3
pkgs.python3Packages.wheel
];
buildPhase = ''python3 scripts/wheel-rpc-server.py source deltachat_rpc_server-${manifest.version}.tar.gz'';
installPhase = ''mkdir -p $out; cp -av deltachat_rpc_server-${manifest.version}.tar.gz $out'';
};
deltachat-rpc-client =
pkgs.python3Packages.buildPythonPackage {
pname = "deltachat-rpc-client";
@@ -560,7 +562,7 @@
deltachat-python
deltachat-rpc-client
pkgs.python3Packages.breathe
pkgs.python3Packages.sphinx-rtd-theme
pkgs.python3Packages.sphinx_rtd_theme
];
nativeBuildInputs = [ pkgs.sphinx ];
buildPhase = ''sphinx-build -b html -a python/doc/ dist/html'';

View File

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

View File

@@ -271,6 +271,15 @@ class Chat:
sent out. This is the same object as was passed in, which
has been modified with the new state of the core.
"""
if msg.is_out_preparing():
assert msg.id != 0
# get a fresh copy of dc_msg, the core needs it
maybe_msg = Message.from_db(self.account, msg.id)
if maybe_msg is not None:
msg = maybe_msg
else:
raise ValueError("message does not exist")
sent_id = lib.dc_send_msg(self.account._dc_context, self.id, msg._dc_msg)
if sent_id == 0:
raise ValueError("message could not be sent")
@@ -324,6 +333,26 @@ class Chat:
raise ValueError("message could not be sent")
return Message.from_db(self.account, sent_id)
def send_prepared(self, message):
"""send a previously prepared message.
:param message: a :class:`Message` instance previously returned by
:meth:`prepare_file`.
:raises ValueError: if message can not be sent.
:returns: a :class:`deltachat.message.Message` instance as sent out.
"""
assert message.id != 0 and message.is_out_preparing()
# get a fresh copy of dc_msg, the core needs it
msg = Message.from_db(self.account, message.id)
# pass 0 as chat-id because core-docs say it's ok when out-preparing
sent_id = lib.dc_send_msg(self.account._dc_context, 0, msg._dc_msg)
if sent_id == 0:
raise ValueError("message could not be sent")
assert sent_id == msg.id
# modify message in place to avoid bad state for the caller
msg._dc_msg = Message.from_db(self.account, sent_id)._dc_msg
def set_draft(self, message):
"""set message as draft.

View File

@@ -351,12 +351,17 @@ class Message:
def is_outgoing(self):
"""Return True if Message is outgoing."""
return lib.dc_msg_get_state(self._dc_msg) in (
const.DC_STATE_OUT_PREPARING,
const.DC_STATE_OUT_PENDING,
const.DC_STATE_OUT_FAILED,
const.DC_STATE_OUT_MDN_RCVD,
const.DC_STATE_OUT_DELIVERED,
)
def is_out_preparing(self):
"""Return True if Message is outgoing, but its file is being prepared."""
return self._msgstate == const.DC_STATE_OUT_PREPARING
def is_out_pending(self):
"""Return True if Message is outgoing, but is pending (no single checkmark)."""
return self._msgstate == const.DC_STATE_OUT_PENDING

View File

@@ -433,6 +433,7 @@ class ACFactory:
if self.pytestconfig.getoption("--strict-tls"):
# Enable strict certificate checks for online accounts
configdict["imap_certificate_checks"] = str(const.DC_CERTCK_STRICT)
configdict["smtp_certificate_checks"] = str(const.DC_CERTCK_STRICT)
assert "addr" in configdict and "mail_pw" in configdict
return configdict
@@ -504,6 +505,7 @@ class ACFactory:
"addr": cloned_from.get_config("addr"),
"mail_pw": cloned_from.get_config("mail_pw"),
"imap_certificate_checks": cloned_from.get_config("imap_certificate_checks"),
"smtp_certificate_checks": cloned_from.get_config("smtp_certificate_checks"),
}
configdict.update(kwargs)
ac = self._get_cached_account(addr=configdict["addr"]) if cache else None
@@ -520,7 +522,9 @@ class ACFactory:
ac = self.get_unconfigured_account()
assert "addr" in configdict and "mail_pw" in configdict, configdict
configdict.setdefault("bcc_self", False)
configdict.setdefault("mvbox_move", False)
configdict.setdefault("sync_msgs", False)
configdict.setdefault("delete_server_after", 0)
ac.update_config(configdict)
self._acsetup._account2config[ac] = configdict
self._preconfigure_key(ac)

View File

@@ -298,6 +298,73 @@ def test_use_new_verified_group_after_going_online(acfactory, data, tmp_path, lp
assert msg_in.text == msg_out.text
def test_verified_group_vs_delete_server_after(acfactory, tmp_path, lp):
"""Test for the issue #4346:
- User is added to a verified group.
- First device of the user downloads "member added" from the group.
- First device removes "member added" from the server.
- Some new messages are sent to the group.
- Second device comes online, receives these new messages.
The result is an unverified group with unverified members.
- First device re-gossips Autocrypt keys to the group.
- Now the second device has all members and group verified.
"""
ac1, ac2 = acfactory.get_online_accounts(2)
acfactory.remove_preconfigured_keys()
ac2_offl = acfactory.new_online_configuring_account(cloned_from=ac2)
for ac in [ac2, ac2_offl]:
ac.set_config("bcc_self", "1")
ac2.set_config("delete_server_after", "1")
ac2.set_config("gossip_period", "0") # Re-gossip in every message
acfactory.bring_accounts_online()
dir = tmp_path / "exportdir"
dir.mkdir()
ac2.export_self_keys(str(dir))
ac2_offl.import_self_keys(str(dir))
ac2_offl.stop_io()
lp.sec("ac1: create verified-group QR, ac2 scans and joins")
chat1 = ac1.create_group_chat("hello")
qr = chat1.get_join_qr()
lp.sec("ac2: start QR-code based join-group protocol")
chat2 = ac2.qr_join_chat(qr)
ac1._evtracker.wait_securejoin_inviter_progress(1000)
# Wait for "Member Me (<addr>) added by <addr>." message.
msg_in = ac2._evtracker.wait_next_incoming_message()
assert msg_in.is_system_message()
lp.sec("ac2: waiting for 'member added' to be deleted on the server")
ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_DELETED")
lp.sec("ac1: sending 'hi' to the group")
ac2.set_config("delete_server_after", "0")
chat1.send_text("hi")
lp.sec("ac2_offl: going online, checking the 'hi' message")
ac2_offl.start_io()
msg_in = ac2_offl._evtracker.wait_next_incoming_message()
assert not msg_in.is_system_message()
assert msg_in.text == "hi"
ac2_offl_ac1_contact = msg_in.get_sender_contact()
assert ac2_offl_ac1_contact.addr == ac1.get_config("addr")
assert not ac2_offl_ac1_contact.is_verified()
chat2_offl = msg_in.chat
lp.sec("ac2: sending message re-gossiping Autocrypt keys")
chat2.send_text("hi2")
lp.sec("ac2_offl: receiving message")
ev = ac2_offl._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED")
msg_in = ac2_offl.get_message_by_id(ev.data2)
assert not msg_in.is_system_message()
assert msg_in.text == "hi2"
assert msg_in.chat == chat2_offl
assert msg_in.get_sender_contact().addr == ac2.get_config("addr")
# Until we reset verifications and then send the _verified header,
# verification is not gossiped here:
assert not ac2_offl_ac1_contact.is_verified()
def test_deleted_msgs_dont_reappear(acfactory):
ac1 = acfactory.new_online_configuring_account()
acfactory.bring_accounts_online()

View File

@@ -15,9 +15,6 @@ def test_basic_imap_api(acfactory, tmp_path):
ac1, ac2 = acfactory.get_online_accounts(2)
chat12 = acfactory.get_accepted_chat(ac1, ac2)
# Make sure that messages are not immediately auto-deleted on the server:
ac2.set_config("bcc_self", "1")
imap2 = ac2.direct_imap
with imap2.idle() as idle2:
@@ -165,9 +162,6 @@ def test_webxdc_message(acfactory, data, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat = acfactory.get_accepted_chat(ac1, ac2)
# Make sure that messages are not immediately auto-deleted on the server:
ac2.set_config("bcc_self", "1")
lp.sec("ac1: prepare and send text message to ac2")
msg1 = chat.send_text("message0")
assert not msg1.is_webxdc()
@@ -368,10 +362,6 @@ def test_send_and_receive_message_markseen(acfactory, lp):
# make DC's life harder wrt to encodings
ac1.set_config("displayname", "ä name")
# Make sure that messages are not immediately auto-deleted on the server:
ac1.set_config("bcc_self", "1")
ac2.set_config("bcc_self", "1")
# clear any fresh device messages
ac1.get_device_chat().mark_noticed()
ac2.get_device_chat().mark_noticed()
@@ -516,15 +506,9 @@ def test_mdn_asymmetric(acfactory, lp):
ac1.set_config("mdns_enabled", "1")
ac2.set_config("mdns_enabled", "1")
# Make sure that the mdn is not immediately auto-deleted on the server:
ac1.set_config("bcc_self", "1")
lp.sec("sending text message from ac1 to ac2")
msg_out = chat.send_text("message1")
# Wait for the message to be marked as seen on IMAP.
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder INBOX as seen.")
assert len(chat.get_messages()) == 1 + E2EE_INFO_MSGS
lp.sec("disable ac1 MDNs")
@@ -541,7 +525,7 @@ def test_mdn_asymmetric(acfactory, lp):
lp.sec("ac1: waiting for incoming activity")
assert len(chat.get_messages()) == 1 + E2EE_INFO_MSGS
# Wait for the mdn to be marked as seen on IMAP.
# Wait for the message to be marked as seen on IMAP.
ac1._evtracker.get_info_contains("Marked messages [0-9]+ in folder INBOX as seen.")
# MDN is received even though MDNs are already disabled
@@ -1089,8 +1073,6 @@ def test_send_receive_locations(acfactory, lp):
def test_delete_multiple_messages(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
# Make sure that messages are not immediately auto-deleted on the server:
ac2.set_config("bcc_self", "1")
chat12 = acfactory.get_accepted_chat(ac1, ac2)
lp.sec("ac1: sending seven messages")

View File

@@ -52,19 +52,19 @@ class TestOfflineAccountBasic:
def test_set_config_int_conversion(self, acfactory):
ac1 = acfactory.get_unconfigured_account()
ac1.set_config("bcc_self", False)
assert ac1.get_config("bcc_self") == "0"
ac1.set_config("bcc_self", True)
assert ac1.get_config("bcc_self") == "1"
ac1.set_config("bcc_self", 0)
assert ac1.get_config("bcc_self") == "0"
ac1.set_config("bcc_self", 1)
assert ac1.get_config("bcc_self") == "1"
ac1.set_config("mvbox_move", False)
assert ac1.get_config("mvbox_move") == "0"
ac1.set_config("mvbox_move", True)
assert ac1.get_config("mvbox_move") == "1"
ac1.set_config("mvbox_move", 0)
assert ac1.get_config("mvbox_move") == "0"
ac1.set_config("mvbox_move", 1)
assert ac1.get_config("mvbox_move") == "1"
def test_update_config(self, acfactory):
ac1 = acfactory.get_unconfigured_account()
ac1.update_config({"bcc_self": True})
assert ac1.get_config("bcc_self") == "1"
ac1.update_config({"mvbox_move": False})
assert ac1.get_config("mvbox_move") == "0"
def test_has_bccself(self, acfactory):
ac1 = acfactory.get_unconfigured_account()
@@ -153,8 +153,7 @@ class TestOfflineContact:
def test_delete_referenced_contact_hides_contact(self, acfactory):
ac1 = acfactory.get_pseudo_configured_account()
ac2 = acfactory.get_pseudo_configured_account()
contact1 = ac1.create_contact(ac2)
contact1 = ac1.create_contact("some1@example.com", name="some1")
msg = contact1.create_chat().send_text("one message")
assert ac1.delete_contact(contact1)
assert not msg.filemime
@@ -186,9 +185,8 @@ class TestOfflineChat:
return acfactory.get_pseudo_configured_account()
@pytest.fixture()
def chat1(self, ac1, acfactory):
ac2 = acfactory.get_pseudo_configured_account()
return ac1.create_contact(ac2).create_chat()
def chat1(self, ac1):
return ac1.create_contact("some1@example.org", name="some1").create_chat()
def test_display(self, chat1):
str(chat1)
@@ -406,7 +404,7 @@ class TestOfflineChat:
contact2 = ac1.create_contact("display1 <x@example.org>", "real")
assert contact2.name == "real"
def test_send_lots_of_offline_msgs(self, acfactory, chat1):
def test_send_lots_of_offline_msgs(self, acfactory):
ac1 = acfactory.get_pseudo_configured_account()
ac1.set_config("configured_mail_server", "example.org")
ac1.set_config("configured_mail_user", "example.org")
@@ -415,13 +413,13 @@ class TestOfflineChat:
ac1.set_config("configured_send_user", "example.org")
ac1.set_config("configured_send_pw", "example.org")
ac1.start_io()
chat = ac1.create_contact("some1@example.org", name="some1").create_chat()
for i in range(50):
chat1.send_text("hello")
chat.send_text("hello")
def test_create_chat_simple(self, acfactory):
ac1 = acfactory.get_pseudo_configured_account()
ac2 = acfactory.get_pseudo_configured_account()
contact1 = ac1.create_contact(ac2)
contact1 = ac1.create_contact("some1@example.org", name="some1")
contact1.create_chat().send_text("hello")
def test_chat_message_distinctions(self, ac1, chat1):

View File

@@ -152,8 +152,7 @@ def test_sig():
def test_markseen_invalid_message_ids(acfactory):
ac1 = acfactory.get_pseudo_configured_account()
ac2 = acfactory.get_pseudo_configured_account()
contact1 = ac1.create_contact(ac2)
contact1 = ac1.create_contact("some1@example.com", name="some1")
chat = contact1.create_chat()
chat.send_text("one message")
ac1._evtracker.get_matching("DC_EVENT_MSGS_CHANGED")

View File

@@ -1 +1 @@
2026-05-29
2026-04-08

View File

@@ -48,3 +48,19 @@ the build machine (ask your friendly sysadmin on #deltachat Libera Chat) to type
This will **rsync** your current checkout to the remote build machine
(no need to commit before) and then run either rust or python tests.
# coredeps Dockerfile
`coredeps/Dockerfile` specifies an image that contains all
of Delta Chat's core dependencies. It is used to
build python wheels (binary packages for Python).
You can build the docker images yourself locally
to avoid the relatively large download:
cd scripts # where all CI things are
docker build -t deltachat/coredeps coredeps
Additionally, you can install qemu and build arm64 docker image on x86\_64 machine:
apt-get install qemu binfmt-support qemu-user-static
docker build -t deltachat/coredeps-arm64 --build-arg BASEIMAGE=quay.io/pypa/manylinux2014_aarch64 coredeps

View File

@@ -1,9 +1,3 @@
#!/bin/sh
# Run clippy for all Rust code in the project.
#
# To check, run
# scripts/clippy.sh
#
# To automatically fix warnings, run
# scripts/clippy.sh --fix --allow-dirty
cargo clippy --workspace --all-targets --all-features "$@" -- -D warnings
cargo clippy --workspace --all-targets --all-features -- -D warnings

View File

@@ -0,0 +1,26 @@
# Concourse CI pipeline
`docs_wheels.yml` is a pipeline for [Concourse CI](https://concourse-ci.org/)
that builds C documentation, Python documentation, Python wheels for `x86_64`
and `aarch64` and Python source packages, and uploads them.
To setup the pipeline run
```
fly -t <your-target> set-pipeline -c docs_wheels.yml -p docs_wheels -l secret.yml
```
where `secret.yml` contains the following secrets:
```
c.delta.chat:
private_key: |
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
devpi:
login: dc
password: ...
```
Secrets can be read from the password manager:
```
fly -t b1 set-pipeline -c docs_wheels.yml -p docs_wheels -l <(pass show delta/b1.delta.chat/secret.yml)
```

View File

@@ -0,0 +1,305 @@
resources:
- name: deltachat-core-rust
type: git
icon: github
source:
branch: main
uri: https://github.com/chatmail/core.git
- name: deltachat-core-rust-release
type: git
icon: github
source:
branch: main
uri: https://github.com/chatmail/core.git
tag_filter: "v*"
jobs:
- name: python-x86_64
plan:
- get: deltachat-core-rust
- get: deltachat-core-rust-release
trigger: true
# Build manylinux image with additional dependencies
- task: build-coredeps
privileged: true
config:
inputs:
# Building the latest, not tagged coredeps
- name: deltachat-core-rust
image_resource:
source:
repository: concourse/oci-build-task
type: registry-image
outputs:
- name: coredeps-image
path: image
params:
CONTEXT: deltachat-core-rust/scripts/coredeps
UNPACK_ROOTFS: "true"
BUILD_ARG_BASEIMAGE: quay.io/pypa/manylinux2014_x86_64
platform: linux
caches:
- path: cache
run:
path: build
# Use built image to build python wheels
- task: build-wheels
image: coredeps-image
config:
inputs:
- name: deltachat-core-rust-release
path: .
outputs:
# Binary wheels
- name: py-wheels
path: ./python/.docker-tox/wheelhouse/
platform: linux
run:
path: bash
args:
- -exc
- |
scripts/run_all.sh
# Upload x86_64 wheels and source packages
- task: upload-wheels
config:
inputs:
- name: py-wheels
image_resource:
type: registry-image
source:
repository: debian
platform: linux
run:
path: sh
args:
- -ec
- |
apt-get update -y
apt-get install -y --no-install-recommends python3-pip python3-setuptools python3-venv
python3 -m venv env
env/bin/pip install --upgrade pip
env/bin/pip install devpi
env/bin/devpi use https://m.devpi.net/dc/master
env/bin/devpi login ((devpi.login)) --password ((devpi.password))
env/bin/devpi upload py-wheels/*manylinux201*
- name: python-aarch64
plan:
- get: deltachat-core-rust
- get: deltachat-core-rust-release
trigger: true
# Build manylinux image with additional dependencies
- task: build-coredeps
privileged: true
config:
inputs:
# Building the latest, not tagged coredeps
- name: deltachat-core-rust
image_resource:
source:
repository: concourse/oci-build-task
type: registry-image
outputs:
- name: coredeps-image
path: image
params:
CONTEXT: deltachat-core-rust/scripts/coredeps
UNPACK_ROOTFS: "true"
BUILD_ARG_BASEIMAGE: quay.io/pypa/manylinux2014_aarch64
platform: linux
caches:
- path: cache
run:
path: build
# Use built image to build python wheels
- task: build-wheels
image: coredeps-image
config:
inputs:
- name: deltachat-core-rust-release
path: .
outputs:
- name: py-wheels
path: ./python/.docker-tox/wheelhouse/
platform: linux
run:
path: bash
args:
- -exc
- |
scripts/run_all.sh
# Upload aarch64 wheels
- task: upload-wheels
config:
inputs:
- name: py-wheels
image_resource:
type: registry-image
source:
repository: debian
platform: linux
run:
path: sh
args:
- -ec
- |
apt-get update -y
apt-get install -y --no-install-recommends python3-pip python3-setuptools python3-venv
python3 -m venv env
env/bin/pip install --upgrade pip
env/bin/pip install devpi
env/bin/devpi use https://m.devpi.net/dc/master
env/bin/devpi login ((devpi.login)) --password ((devpi.password))
env/bin/devpi upload py-wheels/*manylinux201*
- name: python-musl-x86_64
plan:
- get: deltachat-core-rust
- get: deltachat-core-rust-release
trigger: true
# Build manylinux image with additional dependencies
- task: build-coredeps
privileged: true
config:
inputs:
# Building the latest, not tagged coredeps
- name: deltachat-core-rust
image_resource:
source:
repository: concourse/oci-build-task
type: registry-image
outputs:
- name: coredeps-image
path: image
params:
CONTEXT: deltachat-core-rust/scripts/coredeps
UNPACK_ROOTFS: "true"
BUILD_ARG_BASEIMAGE: quay.io/pypa/musllinux_1_1_x86_64
platform: linux
caches:
- path: cache
run:
path: build
# Use built image to build python wheels
- task: build-wheels
image: coredeps-image
config:
inputs:
- name: deltachat-core-rust-release
path: .
outputs:
- name: py-wheels
path: ./python/.docker-tox/wheelhouse/
platform: linux
run:
path: bash
args:
- -exc
- |
scripts/run_all.sh
# Upload musl x86_64 wheels
- task: upload-wheels
config:
inputs:
- name: py-wheels
image_resource:
type: registry-image
source:
repository: debian
platform: linux
run:
path: sh
args:
- -ec
- |
apt-get update -y
apt-get install -y --no-install-recommends python3-pip python3-setuptools python3-venv
python3 -m venv env
env/bin/pip install --upgrade pip
env/bin/pip install devpi
env/bin/devpi use https://m.devpi.net/dc/master
env/bin/devpi login ((devpi.login)) --password ((devpi.password))
env/bin/devpi upload py-wheels/*musllinux_1_1_x86_64*
- name: python-musl-aarch64
plan:
- get: deltachat-core-rust
- get: deltachat-core-rust-release
trigger: true
# Build manylinux image with additional dependencies
- task: build-coredeps
privileged: true
config:
inputs:
# Building the latest, not tagged coredeps
- name: deltachat-core-rust
image_resource:
source:
repository: concourse/oci-build-task
type: registry-image
outputs:
- name: coredeps-image
path: image
params:
CONTEXT: deltachat-core-rust/scripts/coredeps
UNPACK_ROOTFS: "true"
BUILD_ARG_BASEIMAGE: quay.io/pypa/musllinux_1_1_aarch64
platform: linux
caches:
- path: cache
run:
path: build
# Use built image to build python wheels
- task: build-wheels
image: coredeps-image
config:
inputs:
- name: deltachat-core-rust-release
path: .
outputs:
- name: py-wheels
path: ./python/.docker-tox/wheelhouse/
platform: linux
run:
path: bash
args:
- -exc
- |
scripts/run_all.sh
# Upload musl aarch64 wheels
- task: upload-wheels
config:
inputs:
- name: py-wheels
image_resource:
type: registry-image
source:
repository: debian
platform: linux
run:
path: sh
args:
- -ec
- |
apt-get update -y
apt-get install -y --no-install-recommends python3-pip python3-setuptools python3-venv
python3 -m venv env
env/bin/pip install --upgrade pip
env/bin/pip install devpi
env/bin/devpi use https://m.devpi.net/dc/master
env/bin/devpi login ((devpi.login)) --password ((devpi.password))
env/bin/devpi upload py-wheels/*musllinux_1_1_aarch64*

View File

@@ -0,0 +1,9 @@
ARG BASEIMAGE=quay.io/pypa/manylinux2014_x86_64
#ARG BASEIMAGE=quay.io/pypa/musllinux_1_1_x86_64
#ARG BASEIMAGE=quay.io/pypa/manylinux2014_aarch64
FROM $BASEIMAGE
RUN pipx install tox
COPY install-rust.sh /scripts/
RUN /scripts/install-rust.sh
RUN if command -v yum; then yum install -y perl-IPC-Cmd; fi

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
# Install Rust
#
# Path from https://forge.rust-lang.org/infra/other-installation-methods.html
#
# Avoid using rustup here as it depends on reading /proc/self/exe and
# has problems running under QEMU.
RUST_VERSION=1.94.0
ARCH="$(uname -m)"
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu
curl "https://static.rust-lang.org/dist/rust-${RUST_VERSION}-$ARCH-unknown-linux-$LIBC.tar.gz" | tar xz
cd "rust-${RUST_VERSION}-$ARCH-unknown-linux-$LIBC"
./install.sh --prefix=/usr --components=rustc,cargo,"rust-std-$ARCH-unknown-linux-$LIBC"
rustc --version
cd ..
rm -fr "rust-${RUST_VERSION}-$ARCH-unknown-linux-$LIBC"

View File

@@ -6,7 +6,7 @@ set -euo pipefail
export TZ=UTC
# Provider database revision.
REV=2cba4b72f4c6e6417b83ba549aff7781be5f166c
REV=996c4bc82be5a7404f70b185ff062da33bfa98d9
CORE_ROOT="$PWD"
TMP="$(mktemp -d)"

View File

@@ -20,6 +20,86 @@ Description-Content-Type: text/markdown
"""
def build_source_package(version, filename):
with tarfile.open(filename, "w:gz") as pkg:
def pack(name, contents):
contents = contents.encode()
tar_info = tarfile.TarInfo(f"deltachat_rpc_server-{version}/{name}")
tar_info.mode = 0o644
tar_info.size = len(contents)
pkg.addfile(tar_info, BytesIO(contents))
pack("PKG-INFO", metadata_contents(version))
pack(
"pyproject.toml",
f"""[build-system]
requires = ["setuptools==68.2.2", "pip"]
build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-server"
version = "{version}"
[project.scripts]
deltachat-rpc-server = "deltachat_rpc_server:main"
""",
)
pack(
"setup.py",
f"""
import sys
from setuptools import setup, find_packages
from distutils.cmd import Command
from setuptools.command.install import install
from setuptools.command.build import build
import subprocess
import platform
import tempfile
from zipfile import ZipFile
from pathlib import Path
import shutil
class BuildCommand(build):
def run(self):
tmpdir = tempfile.mkdtemp()
subprocess.run(
[
sys.executable,
"-m",
"pip",
"download",
"--no-input",
"--timeout",
"1000",
"--platform",
"musllinux_1_1_" + platform.machine(),
"--only-binary=:all:",
"deltachat-rpc-server=={version}",
],
cwd=tmpdir,
)
wheel_path = next(Path(tmpdir).glob("*.whl"))
with ZipFile(wheel_path, "r") as wheel:
exe_path = wheel.extract("deltachat_rpc_server/deltachat-rpc-server", "src")
Path(exe_path).chmod(0o700)
wheel.extract("deltachat_rpc_server/__init__.py", "src")
shutil.rmtree(tmpdir)
return super().run()
setup(
cmdclass={{"build": BuildCommand}},
package_data={{"deltachat_rpc_server": ["deltachat-rpc-server"]}},
)
""",
)
pack("src/deltachat_rpc_server/__init__.py", "")
def build_wheel(version, binary, tag, windows=False):
filename = f"deltachat_rpc_server-{version}-{tag}.whl"
@@ -88,19 +168,23 @@ def main():
with Path("Cargo.toml").open("rb") as fp:
cargo_manifest = tomllib.load(fp)
version = cargo_manifest["package"]["version"]
arch = sys.argv[1]
executable = sys.argv[2]
tags = arch2tags[arch]
if arch in ["win32", "win64"]:
build_wheel(
version,
executable,
f"py3-none-{tags}",
windows=True,
)
if sys.argv[1] == "source":
filename = f"deltachat_rpc_server-{version}.tar.gz"
build_source_package(version, filename)
else:
build_wheel(version, executable, f"py3-none-{tags}")
arch = sys.argv[1]
executable = sys.argv[2]
tags = arch2tags[arch]
if arch in ["win32", "win64"]:
build_wheel(
version,
executable,
f"py3-none-{tags}",
windows=True,
)
else:
build_wheel(version, executable, f"py3-none-{tags}")
main()

18
spec.md
View File

@@ -28,6 +28,7 @@ to implement typical messenger functions.
- [Voice messages](#voice-messages)
- [Reactions](#reactions)
- [Attaching a contact to a message](#attaching-a-contact-to-a-message)
- [Transitioning to a new e-mail address (AEAP)](#transitioning-to-a-new-e-mail-address-aeap)
- [Miscellaneous](#miscellaneous)
- [Sync messages](#sync-messages)
@@ -614,6 +615,23 @@ chatmail clients mark the gossiped key
as indirectly verified.
# Transitioning to a new e-mail address (AEAP)
When receiving a message:
- If the key exists, but belongs to another address
- AND there is a `Chat-Version` header
- AND the message is signed correctly
- AND the From address is (also) in the encrypted (and therefore signed) headers
- AND the message timestamp is newer than the contact's `lastseen`
(to prevent changing the address back when messages arrive out of order)
(this condition is not that important
since we will have eventual consistency even without it):
Replace the contact in _all_ groups,
possibly deduplicate the members list,
and add a system message to all of these chats.
# Miscellaneous
Messengers SHOULD use the header `In-Reply-To` as usual.

View File

@@ -8,7 +8,6 @@ use std::sync::Arc;
use anyhow::{Context as _, Result, bail, ensure};
use async_channel::{self, Receiver, Sender};
use futures::FutureExt as _;
use futures::future;
use futures_lite::FutureExt as _;
use serde::{Deserialize, Serialize};
use tokio::fs;
@@ -23,7 +22,6 @@ use tokio::time::{Duration, sleep};
use crate::context::{Context, ContextBuilder};
use crate::events::{Event, EventEmitter, EventType, Events};
use crate::location;
use crate::log::warn;
use crate::push::PushSubscriber;
use crate::stock_str::StockStrings;
@@ -538,38 +536,6 @@ impl Accounts {
self.push_subscriber.set_device_token(token).await;
Ok(())
}
/// Sets location for all accounts.
///
/// Returns true if location should still be streamed.
pub async fn set_location(&self, latitude: f64, longitude: f64, accuracy: f64) -> Result<bool> {
let continue_streaming = future::try_join_all(self.accounts.iter().map(
|(account_id, account)| async move {
location::set(account, latitude, longitude, accuracy)
.await
.with_context(|| format!("Failed to set location for account {account_id}"))
},
))
.await?
.into_iter()
.any(|continue_streaming| continue_streaming);
Ok(continue_streaming)
}
/// Stops sending locations to all chats.
pub async fn stop_sending_locations(&self) -> Result<()> {
future::try_join_all(
self.accounts
.iter()
.map(|(account_id, account)| async move {
location::stop_sending(account).await.with_context(|| {
format!("Failed to stop sending locations for account {account_id}")
})
}),
)
.await?;
Ok(())
}
}
/// Configuration file name.
@@ -794,7 +760,7 @@ impl Config {
.with_push_subscriber(push_subscriber.clone())
.build()
.await
.with_context(|| format!("failed to create context from file {dbfile:?}"))?;
.with_context(|| format!("failed to create context from file {:?}", &dbfile))?;
// Try to open without a passphrase,
// but do not return an error if account is passphare-protected.
ctx.open("".to_string()).await?;

View File

@@ -4,8 +4,9 @@
use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
use anyhow::{Context as _, Result, bail};
use anyhow::{Context as _, Error, Result, bail};
use crate::key::{DcKey, SignedPublicKey};
@@ -27,8 +28,10 @@ impl fmt::Display for EncryptPreference {
}
}
impl EncryptPreference {
fn new(s: &str) -> Result<Self> {
impl FromStr for EncryptPreference {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"mutual" => Ok(EncryptPreference::Mutual),
"nopreference" => Ok(EncryptPreference::NoPreference),
@@ -82,8 +85,10 @@ impl fmt::Display for Aheader {
}
}
impl Aheader {
pub(crate) fn from_str(s: &str) -> Result<Self> {
impl FromStr for Aheader {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
let mut attributes: BTreeMap<String, String> = s
.split(';')
.filter_map(|a| {
@@ -111,7 +116,7 @@ impl Aheader {
let prefer_encrypt = attributes
.remove("prefer-encrypt")
.and_then(|raw| EncryptPreference::new(&raw).ok())
.and_then(|raw| raw.parse().ok())
.unwrap_or_default();
let verified = attributes.remove("_verified").is_some();
@@ -139,9 +144,8 @@ mod tests {
#[test]
fn test_from_str() -> Result<()> {
let h = Aheader::from_str(&format!(
"addr=me@mail.com; prefer-encrypt=mutual; keydata={RAWKEY}"
))?;
let h: Aheader =
format!("addr=me@mail.com; prefer-encrypt=mutual; keydata={RAWKEY}").parse()?;
assert_eq!(h.addr, "me@mail.com");
assert_eq!(h.prefer_encrypt, EncryptPreference::Mutual);
@@ -153,7 +157,7 @@ mod tests {
#[test]
fn test_from_str_reset() -> Result<()> {
let raw = format!("addr=reset@example.com; prefer-encrypt=reset; keydata={RAWKEY}");
let h = Aheader::from_str(&raw)?;
let h: Aheader = raw.parse()?;
assert_eq!(h.addr, "reset@example.com");
assert_eq!(h.prefer_encrypt, EncryptPreference::NoPreference);
@@ -163,7 +167,7 @@ mod tests {
#[test]
fn test_from_str_non_critical() -> Result<()> {
let raw = format!("addr=me@mail.com; _foo=one; _bar=two; keydata={RAWKEY}");
let h = Aheader::from_str(&raw)?;
let h: Aheader = raw.parse()?;
assert_eq!(h.addr, "me@mail.com");
assert_eq!(h.prefer_encrypt, EncryptPreference::NoPreference);
@@ -173,7 +177,7 @@ mod tests {
#[test]
fn test_from_str_superflous_critical() {
let raw = format!("addr=me@mail.com; _foo=one; _bar=two; other=me; keydata={RAWKEY}");
assert!(Aheader::from_str(&raw).is_err());
assert!(raw.parse::<Aheader>().is_err());
}
#[test]

561
src/authres.rs Normal file
View File

@@ -0,0 +1,561 @@
//! Parsing and handling of the Authentication-Results header.
//! See the comment on [`handle_authres`] for more.
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::fmt;
use std::sync::LazyLock;
use anyhow::Result;
use deltachat_contact_tools::EmailAddress;
use mailparse::MailHeaderMap;
use mailparse::ParsedMail;
use crate::config::Config;
use crate::context::Context;
use crate::headerdef::HeaderDef;
/// `authres` is short for the Authentication-Results header, defined in
/// <https://datatracker.ietf.org/doc/html/rfc8601>, which contains info
/// about whether DKIM and SPF passed.
///
/// To mitigate From forgery, we remember for each sending domain whether it is known
/// to have valid DKIM. If an email from such a domain comes with invalid DKIM,
/// we don't allow changing the autocrypt key.
///
/// See <https://github.com/deltachat/deltachat-core-rust/issues/3507>.
pub(crate) async fn handle_authres(
context: &Context,
mail: &ParsedMail<'_>,
from: &str,
) -> Result<DkimResults> {
let from_domain = match EmailAddress::new(from) {
Ok(email) => email.domain,
Err(e) => {
return Err(anyhow::format_err!("invalid email {from}: {e:#}"));
}
};
let authres = parse_authres_headers(&mail.get_headers(), &from_domain);
update_authservid_candidates(context, &authres).await?;
compute_dkim_results(context, authres).await
}
#[derive(Debug)]
pub(crate) struct DkimResults {
/// Whether DKIM passed for this particular e-mail.
pub dkim_passed: bool,
}
impl fmt::Display for DkimResults {
fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
write!(fmt, "DKIM Results: Passed={}", self.dkim_passed)?;
Ok(())
}
}
type AuthservId = String;
#[derive(Debug, PartialEq)]
enum DkimResult {
/// The header explicitly said that DKIM passed
Passed,
/// The header explicitly said that DKIM failed
Failed,
/// The header didn't say anything about DKIM; this might mean that it wasn't
/// checked, but it might also mean that it failed. This is because some providers
/// (e.g. ik.me, mail.ru, posteo.de) don't add `dkim=none` to their
/// Authentication-Results if there was no DKIM.
Nothing,
}
type ParsedAuthresHeaders = Vec<(AuthservId, DkimResult)>;
fn parse_authres_headers(
headers: &mailparse::headers::Headers<'_>,
from_domain: &str,
) -> ParsedAuthresHeaders {
let mut res = Vec::new();
for header_value in headers.get_all_values(HeaderDef::AuthenticationResults.into()) {
let header_value = remove_comments(&header_value);
if let Some(mut authserv_id) = header_value.split(';').next() {
if authserv_id.contains(char::is_whitespace) || authserv_id.is_empty() {
// Outlook violates the RFC by not adding an authserv-id at all, which we notice
// because there is whitespace in the first identifier before the ';'.
// Authentication-Results-parsing still works securely because they remove incoming
// Authentication-Results headers.
// We just use an arbitrary authserv-id, it will work for Outlook, and in general,
// with providers not implementing the RFC correctly, someone can trick us
// into thinking that an incoming email is DKIM-correct, anyway.
// The most important thing here is that we have some valid `authserv_id`.
authserv_id = "invalidAuthservId";
}
let dkim_passed = parse_one_authres_header(&header_value, from_domain);
res.push((authserv_id.to_string(), dkim_passed));
}
}
res
}
/// The headers can contain comments that look like this:
/// ```text
/// Authentication-Results: (this is a comment) gmx.net; (another; comment) dkim=pass;
/// ```
fn remove_comments(header: &str) -> Cow<'_, str> {
// In Pomsky, this is:
// "(" Codepoint* lazy ")"
// See https://playground.pomsky-lang.org/?text=%22(%22%20Codepoint*%20lazy%20%22)%22
static RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\([\s\S]*?\)").unwrap());
RE.replace_all(header, " ")
}
/// Parses a single Authentication-Results header, like:
///
/// ```text
/// Authentication-Results: gmx.net; dkim=pass header.i=@slack.com
/// ```
fn parse_one_authres_header(header_value: &str, from_domain: &str) -> DkimResult {
if let Some((before_dkim_part, dkim_to_end)) = header_value.split_once("dkim=") {
// Check that the character right before `dkim=` is a space or a tab
// so that we wouldn't e.g. mistake `notdkim=pass` for `dkim=pass`
if before_dkim_part.ends_with(' ') || before_dkim_part.ends_with('\t') {
let dkim_part = dkim_to_end.split(';').next().unwrap_or_default();
let dkim_parts: Vec<_> = dkim_part.split_whitespace().collect();
if let Some(&"pass") = dkim_parts.first() {
// DKIM headers contain a header.d or header.i field
// that says which domain signed. We have to check ourselves
// that this is the same domain as in the From header.
let header_d: &str = &format!("header.d={}", &from_domain);
let header_i: &str = &format!("header.i=@{}", &from_domain);
if dkim_parts.contains(&header_d) || dkim_parts.contains(&header_i) {
// We have found a `dkim=pass` header!
return DkimResult::Passed;
}
} else {
// dkim=fail, dkim=none, ...
return DkimResult::Failed;
}
}
}
DkimResult::Nothing
}
/// ## About authserv-ids
///
/// After having checked DKIM, our email server adds an Authentication-Results header.
///
/// Now, an attacker could just add an Authentication-Results header that says dkim=pass
/// in order to make us think that DKIM was correct in their From-forged email.
///
/// In order to prevent this, each email server adds its authserv-id to the
/// Authentication-Results header, e.g. Testrun's authserv-id is `testrun.org`, Gmail's
/// is `mx.google.com`. When Testrun gets a mail delivered from outside, it will then
/// remove any Authentication-Results headers whose authserv-id is also `testrun.org`.
///
/// We need to somehow find out the authserv-id(s) of our email server, so that
/// we can use the Authentication-Results with the right authserv-id.
///
/// ## What this function does
///
/// When receiving an email, this function is called and updates the candidates for
/// our server's authserv-id, i.e. what we think our server's authserv-id is.
///
/// Usually, every incoming email has Authentication-Results with our server's
/// authserv-id, so, the intersection of the existing authserv-ids and the incoming
/// authserv-ids for our server's authserv-id is a good guess for our server's
/// authserv-id. When this intersection is empty, we assume that the authserv-id has
/// changed and start over with the new authserv-ids.
///
/// See [`handle_authres`].
async fn update_authservid_candidates(
context: &Context,
authres: &ParsedAuthresHeaders,
) -> Result<()> {
let mut new_ids: BTreeSet<&str> = authres
.iter()
.map(|(authserv_id, _dkim_passed)| authserv_id.as_str())
.collect();
if new_ids.is_empty() {
// The incoming message doesn't contain any authentication results, maybe it's a
// self-sent or a mailer-daemon message
return Ok(());
}
let old_config = context.get_config(Config::AuthservIdCandidates).await?;
let old_ids = parse_authservid_candidates_config(&old_config);
let intersection: BTreeSet<&str> = old_ids.intersection(&new_ids).copied().collect();
if !intersection.is_empty() {
new_ids = intersection;
}
// If there were no AuthservIdCandidates previously, just start with
// the ones from the incoming email
if old_ids != new_ids {
let new_config = new_ids.into_iter().collect::<Vec<_>>().join(" ");
context
.set_config_internal(Config::AuthservIdCandidates, Some(&new_config))
.await?;
}
Ok(())
}
/// Use the parsed authres and the authservid candidates to compute whether DKIM passed
/// and whether a keychange should be allowed.
///
/// We track in the `sending_domains` table whether we get positive Authentication-Results
/// for mails from a contact (meaning that their provider properly authenticates against
/// our provider).
///
/// Once a contact is known to come with positive Authentication-Resutls (dkim: pass),
/// we don't accept Autocrypt key changes if they come with negative Authentication-Results.
async fn compute_dkim_results(
context: &Context,
mut authres: ParsedAuthresHeaders,
) -> Result<DkimResults> {
let mut dkim_passed = false;
let ids_config = context.get_config(Config::AuthservIdCandidates).await?;
let ids = parse_authservid_candidates_config(&ids_config);
// Remove all foreign authentication results
authres.retain(|(authserv_id, _dkim_passed)| ids.contains(authserv_id.as_str()));
if authres.is_empty() {
// If the authentication results are empty, then our provider doesn't add them
// and an attacker could just add their own Authentication-Results, making us
// think that DKIM passed. So, in this case, we can as well assume that DKIM passed.
dkim_passed = true;
} else {
for (_authserv_id, current_dkim_passed) in authres {
match current_dkim_passed {
DkimResult::Passed => {
dkim_passed = true;
break;
}
DkimResult::Failed => {
dkim_passed = false;
break;
}
DkimResult::Nothing => {
// Continue looking for an Authentication-Results header
}
}
}
}
Ok(DkimResults { dkim_passed })
}
fn parse_authservid_candidates_config(config: &Option<String>) -> BTreeSet<&str> {
config
.as_deref()
.map(|c| c.split_whitespace().collect())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use tokio::fs;
use tokio::io::AsyncReadExt;
use super::*;
use crate::mimeparser;
use crate::test_utils::TestContext;
use crate::test_utils::TestContextManager;
use crate::tools;
#[test]
fn test_remove_comments() {
let header = "Authentication-Results: mx3.messagingengine.com;
dkim=pass (1024-bit rsa key sha256) header.d=riseup.net;"
.to_string();
assert_eq!(
remove_comments(&header),
"Authentication-Results: mx3.messagingengine.com;
dkim=pass header.d=riseup.net;"
);
let header = ") aaa (".to_string();
assert_eq!(remove_comments(&header), ") aaa (");
let header = "((something weird) no comment".to_string();
assert_eq!(remove_comments(&header), " no comment");
let header = "🎉(🎉(🎉))🎉(".to_string();
assert_eq!(remove_comments(&header), "🎉 )🎉(");
// Comments are allowed to include whitespace
let header = "(com\n\t\r\nment) no comment (comment)".to_string();
assert_eq!(remove_comments(&header), " no comment ");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_authentication_results() -> Result<()> {
let t = TestContext::new().await;
t.configure_addr("alice@gmx.net").await;
let bytes = b"Authentication-Results: gmx.net; dkim=pass header.i=@slack.com
Authentication-Results: gmx.net; dkim=pass header.i=@amazonses.com";
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
assert_eq!(
actual,
vec![
("gmx.net".to_string(), DkimResult::Passed),
("gmx.net".to_string(), DkimResult::Nothing)
]
);
let bytes = b"Authentication-Results: gmx.net; notdkim=pass header.i=@slack.com
Authentication-Results: gmx.net; notdkim=pass header.i=@amazonses.com";
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
assert_eq!(
actual,
vec![
("gmx.net".to_string(), DkimResult::Nothing),
("gmx.net".to_string(), DkimResult::Nothing)
]
);
let bytes = b"Authentication-Results: gmx.net; dkim=pass header.i=@amazonses.com";
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
assert_eq!(actual, vec![("gmx.net".to_string(), DkimResult::Nothing)],);
// Weird Authentication-Results from Outlook without an authserv-id
let bytes = b"Authentication-Results: spf=pass (sender IP is 40.92.73.85)
smtp.mailfrom=hotmail.com; dkim=pass (signature was verified)
header.d=hotmail.com;dmarc=pass action=none
header.from=hotmail.com;compauth=pass reason=100";
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "hotmail.com");
// At this point, the most important thing to test is that there are no
// authserv-ids with whitespace in them.
assert_eq!(
actual,
vec![("invalidAuthservId".to_string(), DkimResult::Passed)]
);
let bytes = b"Authentication-Results: gmx.net; dkim=none header.i=@slack.com
Authentication-Results: gmx.net; dkim=pass header.i=@slack.com";
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
assert_eq!(
actual,
vec![
("gmx.net".to_string(), DkimResult::Failed),
("gmx.net".to_string(), DkimResult::Passed)
]
);
// ';' in comments
let bytes = b"Authentication-Results: mx1.riseup.net;
dkim=pass (1024-bit key; unprotected) header.d=yandex.ru header.i=@yandex.ru header.a=rsa-sha256 header.s=mail header.b=avNJu6sw;
dkim-atps=neutral";
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "yandex.ru");
assert_eq!(
actual,
vec![("mx1.riseup.net".to_string(), DkimResult::Passed)]
);
let bytes = br#"Authentication-Results: box.hispanilandia.net;
dkim=fail reason="signature verification failed" (2048-bit key; secure) header.d=disroot.org header.i=@disroot.org header.b="kqh3WUKq";
dkim-atps=neutral
Authentication-Results: box.hispanilandia.net; dmarc=pass (p=quarantine dis=none) header.from=disroot.org
Authentication-Results: box.hispanilandia.net; spf=pass smtp.mailfrom=adbenitez@disroot.org"#;
let mail = mailparse::parse_mail(bytes)?;
let actual = parse_authres_headers(&mail.get_headers(), "disroot.org");
assert_eq!(
actual,
vec![
("box.hispanilandia.net".to_string(), DkimResult::Failed),
("box.hispanilandia.net".to_string(), DkimResult::Nothing),
("box.hispanilandia.net".to_string(), DkimResult::Nothing),
]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_update_authservid_candidates() -> Result<()> {
let t = TestContext::new_alice().await;
update_authservid_candidates_test(&t, &["mx3.messagingengine.com"]).await;
let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
assert_eq!(candidates, "mx3.messagingengine.com");
// "mx4.messagingengine.com" seems to be the new authserv-id, DC should accept it
update_authservid_candidates_test(&t, &["mx4.messagingengine.com"]).await;
let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
assert_eq!(candidates, "mx4.messagingengine.com");
// A message without any Authentication-Results headers shouldn't remove all
// candidates since it could be a mailer-daemon message or so
update_authservid_candidates_test(&t, &[]).await;
let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
assert_eq!(candidates, "mx4.messagingengine.com");
update_authservid_candidates_test(&t, &["mx4.messagingengine.com", "someotherdomain.com"])
.await;
let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
assert_eq!(candidates, "mx4.messagingengine.com");
Ok(())
}
/// Calls update_authservid_candidates(), meant for using in a test.
///
/// update_authservid_candidates() only looks at the keys of its
/// `authentication_results` parameter. So, this function takes `incoming_ids`
/// and adds some AuthenticationResults to get the HashMap we need.
async fn update_authservid_candidates_test(context: &Context, incoming_ids: &[&str]) {
let v = incoming_ids
.iter()
.map(|id| (id.to_string(), DkimResult::Passed))
.collect();
update_authservid_candidates(context, &v).await.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_realworld_authentication_results() -> Result<()> {
let mut test_failed = false;
let dir = tools::read_dir("test-data/message/dkimchecks-2022-09-28/".as_ref())
.await
.unwrap();
let mut bytes = Vec::new();
for entry in dir {
if !entry.file_type().await.unwrap().is_dir() {
continue;
}
let self_addr = entry.file_name().into_string().unwrap();
let self_domain = EmailAddress::new(&self_addr).unwrap().domain;
let authres_parsing_works = [
"ik.me",
"web.de",
"posteo.de",
"gmail.com",
"hotmail.com",
"mail.ru",
"aol.com",
"yahoo.com",
"icloud.com",
"fastmail.com",
"mail.de",
"outlook.com",
"gmx.de",
"testrun.org",
]
.contains(&self_domain.as_str());
let t = TestContext::new().await;
t.configure_addr(&self_addr).await;
if !authres_parsing_works {
println!("========= Receiving as {} =========", &self_addr);
}
// Simulate receiving all emails once, so that we have the correct authserv-ids
let mut dir = tools::read_dir(&entry.path()).await.unwrap();
// The ordering in which the emails are received can matter;
// the test _should_ pass for every ordering.
dir.sort_by_key(|d| d.file_name());
//rand::seq::SliceRandom::shuffle(&mut dir[..], &mut rand::rng());
for entry in &dir {
let mut file = fs::File::open(entry.path()).await?;
bytes.clear();
file.read_to_end(&mut bytes).await.unwrap();
let mail = mailparse::parse_mail(&bytes)?;
let from = &mimeparser::get_from(&mail.headers).unwrap().addr;
let res = handle_authres(&t, &mail, from).await?;
let from_domain = EmailAddress::new(from).unwrap().domain;
// delta.blinzeln.de and gmx.de have invalid DKIM, so the DKIM check should fail
let expected_result = (from_domain != "delta.blinzeln.de") && (from_domain != "gmx.de")
// These are (fictional) forged emails where the attacker added a fake
// Authentication-Results before sending the email
&& from != "forged-authres-added@example.com"
// Other forged emails
&& !from.starts_with("forged");
if res.dkim_passed != expected_result {
if authres_parsing_works {
println!(
"!!!!!! FAILURE Receiving {:?} wrong result: !!!!!!",
entry.path(),
);
test_failed = true;
}
println!("From {}: {}", from_domain, res.dkim_passed);
}
}
}
assert!(!test_failed);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_handle_authres() {
let t = TestContext::new().await;
// Even if the format is wrong and parsing fails, handle_authres() shouldn't
// return an Err because this would prevent the message from being added
// to the database and downloaded again and again
let bytes = b"From: invalid@from.com
Authentication-Results: dkim=";
let mail = mailparse::parse_mail(bytes).unwrap();
handle_authres(&t, &mail, "invalid@rom.com").await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_authres_in_mailinglist_ignored() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
// Bob knows his server's authserv-id
bob.set_config(Config::AuthservIdCandidates, Some("example.net"))
.await?;
let alice_bob_chat = alice.create_chat(&bob).await;
let mut sent = alice.send_text(alice_bob_chat.id, "hellooo").await;
sent.payload
.insert_str(0, "List-Post: <mailto:deltachat-community.example.net>\n");
sent.payload
.insert_str(0, "Authentication-Results: example.net; dkim=fail\n");
let rcvd = bob.recv_msg(&sent).await;
assert!(rcvd.error.is_none());
// Do the same without the mailing list header, this time the failed
// authres isn't ignored
let mut sent = alice
.send_text(alice_bob_chat.id, "hellooo without mailing list")
.await;
sent.payload
.insert_str(0, "Authentication-Results: example.net; dkim=fail\n");
let rcvd = bob.recv_msg(&sent).await;
// The message info should contain a warning:
assert!(
rcvd.id
.get_info(&bob)
.await
.unwrap()
.contains("DKIM Results: Passed=false")
);
Ok(())
}
}

View File

@@ -284,6 +284,10 @@ impl<'a> BlobObject<'a> {
///
/// Recoding is only done for [`Viewtype::Image`]. For [`Viewtype::File`], if it's a correct
/// image, `*viewtype` is set to [`Viewtype::Image`].
///
/// On some platforms images are passed to Core as [`Viewtype::Sticker`]. We recheck if the
/// image is a true sticker assuming that it must have at least one fully transparent corner,
/// otherwise `*viewtype` is set to [`Viewtype::Image`].
pub async fn check_or_recode_image(
&mut self,
context: &Context,
@@ -461,7 +465,7 @@ impl<'a> BlobObject<'a> {
));
}
target_wh = target_wh * 7 / 8;
target_wh = target_wh * 2 / 3;
} else {
info!(
context,

View File

@@ -445,6 +445,7 @@ async fn test_recode_image_balanced_png() {
.await
.unwrap();
// This will be sent as Image, see [`BlobObject::check_or_recode_image()`] for explanation.
SendImageCheckMediaquality {
viewtype: Viewtype::Sticker,
media_quality_config: "0",
@@ -452,7 +453,6 @@ async fn test_recode_image_balanced_png() {
extension: "png",
original_width: 1920,
original_height: 1080,
res_viewtype: Some(Viewtype::Sticker),
compressed_width: 1920,
compressed_height: 1080,
..Default::default()
@@ -734,6 +734,8 @@ async fn test_send_gif_as_sticker() -> Result<()> {
let chat = alice.get_self_chat().await;
let sent = alice.send_msg(chat.id, &mut msg).await;
let msg = Message::load_from_db(alice, sent.sender_msg_id).await?;
// Message::force_sticker() wasn't used, still Viewtype::Sticker is preserved because of the
// extension.
assert_eq!(msg.get_viewtype(), Viewtype::Sticker);
Ok(())
}

View File

@@ -218,10 +218,11 @@ impl Context {
let wait = RINGING_SECONDS;
let context = self.get_weak_context();
task::spawn(Context::emit_end_call_if_unaccepted(
task::spawn(Context::finalize_call_if_unaccepted(
context,
wait.try_into()?,
call.id,
true, // Doesn't matter for outgoing calls
));
Ok(call.id)
@@ -314,39 +315,67 @@ impl Context {
Ok(())
}
async fn emit_end_call_if_unaccepted(
async fn finalize_call_if_unaccepted(
context: WeakContext,
wait: u64,
call_id: MsgId,
can_call_me: bool,
) -> Result<()> {
sleep(Duration::from_secs(wait)).await;
let context = context.upgrade()?;
let Some(mut call) = context.load_call_by_id(call_id).await? else {
warn!(
context,
"emit_end_call_if_unaccepted is called with {call_id} which does not refer to a call."
"finalize_call_if_unaccepted is called with {call_id} which does not refer to a call."
);
return Ok(());
};
if !call.is_accepted() && !call.is_ended() {
let (msg_id, chat_id) = (call_id, call.msg.chat_id);
if call.is_incoming() {
call.mark_as_canceled(&context).await?;
let missed_call_str = stock_str::missed_call(&context);
call.update_text(&context, &missed_call_str).await?;
if can_call_me {
context.emit_event(EventType::CallMissed { msg_id, chat_id });
}
} else {
call.mark_as_ended(&context).await?;
let canceled_call_str = stock_str::canceled_call(&context);
call.update_text(&context, &canceled_call_str).await?;
}
if can_call_me {
context.emit_event(EventType::CallEnded { msg_id, chat_id });
}
context.emit_msgs_changed(call.msg.chat_id, call_id);
context.emit_event(EventType::CallEnded {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
});
}
Ok(())
}
async fn can_call_me(&self, from_id: ContactId) -> Result<bool> {
Ok(match who_can_call_me(self).await? {
WhoCanCallMe::Contacts => ChatIdBlocked::lookup_by_contact(self, from_id)
.await?
.is_some_and(|chat_id_blocked| {
match chat_id_blocked.blocked {
Blocked::Not => true,
Blocked::Yes | Blocked::Request => {
// Do not notify about incoming calls
// from contact requests and blocked contacts.
//
// User can still access the call and accept it
// via the chat in case of contact requests.
false
}
}
}),
WhoCanCallMe::Everybody => ChatIdBlocked::lookup_by_contact(self, from_id)
.await?
.is_none_or(|chat_id_blocked| chat_id_blocked.blocked != Blocked::Yes),
WhoCanCallMe::Nobody => false,
})
}
pub(crate) async fn handle_call_msg(
&self,
call_id: MsgId,
@@ -360,50 +389,33 @@ impl Context {
};
if call.is_incoming() {
if call.is_stale() {
let missed_call_str = stock_str::missed_call(self);
call.update_text(self, &missed_call_str).await?;
self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call
let call_str = match call.is_stale() {
true => stock_str::missed_call(self),
false => stock_str::incoming_call(self, call.has_video_initially()),
};
call.update_text(self, &call_str).await?;
let (msg_id, chat_id) = (call_id, call.msg.chat_id);
let can_call_me = self.can_call_me(from_id).await?;
if !can_call_me {
} else if call.is_stale() {
self.emit_event(EventType::CallMissed { msg_id, chat_id });
} else {
let incoming_call_str =
stock_str::incoming_call(self, call.has_video_initially());
call.update_text(self, &incoming_call_str).await?;
self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified
let can_call_me = match who_can_call_me(self).await? {
WhoCanCallMe::Contacts => ChatIdBlocked::lookup_by_contact(self, from_id)
.await?
.is_some_and(|chat_id_blocked| {
match chat_id_blocked.blocked {
Blocked::Not => true,
Blocked::Yes | Blocked::Request => {
// Do not notify about incoming calls
// from contact requests and blocked contacts.
//
// User can still access the call and accept it
// via the chat in case of contact requests.
false
}
}
}),
WhoCanCallMe::Everybody => ChatIdBlocked::lookup_by_contact(self, from_id)
.await?
.is_none_or(|chat_id_blocked| chat_id_blocked.blocked != Blocked::Yes),
WhoCanCallMe::Nobody => false,
};
if can_call_me {
self.emit_event(EventType::IncomingCall {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
place_call_info: call.place_call_info.to_string(),
has_video: call.has_video_initially(),
});
}
self.emit_event(EventType::IncomingCall {
msg_id,
chat_id,
place_call_info: call.place_call_info.to_string(),
has_video: call.has_video_initially(),
});
}
self.emit_msgs_changed(chat_id, msg_id);
if !call.is_stale() {
let wait = call.remaining_ring_seconds();
let context = self.get_weak_context();
task::spawn(Context::emit_end_call_if_unaccepted(
task::spawn(Context::finalize_call_if_unaccepted(
context,
wait.try_into()?,
call.msg.id,
can_call_me,
));
}
} else {
@@ -455,6 +467,7 @@ impl Context {
return Ok(());
}
let (msg_id, chat_id) = (call_id, call.msg.chat_id);
if !call.is_accepted() {
if call.is_incoming() {
if from_id == ContactId::SELF {
@@ -465,6 +478,9 @@ impl Context {
call.mark_as_canceled(self).await?;
let missed_call_str = stock_str::missed_call(self);
call.update_text(self, &missed_call_str).await?;
if self.can_call_me(from_id).await? {
self.emit_event(EventType::CallMissed { msg_id, chat_id });
}
}
} else {
// outgoing
@@ -482,12 +498,8 @@ impl Context {
call.mark_as_ended(self).await?;
call.update_text_duration(self).await?;
}
self.emit_event(EventType::CallEnded { msg_id, chat_id });
self.emit_msgs_changed(call.msg.chat_id, call_id);
self.emit_event(EventType::CallEnded {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
});
}
_ => {}
}

View File

@@ -4,8 +4,8 @@ use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::message::MessageState;
use crate::receive_imf::receive_imf;
use crate::test_utils;
use crate::test_utils::{TestContext, TestContextManager};
use crate::tools::SystemTime;
struct CallSetup {
pub alice: TestContext,
@@ -491,6 +491,9 @@ async fn test_caller_cancels_call() -> Result<()> {
// Bob receives the ending message
bob.recv_msg_trash(&sent3).await;
assert_text(&bob, bob_call.id, "Missed call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallMissed { .. }))
.await;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -503,6 +506,9 @@ async fn test_caller_cancels_call() -> Result<()> {
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Missed call").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallMissed { .. }))
.await;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -511,6 +517,95 @@ async fn test_caller_cancels_call() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stale_call() -> Result<()> {
let mut tcm = TestContextManager::new();
for accepted in [false, true] {
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
info!(bob, "Alice is accepted: {accepted}.");
if accepted {
bob.create_chat(alice).await;
}
let alice_chat = alice.create_chat(bob).await;
alice
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true)
.await?;
let sent1 = alice.pop_sent_msg().await;
SystemTime::shift(Duration::from_secs(3600));
let bob_call = bob.recv_msg(&sent1).await;
let EventType::MsgsChanged { msg_id, chat_id } = bob
.evtracker
.get_matching(|evt| {
matches!(
evt,
EventType::MsgsChanged { .. }
| EventType::CallMissed { .. }
| EventType::CallEnded { .. }
)
})
.await
else {
unreachable!();
};
assert_eq!(chat_id, bob_call.chat_id);
let msg = Message::load_from_db(bob, msg_id).await?;
assert_eq!(msg.text, stock_str::messages_e2ee_info_msg(bob));
if accepted {
let EventType::CallMissed { msg_id, chat_id } = bob
.evtracker
.get_matching(|evt| {
matches!(
evt,
EventType::CallMissed { .. } | EventType::CallEnded { .. }
)
})
.await
else {
unreachable!();
};
assert_eq!(msg_id, bob_call.id);
assert_eq!(chat_id, bob_call.chat_id);
}
let EventType::MsgsChanged { msg_id, chat_id } = bob
.evtracker
.get_matching(|evt| {
matches!(
evt,
EventType::MsgsChanged { .. }
| EventType::CallMissed { .. }
| EventType::CallEnded { .. }
)
})
.await
else {
unreachable!();
};
assert_eq!(msg_id, bob_call.id);
assert_eq!(chat_id, bob_call.chat_id);
let evt = bob
.evtracker
.get_matching_opt(bob, |evt| {
matches!(
evt,
EventType::CallMissed { .. } | EventType::CallEnded { .. }
)
})
.await;
assert!(evt.is_none());
assert_text(bob, bob_call.id, "Missed call").await?;
assert_eq!(call_state(bob, bob_call.id).await?, CallState::Missed);
// Test that message summary says it is a missed call.
let bob_call_msg = Message::load_from_db(bob, bob_call.id).await?;
let summary = bob_call_msg.get_summary(bob, None).await?;
assert_eq!(summary.text, "🎥 Missed call");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_stale_call() -> Result<()> {
// a call started now is not stale
@@ -635,23 +730,20 @@ async fn test_forward_call() -> Result<()> {
async fn test_end_text_call() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let encrypted_message = test_utils::encrypt_raw_message(
bob,
&[alice],
b"From: bob@example.net\r\n\
To: alice@example.org\r\n\
Message-ID: <first@example.net>\r\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\r\n\
Chat-Version: 1.0\r\n\
\r\n\
Hello\r\n",
let received1 = receive_imf(
alice,
b"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <first@example.net>\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
Chat-Version: 1.0\n\
\n\
Hello\n",
false,
)
.await?;
let received1 = receive_imf(alice, encrypted_message.as_bytes(), false)
.await?
.unwrap();
.await?
.unwrap();
assert_eq!(received1.msg_ids.len(), 1);
let msg = Message::load_from_db(alice, received1.msg_ids[0])
.await
@@ -660,23 +752,21 @@ async fn test_end_text_call() -> Result<()> {
// Receiving "Call ended" message that refers
// to the text message does not result in an error.
let encrypted_message2 = test_utils::encrypt_raw_message(
bob,
&[alice],
b"From: bob@example.net\r\n\
To: alice@example.org\r\n\
Message-ID: <second@example.net>\r\n\
Date: Sun, 22 Mar 2020 23:37:57 +0000\r\n\
In-Reply-To: <first@example.net>\r\n\
Chat-Version: 1.0\r\n\
Chat-Content: call-ended\r\n\
\r\n\
Call ended\r\n",
let received2 = receive_imf(
alice,
b"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <second@example.net>\n\
Date: Sun, 22 Mar 2020 23:37:57 +0000\n\
In-Reply-To: <first@example.net>\n\
Chat-Version: 1.0\n\
Chat-Content: call-ended\n\
\n\
Call ended\n",
false,
)
.await?;
let received2 = receive_imf(alice, encrypted_message2.as_bytes(), false)
.await?
.unwrap();
.await?
.unwrap();
assert_eq!(received2.msg_ids.len(), 1);
assert_eq!(received2.chat_id, DC_CHAT_ID_TRASH);

View File

@@ -1,11 +1,12 @@
//! # Chat module.
use std::cmp;
use std::collections::{BTreeSet, HashMap};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fmt;
use std::io::Cursor;
use std::marker::Sync;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Context as _, Result, anyhow, bail, ensure};
@@ -22,9 +23,8 @@ 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, EDITED_PREFIX,
TIMESTAMP_SENT_TOLERANCE,
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, EDITED_PREFIX, TIMESTAMP_SENT_TOLERANCE,
};
use crate::contact::{self, Contact, ContactId, Origin};
use crate::context::Context;
@@ -32,10 +32,9 @@ use crate::debug_logging::maybe_set_logging_xdc;
use crate::download::{
DownloadState, PRE_MSG_ATTACHMENT_SIZE_THRESHOLD, PRE_MSG_SIZE_WARNING_THRESHOLD,
};
use crate::ensure_and_debug_assert_eq;
use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers};
use crate::events::EventType;
use crate::key::{Fingerprint, self_fingerprint};
use crate::key::self_fingerprint;
use crate::location;
use crate::log::{LogExt, warn};
use crate::logged_debug_assert;
@@ -1189,6 +1188,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
/// prefer plaintext emails.
///
/// To get more verbose summary for a contact, including its key fingerprint, use [`Contact::get_encrinfo`].
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_encryption_info(self, context: &Context) -> Result<String> {
let chat = Chat::load_from_db(context, self).await?;
if !chat.is_encrypted(context).await? {
@@ -1211,8 +1211,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
);
let fingerprint = contact
.fingerprint()
.context("Contact does not have a fingerprint in encrypted chat")?
.human_readable();
.context("Contact does not have a fingerprint in encrypted chat")?;
if let Some(public_key) = contact.public_key(context).await? {
if let Some(relay_addrs) = addresses_from_public_key(&public_key) {
let relays = relay_addrs.join(",");
@@ -1753,6 +1752,7 @@ impl Chat {
///
/// If `update_msg_id` is set, that record is reused;
/// if `update_msg_id` is None, a new record is created.
#[expect(clippy::arithmetic_side_effects)]
async fn prepare_msg_raw(
&mut self,
context: &Context,
@@ -1783,8 +1783,9 @@ impl Chat {
);
bail!("Cannot set message, contact for {} not found.", self.id);
}
} else if self.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 {
ensure_and_debug_assert_eq!(self.typ, Chattype::Group,);
} else if matches!(self.typ, Chattype::Group | Chattype::OutBroadcast)
&& self.param.get_int(Param::Unpromoted).unwrap_or_default() == 1
{
msg.param.set_int(Param::AttachChatAvatarAndDescription, 1);
self.param
.remove(Param::Unpromoted)
@@ -2467,7 +2468,10 @@ async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
.with_context(|| format!("attachment missing for message of type #{}", msg.viewtype))?;
let mut maybe_image = false;
if msg.viewtype == Viewtype::File || msg.viewtype == Viewtype::Image {
if msg.viewtype == Viewtype::File
|| msg.viewtype == Viewtype::Image
|| msg.viewtype == Viewtype::Sticker && !msg.param.exists(Param::ForceSticker)
{
// Correct the type, take care not to correct already very special
// formats as GIF or VOICE.
//
@@ -2475,7 +2479,12 @@ async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
// - from FILE to AUDIO/VIDEO/IMAGE
// - from FILE/IMAGE to GIF */
if let Some((better_type, _)) = message::guess_msgtype_from_suffix(msg) {
if better_type == Viewtype::Image {
if msg.viewtype == Viewtype::Sticker {
if better_type != Viewtype::Image {
// UIs don't want conversions of `Sticker` to anything other than `Image`.
msg.param.set_int(Param::ForceSticker, 1);
}
} else if better_type == Viewtype::Image {
maybe_image = true;
} else if better_type != Viewtype::Webxdc
|| context
@@ -2495,7 +2504,10 @@ async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
if msg.viewtype == Viewtype::Vcard {
msg.try_set_vcard(context, &blob.to_abs_path()).await?;
}
if msg.viewtype == Viewtype::File && maybe_image || msg.viewtype == Viewtype::Image {
if msg.viewtype == Viewtype::File && maybe_image
|| msg.viewtype == Viewtype::Image
|| msg.viewtype == Viewtype::Sticker && !msg.param.exists(Param::ForceSticker)
{
let new_name = blob
.check_or_recode_image(context, msg.get_filename(), &mut msg.viewtype)
.await?;
@@ -2529,7 +2541,7 @@ async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
// running numbers, etc.
let filename: String = match viewtype_orig {
Viewtype::Voice => format!(
"voice-messsage_{}.{suffix}",
"voice-messsage_{}.{}",
chrono::Utc
.timestamp_opt(msg.timestamp_sort, 0)
.single()
@@ -2537,9 +2549,10 @@ async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
|| "YY-mm-dd_hh:mm:ss".to_string(),
|ts| ts.format("%Y-%m-%d_%H-%M-%S").to_string()
),
&suffix
),
Viewtype::Image | Viewtype::Gif => format!(
"image_{}.{suffix}",
"image_{}.{}",
chrono::Utc
.timestamp_opt(msg.timestamp_sort, 0)
.single()
@@ -2547,9 +2560,10 @@ async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
|| "YY-mm-dd_hh:mm:ss".to_string(),
|ts| ts.format("%Y-%m-%d_%H-%M-%S").to_string(),
),
&suffix,
),
Viewtype::Video => format!(
"video_{}.{suffix}",
"video_{}.{}",
chrono::Utc
.timestamp_opt(msg.timestamp_sort, 0)
.single()
@@ -2557,6 +2571,7 @@ async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
|| "YY-mm-dd_hh:mm:ss".to_string(),
|ts| ts.format("%Y-%m-%d_%H-%M-%S").to_string()
),
&suffix
),
_ => filename,
};
@@ -2610,10 +2625,10 @@ pub async fn send_msg(context: &Context, chat_id: ChatId, msg: &mut Message) ->
"chat_id cannot be a special chat: {chat_id}"
);
if msg.state != MessageState::Undefined {
if msg.state != MessageState::Undefined && msg.state != MessageState::OutPreparing {
msg.param.remove(Param::GuaranteeE2ee);
msg.param.remove(Param::ForcePlaintext);
// create_send_msg_jobs() will update `param` in the db.
msg.update_param(context).await?;
}
// protect all system messages against RTLO attacks
@@ -2718,16 +2733,7 @@ async fn prepare_send_msg(
None
};
if msg.state == MessageState::Undefined
// Legacy SecureJoin "v*-request" messages are unencrypted.
&& msg.param.get_cmd() != SystemMessage::SecurejoinMessage
&& chat.is_encrypted(context).await?
{
msg.param.set_int(Param::GuaranteeE2ee, 1);
if !msg.id.is_unset() {
msg.update_param(context).await?;
}
}
// ... then change the MessageState in the message object
msg.state = MessageState::OutPending;
msg.timestamp_sort = create_smeared_timestamp(context);
@@ -2773,13 +2779,15 @@ async fn render_mime_message_and_pre_message(
let mut mimefactory_post_msg = mimefactory.clone();
mimefactory_post_msg.set_as_post_message();
let rendered_msg = Box::pin(mimefactory_post_msg.render(context))
let rendered_msg = mimefactory_post_msg
.render(context)
.await
.context("Failed to render post-message")?;
let mut mimefactory_pre_msg = mimefactory;
mimefactory_pre_msg.set_as_pre_message_for(&rendered_msg);
let rendered_pre_msg = Box::pin(mimefactory_pre_msg.render(context))
let rendered_pre_msg = mimefactory_pre_msg
.render(context)
.await
.context("pre-message failed to render")?;
@@ -2794,7 +2802,7 @@ async fn render_mime_message_and_pre_message(
Ok((Some(rendered_pre_msg), rendered_msg))
} else {
Ok((None, Box::pin(mimefactory.render(context)).await?))
Ok((None, mimefactory.render(context).await?))
}
}
@@ -2823,12 +2831,7 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
.await?;
}
let needs_encryption = msg.param.get_bool(Param::GuaranteeE2ee).unwrap_or_default()
|| (!msg
.param
.get_bool(Param::ForcePlaintext)
.unwrap_or_default()
&& context.get_config_bool(Config::ForceEncryption).await?);
let needs_encryption = msg.param.get_bool(Param::GuaranteeE2ee).unwrap_or_default();
let mimefactory = match MimeFactory::from_msg(context, msg.clone()).await {
Ok(mf) => mf,
Err(err) => {
@@ -2895,26 +2898,11 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
}
if needs_encryption && !rendered_msg.is_encrypted {
let addr = context.get_config(Config::ConfiguredAddr).await?;
let text = stock_str::unencrypted_email(
/* unrecoverable */
message::set_msg_failed(
context,
addr.unwrap_or_default()
.split('@')
.nth(1)
.unwrap_or_default(),
)
.await;
message::set_msg_failed(context, msg, &text).await?;
add_info_msg_with_cmd(
context,
msg.chat_id,
&text,
SystemMessage::InvalidUnencryptedMail,
Some(msg.timestamp_sort),
msg.timestamp_sort,
None,
None,
None,
msg,
"End-to-end-encryption unavailable unexpectedly.",
)
.await?;
bail!(
@@ -2943,26 +2931,11 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
msg.param.remove(Param::GuaranteeE2ee);
}
msg.subject.clone_from(&rendered_msg.subject);
// Sort the message to the bottom. Employ `msgs_index7` to compute `timestamp`.
context
.sql
.execute(
"
UPDATE msgs SET
timestamp=(
SELECT MAX(timestamp) FROM msgs INDEXED BY msgs_index7 WHERE
-- From `InFresh` to `OutDelivered` inclusive, except `OutDraft`.
state IN(10,13,16,18,20,24,26) AND
hidden IN(0,1) AND
chat_id=? AND
id<=?
),
pre_rfc724_mid=?, subject=?, param=?
WHERE id=?
",
"UPDATE msgs SET pre_rfc724_mid=?, subject=?, param=? WHERE id=?",
(
msg.chat_id,
msg.id,
&msg.pre_rfc724_mid,
&msg.subject,
msg.param.to_string(),
@@ -3027,6 +3000,7 @@ pub async fn send_text_msg(
}
/// Sends chat members a request to edit the given message's text.
#[expect(clippy::arithmetic_side_effects)]
pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: String) -> Result<()> {
let mut original_msg = Message::load_from_db(context, msg_id).await?;
ensure!(
@@ -3111,6 +3085,9 @@ async fn donation_request_maybe(context: &Context) -> Result<()> {
/// Chat message list request options.
#[derive(Debug)]
pub struct MessageListOptions {
/// Return only info messages.
pub info_only: bool,
/// Add day markers before each date regarding the local timezone.
pub add_daymarker: bool,
}
@@ -3121,27 +3098,56 @@ pub async fn get_chat_msgs(context: &Context, chat_id: ChatId) -> Result<Vec<Cha
context,
chat_id,
MessageListOptions {
info_only: false,
add_daymarker: false,
},
)
.await
}
/// Returns messages belonging to the chat according to the given options,
/// sorted by oldest message first.
/// Returns messages belonging to the chat according to the given options.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_chat_msgs_ex(
context: &Context,
chat_id: ChatId,
options: MessageListOptions,
) -> Result<Vec<ChatItem>> {
let MessageListOptions { add_daymarker } = options;
let process_row = |row: &rusqlite::Row| {
Ok((
row.get::<_, i64>("timestamp")?,
row.get::<_, MsgId>("id")?,
false,
))
let MessageListOptions {
info_only,
add_daymarker,
} = options;
let process_row = if info_only {
|row: &rusqlite::Row| {
// is_info logic taken from Message.is_info()
let params = row.get::<_, String>("param")?;
let (from_id, to_id) = (
row.get::<_, ContactId>("from_id")?,
row.get::<_, ContactId>("to_id")?,
);
let is_info_msg: bool = from_id == ContactId::INFO
|| to_id == ContactId::INFO
|| match Params::from_str(&params) {
Ok(p) => {
let cmd = p.get_cmd();
cmd != SystemMessage::Unknown && cmd != SystemMessage::AutocryptSetupMessage
}
_ => false,
};
Ok((
row.get::<_, i64>("timestamp")?,
row.get::<_, MsgId>("id")?,
!is_info_msg,
))
}
} else {
|row: &rusqlite::Row| {
Ok((
row.get::<_, i64>("timestamp")?,
row.get::<_, MsgId>("id")?,
false,
))
}
};
let process_rows = |rows: rusqlite::AndThenRows<_>| {
// It is faster to sort here rather than
@@ -3176,18 +3182,39 @@ pub async fn get_chat_msgs_ex(
Ok(ret)
};
let items = context
.sql
.query_map(
"SELECT m.id AS id, m.timestamp AS timestamp
let items = if info_only {
context
.sql
.query_map(
// GLOB is used here instead of LIKE because it is case-sensitive
"SELECT m.id AS id, m.timestamp AS timestamp, m.param AS param, m.from_id AS from_id, m.to_id AS to_id
FROM msgs m
WHERE m.chat_id=?
AND m.hidden=0
AND (
m.param GLOB '*\nS=*' OR param GLOB 'S=*'
OR m.from_id == ?
OR m.to_id == ?
);",
(chat_id, ContactId::INFO, ContactId::INFO),
process_row,
process_rows,
)
.await?
} else {
context
.sql
.query_map(
"SELECT m.id AS id, m.timestamp AS timestamp
FROM msgs m
WHERE m.chat_id=?
AND m.hidden=0;",
(chat_id,),
process_row,
process_rows,
)
.await?;
(chat_id,),
process_row,
process_rows,
)
.await?
};
Ok(items)
}
@@ -3626,6 +3653,9 @@ pub(crate) async fn create_group_ex(
/// because the word "channel" already appears a lot in the code,
/// which would make it hard to grep for it.
///
/// After creation, the chat contains no recipients and is in _unpromoted_ state;
/// see [`create_group`] for more information on the unpromoted state.
///
/// Returns the created chat's id.
pub async fn create_broadcast(context: &Context, chat_name: String) -> Result<ChatId> {
let grpid = create_id();
@@ -3657,20 +3687,17 @@ pub(crate) async fn create_out_broadcast_ex(
|row| row.get(0),
)?;
ensure!(cnt == 0, "{cnt} chats exist with grpid {grpid}");
let mut params: Params = Params::new();
params.update_timestamp(Param::GroupNameTimestamp, time())?;
t.execute(
"INSERT INTO chats
(type, name, name_normalized, grpid, created_timestamp, param)
VALUES(?, ?, ?, ?, ?, ?)",
(type, name, name_normalized, grpid, created_timestamp)
VALUES(?, ?, ?, ?, ?)",
(
Chattype::OutBroadcast,
&chat_name,
normalize_text(&chat_name),
&grpid,
timestamp,
params.to_string(),
),
)?;
let chat_id = ChatId::new(t.last_insert_rowid().try_into()?);
@@ -3736,21 +3763,19 @@ pub(crate) async fn update_chat_contacts_table(
context: &Context,
timestamp: i64,
id: ChatId,
contacts: &BTreeSet<ContactId>,
contacts: &HashSet<ContactId>,
) -> Result<()> {
// See add_to_chat_contacts_table() for reasoning.
let limit = cmp::max(time().saturating_add(TIMESTAMP_SENT_TOLERANCE), timestamp);
context
.sql
.transaction(move |transaction| {
// Bump `remove_timestamp` even for members from `contacts`.
// Bump `remove_timestamp` to at least `now`
// even for members from `contacts`.
// We add members from `contacts` back below.
transaction.execute(
"UPDATE chats_contacts SET
add_timestamp=MIN(add_timestamp, ?1),
remove_timestamp=MAX(MIN(remove_timestamp,?1), MIN(add_timestamp,?1)+1, ?)
"UPDATE chats_contacts
SET remove_timestamp=MAX(add_timestamp+1, ?)
WHERE chat_id=?",
(limit, timestamp, id),
(timestamp, id),
)?;
if !contacts.is_empty() {
@@ -3762,8 +3787,9 @@ pub(crate) async fn update_chat_contacts_table(
)?;
for contact_id in contacts {
// We bumped `remove_timestamp` for existing rows above,
// so on conflict it is enough to set `add_timestamp = remove_timestamp`.
// We bumped `add_timestamp` for existing rows above,
// so on conflict it is enough to set `add_timestamp = remove_timestamp`
// and this guarantees that `add_timestamp` is no less than `timestamp`.
statement.execute((id, contact_id, timestamp))?;
}
}
@@ -3780,24 +3806,17 @@ pub(crate) async fn add_to_chat_contacts_table(
chat_id: ChatId,
contact_ids: &[ContactId],
) -> Result<()> {
// Our clock may be slow, so limit stored timestamps with `timestamp` if it's bigger. This way
// we only cap remote timestamps if, in addition, remote changes arrive reordered or we do local
// changes. Also allow some tolerance, moreover, previous removals might lend time from the
// future.
let limit = cmp::max(time().saturating_add(TIMESTAMP_SENT_TOLERANCE), timestamp);
context
.sql
.transaction(move |transaction| {
let mut add_statement = transaction.prepare(
"INSERT INTO chats_contacts (chat_id, contact_id, add_timestamp) VALUES(?1, ?2, ?3)
ON CONFLICT (chat_id, contact_id)
DO UPDATE SET
remove_timestamp=MIN(remove_timestamp, ?4),
add_timestamp=MIN(MAX(add_timestamp,remove_timestamp,?3), ?4)",
DO UPDATE SET add_timestamp=MAX(remove_timestamp, ?3)",
)?;
for contact_id in contact_ids {
add_statement.execute((chat_id, contact_id, timestamp, limit))?;
add_statement.execute((chat_id, contact_id, timestamp))?;
}
Ok(())
})
@@ -3808,34 +3827,26 @@ pub(crate) async fn add_to_chat_contacts_table(
/// Removes a contact from the chat
/// by updating the `remove_timestamp`.
/// Returns whether the contact has been a chat member recently. If so, a removal message should be
/// sent.
pub(crate) async fn remove_from_chat_contacts_table(
context: &Context,
chat_id: ChatId,
contact_id: ContactId,
) -> Result<bool> {
) -> Result<()> {
let now = time();
// See add_to_chat_contacts_table() for reasoning.
let limit = now.saturating_add(TIMESTAMP_SENT_TOLERANCE);
let is_past_member = context
context
.sql
.execute(
"UPDATE chats_contacts SET
add_timestamp=MIN(add_timestamp, ?1),
remove_timestamp=MAX(MIN(remove_timestamp,?1), MIN(add_timestamp,?1)+1, ?)
"UPDATE chats_contacts
SET remove_timestamp=MAX(add_timestamp+1, ?)
WHERE chat_id=? AND contact_id=?",
(limit, now, chat_id, contact_id),
(now, chat_id, contact_id),
)
.await?
> 0;
Ok(is_past_member)
.await?;
Ok(())
}
/// Removes a contact from the chat
/// without leaving a trace in the db.
/// Returns whether the contact was removed, even if it was a past contact. If so, a removal message
/// should be sent if the removal is issued by this device.
/// without leaving a trace.
///
/// Note that if we call this function,
/// and then receive a message from another device
@@ -3845,17 +3856,17 @@ pub(crate) async fn remove_from_chat_contacts_table_without_trace(
context: &Context,
chat_id: ChatId,
contact_id: ContactId,
) -> Result<bool> {
let removed = context
) -> Result<()> {
context
.sql
.execute(
"DELETE FROM chats_contacts
WHERE chat_id=? AND contact_id=?",
(chat_id, contact_id),
)
.await?
> 0;
Ok(removed)
.await?;
Ok(())
}
/// Adds a contact to the chat.
@@ -3978,47 +3989,9 @@ pub(crate) async fn add_contact_to_chat_ex(
if sync.into() {
chat.sync_contacts(context).await.log_err(context).ok();
}
if chat.typ == Chattype::OutBroadcast {
resend_last_msgs(context, chat.id, &contact)
.await
.log_err(context)
.ok();
}
Ok(true)
}
async fn resend_last_msgs(context: &Context, chat_id: ChatId, to_contact: &Contact) -> Result<()> {
let msgs: Vec<MsgId> = context
.sql
.query_map_vec(
"
SELECT id
FROM msgs
WHERE chat_id=?
AND hidden=0
AND NOT ( -- Exclude info and system messages
param GLOB '*\nS=*' OR param GLOB 'S=*'
OR from_id=?
OR to_id=?
)
AND type!=?
ORDER BY timestamp DESC, id DESC LIMIT ?",
(
chat_id,
ContactId::INFO,
ContactId::INFO,
Viewtype::Webxdc,
constants::N_MSGS_TO_NEW_BROADCAST_MEMBER,
),
|row: &rusqlite::Row| Ok(row.get::<_, MsgId>(0)?),
)
.await?
.into_iter()
.rev()
.collect();
resend_msgs_ex(context, &msgs, to_contact.fingerprint()).await
}
/// Returns true if an avatar should be attached in the given chat.
///
/// This function does not check if the avatar is set.
@@ -4158,59 +4131,61 @@ pub async fn remove_contact_from_chat(
delete_broadcast_secret(context, chat_id).await?;
}
ensure!(
matches!(
chat.typ,
Chattype::Group | Chattype::OutBroadcast | Chattype::InBroadcast
),
"Cannot remove members from non-group chats."
);
if matches!(
chat.typ,
Chattype::Group | Chattype::OutBroadcast | Chattype::InBroadcast
) {
if !chat.is_self_in_chat(context).await? {
let err_msg = format!(
"Cannot remove contact {contact_id} from chat {chat_id}: self not in group."
);
context.emit_event(EventType::ErrorSelfNotInGroup(err_msg.clone()));
bail!("{err_msg}");
} else {
let mut sync = Nosync;
if !chat.is_self_in_chat(context).await? {
let err_msg =
format!("Cannot remove contact {contact_id} from chat {chat_id}: self not in group.");
context.emit_event(EventType::ErrorSelfNotInGroup(err_msg.clone()));
bail!("{err_msg}");
}
if chat.is_promoted() && chat.typ != Chattype::OutBroadcast {
remove_from_chat_contacts_table(context, chat_id, contact_id).await?;
} else {
remove_from_chat_contacts_table_without_trace(context, chat_id, contact_id).await?;
}
let mut sync = Nosync;
// We do not return an error if the contact does not exist in the database.
// This allows to delete dangling references to deleted contacts
// in case of the database becoming inconsistent due to a bug.
if let Some(contact) = Contact::get_by_id_optional(context, contact_id).await? {
if chat.is_promoted() {
let addr = contact.get_addr();
let fingerprint = contact.fingerprint().map(|f| f.hex());
let removed = if chat.is_promoted() && chat.typ != Chattype::OutBroadcast {
remove_from_chat_contacts_table(context, chat_id, contact_id).await?
} else {
remove_from_chat_contacts_table_without_trace(context, chat_id, contact_id).await?
};
if !removed {
return Ok(());
}
// We do not return an error if the contact does not exist in the database.
// This allows to delete dangling references to deleted contacts
// in case of the database becoming inconsistent due to a bug.
if let Some(contact) = Contact::get_by_id_optional(context, contact_id).await? {
if chat.is_promoted() {
let addr = contact.get_addr();
let fingerprint = contact.fingerprint().map(|f| f.hex());
let res =
send_member_removal_msg(context, &chat, contact_id, addr, fingerprint.as_deref())
let res = send_member_removal_msg(
context,
&chat,
contact_id,
addr,
fingerprint.as_deref(),
)
.await;
if contact_id == ContactId::SELF {
res?;
} else if let Err(e) = res {
warn!(
context,
"remove_contact_from_chat({chat_id}, {contact_id}): send_msg() failed: {e:#}."
);
if contact_id == ContactId::SELF {
res?;
} else if let Err(e) = res {
warn!(
context,
"remove_contact_from_chat({chat_id}, {contact_id}): send_msg() failed: {e:#}."
);
}
} else {
sync = Sync;
}
}
context.emit_event(EventType::ChatModified(chat_id));
if sync.into() {
chat.sync_contacts(context).await.log_err(context).ok();
}
} else {
sync = Sync;
}
}
context.emit_event(EventType::ChatModified(chat_id));
if sync.into() {
chat.sync_contacts(context).await.log_err(context).ok();
} else {
bail!("Cannot remove members from non-group chats.");
}
Ok(())
@@ -4572,7 +4547,6 @@ pub async fn forward_msgs_2ctx(
msg.state = MessageState::OutPending;
msg.rfc724_mid = create_outgoing_rfc724_mid();
msg.pre_rfc724_mid.clear();
msg.timestamp_sort = curr_timestamp;
chat.prepare_msg_raw(ctx_dst, &mut msg, None).await?;
@@ -4620,6 +4594,7 @@ pub async fn save_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
/// the copy contains a reference to the original message
/// as well as to the original chat in case the original message gets deleted.
/// Returns data needed to add a `SaveMessage` sync item.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn save_copy_in_self_talk(
context: &Context,
src_msg_id: MsgId,
@@ -4686,26 +4661,10 @@ pub(crate) async fn save_copy_in_self_talk(
Ok(msg.rfc724_mid)
}
/// Resends given messages to members of the corresponding chats.
/// Resends given messages with the same Message-ID.
///
/// This is primarily intended to make existing webxdcs available to new chat members.
pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
resend_msgs_ex(context, msg_ids, None).await
}
/// Resends given messages to a contact with fingerprint `to_fingerprint` or, if it's `None`, to
/// members of the corresponding chats.
///
/// NB: Actually `to_fingerprint` is only passed for `OutBroadcast` chats when a new member is
/// added. Regarding webxdcs: It is not trivial to resend only the own status updates,
/// and it is not trivial to resend them only to the newly-joined member,
/// so that for now, [`resend_last_msgs`] does not automatically resend webxdcs at all.
pub(crate) async fn resend_msgs_ex(
context: &Context,
msg_ids: &[MsgId],
to_fingerprint: Option<Fingerprint>,
) -> Result<()> {
let to_fingerprint = to_fingerprint.map(|f| f.hex());
let mut msgs: Vec<Message> = Vec::new();
for msg_id in msg_ids {
let msg = Message::load_from_db(context, *msg_id).await?;
@@ -4724,17 +4683,10 @@ pub(crate) async fn resend_msgs_ex(
| MessageState::OutFailed
| MessageState::OutDelivered
| MessageState::OutMdnRcvd => {
// Broadcast owners shouldn't see spinners on messages being auto-re-sent to new
// subscribers (otherwise big channel owners will see spinners most of the time).
if to_fingerprint.is_none() {
message::update_msg_state(context, msg.id, MessageState::OutPending).await?;
}
message::update_msg_state(context, msg.id, MessageState::OutPending).await?
}
msg_state => bail!("Unexpected message state {msg_state}"),
}
if let Some(to_fingerprint) = &to_fingerprint {
msg.param.set(Param::Arg4, to_fingerprint.clone());
}
if create_send_msg_jobs(context, &mut msg).await?.is_empty() {
continue;
}
@@ -4746,8 +4698,7 @@ pub(crate) async fn resend_msgs_ex(
chat_id: msg.chat_id,
msg_id: msg.id,
});
// The event only matters if the message is last in the chat.
// But it's probably too expensive check, and UIs anyways need to debounce.
// 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 msg.viewtype == Viewtype::Webxdc {
@@ -4940,6 +4891,8 @@ pub async fn was_device_msg_ever_added(context: &Context, label: &str) -> Result
// no wrong information are shown in the device chat
// - deletion in `devmsglabels` makes sure,
// deleted messages are reset and useful messages can be added again
// - we reset the config-option `QuotaExceeding`
// that is used as a helper to drive the corresponding device message.
pub(crate) async fn delete_and_reset_all_device_msgs(context: &Context) -> Result<()> {
context
.sql
@@ -4955,6 +4908,9 @@ pub(crate) async fn delete_and_reset_all_device_msgs(context: &Context) -> Resul
(),
)
.await?;
context
.set_config_internal(Config::QuotaExceeding, None)
.await?;
Ok(())
}
@@ -5071,7 +5027,7 @@ async fn set_contacts_by_addrs(context: &Context, id: ChatId, addrs: &[String])
chat.typ == Chattype::OutBroadcast,
"{id} is not a broadcast list",
);
let mut contacts = BTreeSet::new();
let mut contacts = HashSet::new();
for addr in addrs {
let contact_addr = ContactAddress::new(addr)?;
let contact = Contact::add_or_lookup(context, "", &contact_addr, Origin::Hidden)
@@ -5079,7 +5035,7 @@ async fn set_contacts_by_addrs(context: &Context, id: ChatId, addrs: &[String])
.0;
contacts.insert(contact);
}
let contacts_old = BTreeSet::<ContactId>::from_iter(get_chat_contacts(context, id).await?);
let contacts_old = HashSet::<ContactId>::from_iter(get_chat_contacts(context, id).await?);
if contacts == contacts_old {
return Ok(());
}

View File

@@ -3,16 +3,14 @@ use std::sync::Arc;
use super::*;
use crate::Event;
use crate::chatlist::get_archived_cnt;
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS, N_MSGS_TO_NEW_BROADCAST_MEMBER};
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS};
use crate::ephemeral::Timer;
use crate::headerdef::HeaderDef;
use crate::imex::{ImexMode, has_backup, imex};
use crate::message::{Message, MessengerMessage, delete_msgs};
use crate::mimeparser::{self, MimeMessage};
use crate::qr::{Qr, check_qr};
use crate::receive_imf::receive_imf;
use crate::securejoin::{get_securejoin_qr, join_securejoin};
use crate::test_utils;
use crate::test_utils::{
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, E2EE_INFO_MSGS, TestContext, TestContextManager,
TimeShiftFalsePositiveNote, sync,
@@ -1161,51 +1159,46 @@ async fn test_archive() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unarchive_if_muted() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let bob = &tcm.bob().await;
let t = TestContext::new_alice().await;
let msg_from_bob = async |num: u32| {
let encrypted_message = test_utils::encrypt_raw_message(
bob,
&[t],
async fn msg_from_bob(t: &TestContext, num: u32) -> Result<()> {
receive_imf(
t,
format!(
"From: bob@example.net\r\n\
To: alice@example.org\r\n\
Message-ID: <{num}@example.org>\r\n\
Chat-Version: 1.0\r\n\
Date: Sun, 22 Mar 2022 19:37:57 +0000\r\n\
\r\n\
hello\r\n"
"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <{num}@example.org>\n\
Chat-Version: 1.0\n\
Date: Sun, 22 Mar 2022 19:37:57 +0000\n\
\n\
hello\n"
)
.as_bytes(),
false,
)
.await
.unwrap();
receive_imf(t, encrypted_message.as_bytes(), false)
.await
.unwrap();
};
.await?;
Ok(())
}
msg_from_bob(1).await;
msg_from_bob(&t, 1).await?;
let chat_id = t.get_last_msg().await.get_chat_id();
chat_id.accept(t).await?;
chat_id.set_visibility(t, ChatVisibility::Archived).await?;
assert_eq!(get_archived_cnt(t).await?, 1);
chat_id.accept(&t).await?;
chat_id.set_visibility(&t, ChatVisibility::Archived).await?;
assert_eq!(get_archived_cnt(&t).await?, 1);
// not muted chat is unarchived on receiving a message
msg_from_bob(2).await;
assert_eq!(get_archived_cnt(t).await?, 0);
msg_from_bob(&t, 2).await?;
assert_eq!(get_archived_cnt(&t).await?, 0);
// forever muted chat is not unarchived on receiving a message
chat_id.set_visibility(t, ChatVisibility::Archived).await?;
set_muted(t, chat_id, MuteDuration::Forever).await?;
msg_from_bob(3).await;
assert_eq!(get_archived_cnt(t).await?, 1);
chat_id.set_visibility(&t, ChatVisibility::Archived).await?;
set_muted(&t, chat_id, MuteDuration::Forever).await?;
msg_from_bob(&t, 3).await?;
assert_eq!(get_archived_cnt(&t).await?, 1);
// otherwise muted chat is not unarchived on receiving a message
set_muted(
t,
&t,
chat_id,
MuteDuration::Until(
SystemTime::now()
@@ -1214,12 +1207,12 @@ async fn test_unarchive_if_muted() -> Result<()> {
),
)
.await?;
msg_from_bob(4).await;
assert_eq!(get_archived_cnt(t).await?, 1);
msg_from_bob(&t, 4).await?;
assert_eq!(get_archived_cnt(&t).await?, 1);
// expired mute will unarchive the chat
set_muted(
t,
&t,
chat_id,
MuteDuration::Until(
SystemTime::now()
@@ -1228,20 +1221,20 @@ async fn test_unarchive_if_muted() -> Result<()> {
),
)
.await?;
msg_from_bob(5).await;
assert_eq!(get_archived_cnt(t).await?, 0);
msg_from_bob(&t, 5).await?;
assert_eq!(get_archived_cnt(&t).await?, 0);
// no unarchiving on sending to muted chat or on adding info messages to muted chat
chat_id.set_visibility(t, ChatVisibility::Archived).await?;
set_muted(t, chat_id, MuteDuration::Forever).await?;
send_text_msg(t, chat_id, "out".to_string()).await?;
add_info_msg(t, chat_id, "info").await?;
assert_eq!(get_archived_cnt(t).await?, 1);
chat_id.set_visibility(&t, ChatVisibility::Archived).await?;
set_muted(&t, chat_id, MuteDuration::Forever).await?;
send_text_msg(&t, chat_id, "out".to_string()).await?;
add_info_msg(&t, chat_id, "info").await?;
assert_eq!(get_archived_cnt(&t).await?, 1);
// finally, unarchive on sending to not muted chat
set_muted(t, chat_id, MuteDuration::NotMuted).await?;
send_text_msg(t, chat_id, "out2".to_string()).await?;
assert_eq!(get_archived_cnt(t).await?, 0);
set_muted(&t, chat_id, MuteDuration::NotMuted).await?;
send_text_msg(&t, chat_id, "out2".to_string()).await?;
assert_eq!(get_archived_cnt(&t).await?, 0);
Ok(())
}
@@ -1387,7 +1380,6 @@ async fn test_markfresh_chat() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_archive_fresh_msgs() -> Result<()> {
let t = TestContext::new_alice().await;
t.allow_unencrypted().await?;
async fn msg_from(t: &TestContext, name: &str, num: u32) -> Result<()> {
receive_imf(
@@ -1881,38 +1873,45 @@ async fn test_lookup_self_by_contact_id() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_marknoticed_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat = alice.create_chat(bob).await;
let t = TestContext::new_alice().await;
let chat = t.create_chat_with_contact("bob", "bob@example.org").await;
let bob_chat_id = bob.create_chat_id(alice).await;
let sent = bob.send_text(bob_chat_id, "hello").await;
alice.recv_msg(&sent).await;
receive_imf(
&t,
b"From: bob@example.org\n\
To: alice@example.org\n\
Message-ID: <1@example.org>\n\
Chat-Version: 1.0\n\
Date: Fri, 23 Apr 2021 10:00:57 +0000\n\
\n\
hello\n",
false,
)
.await?;
let chats = Chatlist::try_load(alice, 0, None, None).await?;
let chats = Chatlist::try_load(&t, 0, None, None).await?;
assert_eq!(chats.len(), 1);
assert_eq!(chats.get_chat_id(0)?, chat.id);
assert_eq!(chat.id.get_fresh_msg_cnt(alice).await?, 1);
assert_eq!(alice.get_fresh_msgs().await?.len(), 1);
assert_eq!(chat.id.get_fresh_msg_cnt(&t).await?, 1);
assert_eq!(t.get_fresh_msgs().await?.len(), 1);
let msgs = get_chat_msgs(alice, chat.id).await?;
assert_eq!(msgs.len(), 2);
let msg_id = match msgs.last().unwrap() {
let msgs = get_chat_msgs(&t, chat.id).await?;
assert_eq!(msgs.len(), 1);
let msg_id = match msgs.first().unwrap() {
ChatItem::Message { msg_id } => *msg_id,
_ => MsgId::new_unset(),
};
let msg = message::Message::load_from_db(alice, msg_id).await?;
let msg = message::Message::load_from_db(&t, msg_id).await?;
assert_eq!(msg.state, MessageState::InFresh);
marknoticed_chat(alice, chat.id).await?;
marknoticed_chat(&t, chat.id).await?;
let chats = Chatlist::try_load(alice, 0, None, None).await?;
let chats = Chatlist::try_load(&t, 0, None, None).await?;
assert_eq!(chats.len(), 1);
let msg = message::Message::load_from_db(alice, msg_id).await?;
let msg = message::Message::load_from_db(&t, msg_id).await?;
assert_eq!(msg.state, MessageState::InNoticed);
assert_eq!(chat.id.get_fresh_msg_cnt(alice).await?, 0);
assert_eq!(alice.get_fresh_msgs().await?.len(), 0);
assert_eq!(chat.id.get_fresh_msg_cnt(&t).await?, 0);
assert_eq!(t.get_fresh_msgs().await?.len(), 0);
Ok(())
}
@@ -1920,7 +1919,6 @@ async fn test_marknoticed_chat() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_contact_request_fresh_messages() -> Result<()> {
let t = TestContext::new_alice().await;
t.allow_unencrypted().await?;
let chats = Chatlist::try_load(&t, 0, None, None).await?;
assert_eq!(chats.len(), 0);
@@ -1972,43 +1970,40 @@ async fn test_contact_request_fresh_messages() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_contact_request_archive() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let t = TestContext::new_alice().await;
let bob_chat_id = bob.create_chat_id(alice).await;
let bob_sent_text = bob.send_text(bob_chat_id, "hello").await;
alice.recv_msg(&bob_sent_text).await;
receive_imf(
&t,
b"From: bob@example.org\n\
To: alice@example.org\n\
Message-ID: <2@example.org>\n\
Chat-Version: 1.0\n\
Date: Sun, 22 Mar 2021 19:37:57 +0000\n\
\n\
hello\n",
false,
)
.await?;
let chats = Chatlist::try_load(alice, 0, None, None).await?;
let chats = Chatlist::try_load(&t, 0, None, None).await?;
assert_eq!(chats.len(), 1);
let chat_id = chats.get_chat_id(0)?;
assert!(
Chat::load_from_db(alice, chat_id)
.await?
.is_contact_request()
);
assert_eq!(get_archived_cnt(alice).await?, 0);
assert!(Chat::load_from_db(&t, chat_id).await?.is_contact_request());
assert_eq!(get_archived_cnt(&t).await?, 0);
// archive request without accepting or blocking
chat_id
.set_visibility(alice, ChatVisibility::Archived)
.await?;
chat_id.set_visibility(&t, ChatVisibility::Archived).await?;
let chats = Chatlist::try_load(alice, 0, None, None).await?;
let chats = Chatlist::try_load(&t, 0, None, None).await?;
assert_eq!(chats.len(), 1);
let chat_id = chats.get_chat_id(0)?;
assert!(chat_id.is_archived_link());
assert_eq!(get_archived_cnt(alice).await?, 1);
assert_eq!(get_archived_cnt(&t).await?, 1);
let chats = Chatlist::try_load(alice, DC_GCL_ARCHIVED_ONLY, None, None).await?;
let chats = Chatlist::try_load(&t, DC_GCL_ARCHIVED_ONLY, None, None).await?;
assert_eq!(chats.len(), 1);
let chat_id = chats.get_chat_id(0)?;
assert!(
Chat::load_from_db(alice, chat_id)
.await?
.is_contact_request()
);
assert!(Chat::load_from_db(&t, chat_id).await?.is_contact_request());
Ok(())
}
@@ -2016,7 +2011,6 @@ async fn test_contact_request_archive() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_classic_email_chat() -> Result<()> {
let alice = TestContext::new_alice().await;
alice.allow_unencrypted().await?;
// Alice receives a classic (non-chat) message from Bob.
receive_imf(
@@ -2038,6 +2032,12 @@ async fn test_classic_email_chat() -> Result<()> {
let msgs = get_chat_msgs(&alice, chat_id).await?;
assert_eq!(msgs.len(), 1);
// Alice disables receiving classic emails.
alice
.set_config(Config::ShowEmails, Some("0"))
.await
.unwrap();
// Already received classic email should still be in the chat.
assert_eq!(chat_id.get_fresh_msg_cnt(&alice).await?, 1);
@@ -2075,7 +2075,13 @@ async fn test_chat_get_color_encrypted() -> Result<()> {
Ok(())
}
async fn test_sticker(filename: &str, bytes: &[u8], w: i32, h: i32) -> Result<()> {
async fn test_sticker(
filename: &str,
bytes: &[u8],
res_viewtype: Viewtype,
w: i32,
h: i32,
) -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
@@ -2091,7 +2097,7 @@ async fn test_sticker(filename: &str, bytes: &[u8], w: i32, h: i32) -> Result<()
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, bob_chat.id);
assert_eq!(msg.get_viewtype(), Viewtype::Sticker);
assert_eq!(msg.get_viewtype(), res_viewtype);
assert_eq!(msg.get_filename().unwrap(), filename);
assert_eq!(msg.get_width(), w);
assert_eq!(msg.get_height(), h);
@@ -2105,6 +2111,7 @@ async fn test_sticker_png() -> Result<()> {
test_sticker(
"sticker.png",
include_bytes!("../../test-data/image/logo.png"),
Viewtype::Sticker,
135,
135,
)
@@ -2116,6 +2123,7 @@ async fn test_sticker_jpeg() -> Result<()> {
test_sticker(
"sticker.jpg",
include_bytes!("../../test-data/image/avatar1000x1000.jpg"),
Viewtype::Image,
1000,
1000,
)
@@ -2123,33 +2131,10 @@ async fn test_sticker_jpeg() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sticker_gif() -> Result<()> {
test_sticker(
"sticker.gif",
include_bytes!("../../test-data/image/logo.gif"),
135,
135,
)
.await
}
/// Tests that stickers are sent as stickers.
///
/// Previously there was heuristic that stickers
/// were sometimes turned into non-stickers,
/// e.g. when it looked like UI sent
/// a screenshot dragged from the gallery into chat
/// as a sticker.
///
/// We have no such heuristic anymore,
/// if such heuristic is needed on some platform,
/// UI code should implement it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sticker_no_heuristics() {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
async fn test_sticker_jpeg_force() {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
let file = alice.get_blobdir().join("sticker.jpg");
tokio::fs::write(
@@ -2159,38 +2144,53 @@ async fn test_sticker_no_heuristics() {
.await
.unwrap();
// Send a sticker.
// Images without force_sticker should be turned into [Viewtype::Image]
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(alice, &file, Some("sticker.jpg"), None)
msg.set_file_and_deduplicate(&alice, &file, Some("sticker.jpg"), None)
.unwrap();
let file = msg.get_file(alice).unwrap();
let file = msg.get_file(&alice).unwrap();
let sent_msg = alice.send_msg(alice_chat.id, &mut msg).await;
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.get_viewtype(), Viewtype::Image);
// Images with `force_sticker = true` should keep [Viewtype::Sticker]
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(&alice, &file, Some("sticker.jpg"), None)
.unwrap();
msg.force_sticker();
let sent_msg = alice.send_msg(alice_chat.id, &mut msg).await;
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.get_viewtype(), Viewtype::Sticker);
// Send a sticker reusing the file.
// Images with `force_sticker = true` should keep [Viewtype::Sticker]
// even on drafted messages
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(alice, &file, Some("sticker.jpg"), None)
.unwrap();
let sent_msg = alice.send_msg(alice_chat.id, &mut msg).await;
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.get_viewtype(), Viewtype::Sticker);
// Set sticker as a draft, then send it.
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(alice, &file, Some("sticker.jpg"), None)
msg.set_file_and_deduplicate(&alice, &file, Some("sticker.jpg"), None)
.unwrap();
msg.force_sticker();
alice_chat
.id
.set_draft(alice, Some(&mut msg))
.set_draft(&alice, Some(&mut msg))
.await
.unwrap();
let mut msg = alice_chat.id.get_draft(alice).await.unwrap().unwrap();
let mut msg = alice_chat.id.get_draft(&alice).await.unwrap().unwrap();
let sent_msg = alice.send_msg(alice_chat.id, &mut msg).await;
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.get_viewtype(), Viewtype::Sticker);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sticker_gif() -> Result<()> {
test_sticker(
"sticker.gif",
include_bytes!("../../test-data/image/logo.gif"),
Viewtype::Sticker,
135,
135,
)
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sticker_forward() -> Result<()> {
// create chats
@@ -2570,7 +2570,6 @@ async fn test_forward_encrypted_to_unencrypted() -> Result<()> {
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
bob.allow_unencrypted().await?;
let txt = "This should be encrypted";
let sent = alice.send_text(alice.create_chat(bob).await.id, txt).await;
@@ -2693,49 +2692,6 @@ async fn test_resend_own_message() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_resend_doesnt_resort_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_grp = create_group(alice, "").await?;
let sent1 = alice.send_text(alice_grp, "hi").await;
let sent1_ts = Message::load_from_db(alice, sent1.sender_msg_id)
.await?
.timestamp_sort;
SystemTime::shift(Duration::from_secs(60));
add_contact_to_chat(alice, alice_grp, alice.add_or_lookup_contact_id(bob).await).await?;
let sent2 = alice
.send_text(
alice_grp,
"Let's test resending, there are very few tests on it",
)
.await;
let resent_msg_id = sent1.sender_msg_id;
resend_msgs(alice, &[resent_msg_id]).await?;
assert_eq!(
resent_msg_id.get_state(alice).await?,
MessageState::OutPending
);
alice.pop_sent_msg().await;
assert_eq!(
resent_msg_id.get_state(alice).await?,
MessageState::OutDelivered
);
assert_eq!(
Message::load_from_db(alice, sent1.sender_msg_id)
.await?
.timestamp_sort,
sent1_ts
);
assert_eq!(
alice.get_last_msg_id_in(alice_grp).await,
sent2.sender_msg_id
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_resend_foreign_message_fails() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -2800,30 +2756,6 @@ async fn test_can_send_group() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cant_remove_nonmember() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
let alice_broadcast_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_broadcast_id))
.await
.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let alice_charlie_id = alice.add_or_lookup_contact_id(charlie).await;
remove_contact_from_chat(alice, alice_broadcast_id, alice_charlie_id).await?;
assert!(alice.pop_sent_msg_opt(Duration::ZERO).await.is_none());
assert!(!remove_from_chat_contacts_table(alice, alice_broadcast_id, alice_charlie_id).await?);
assert!(
!remove_from_chat_contacts_table_without_trace(alice, alice_broadcast_id, alice_charlie_id)
.await?
);
Ok(())
}
/// Tests that in a broadcast channel,
/// the recipients can't see the identity of their fellow recipients.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -2873,15 +2805,6 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
"alice@example.org charlie@example.net"
);
// Check additionally that subscribers don't send "Chat-Group-Name*" headers.
let parsed = alice.parse_msg(&request_with_auth).await;
assert!(parsed.get_header(HeaderDef::ChatGroupName).is_none());
assert!(
parsed
.get_header(HeaderDef::ChatGroupNameTimestamp)
.is_none()
);
alice.recv_msg_trash(&request_with_auth).await;
}
@@ -2947,24 +2870,10 @@ async fn test_broadcast_change_name() -> Result<()> {
let fiona = &tcm.fiona().await;
let broadcast_id = create_broadcast(alice, "Channel".to_string()).await?;
let mut qr = get_securejoin_qr(alice, Some(broadcast_id)).await.unwrap();
// Something goes wrong with the title, e.g. maybe it gets ellipsized
// Note that the title always comes at the end for human readability
qr += "+modified+title";
{
tcm.section("Alice invites Bob to her channel");
let Qr::AskJoinBroadcast { name, .. } = check_qr(bob, &qr).await? else {
panic!();
};
assert_eq!(name, "Channel modified title");
// The channel's name gets fixed after actually joining the channel:
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
assert_eq!(bob_chat.name, "Channel");
}
let qr = get_securejoin_qr(alice, Some(broadcast_id)).await.unwrap();
tcm.section("Alice invites Bob to her channel");
tcm.exec_securejoin_qr(bob, alice, &qr).await;
tcm.section("Alice invites Fiona to her channel");
tcm.exec_securejoin_qr(fiona, alice, &qr).await;
@@ -3038,81 +2947,6 @@ async fn test_broadcast_change_name() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_resend_to_new_member() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let alice_bc_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_bc_id)).await.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let mut alice_msg_ids = Vec::new();
for i in 0..(N_MSGS_TO_NEW_BROADCAST_MEMBER + 1) {
alice_msg_ids.push(
alice
.send_text(alice_bc_id, &i.to_string())
.await
.sender_msg_id,
);
}
let fiona_bc_id = tcm.exec_securejoin_qr(fiona, alice, &qr).await;
for msg_id in alice_msg_ids {
assert_eq!(msg_id.get_state(alice).await?, MessageState::OutDelivered);
}
for i in 0..N_MSGS_TO_NEW_BROADCAST_MEMBER {
let rev_order = false;
let resent_msg = alice
.pop_sent_msg_ex(rev_order, Duration::ZERO)
.await
.unwrap();
let fiona_msg = fiona.recv_msg(&resent_msg).await;
assert_eq!(fiona_msg.chat_id, fiona_bc_id);
assert_eq!(fiona_msg.text, (i + 1).to_string());
assert!(resent_msg.recipients.contains("fiona@example.net"));
assert!(!resent_msg.recipients.contains("bob@"));
// The message is undecryptable for Bob, he mustn't be able to know yet that somebody joined
// the broadcast even if he is a postman in this land. E.g. Fiona may leave after fetching
// the news, Bob won't know about that.
assert!(
MimeMessage::from_bytes(bob, resent_msg.payload().as_bytes())
.await?
.decryption_error
.is_some()
);
bob.recv_msg_trash(&resent_msg).await;
}
assert!(alice.pop_sent_msg_opt(Duration::ZERO).await.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_resend_failed_msg_to_new_member() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let alice_bc_id = create_broadcast(alice, "bc".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_bc_id)).await.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await;
let alice_msg_id = alice.send_text(alice_bc_id, "text").await.sender_msg_id;
let mut msg = Message::load_from_db(alice, alice_msg_id).await?;
message::set_msg_failed(alice, &mut msg, "error").await?;
let fiona_bc_id = tcm.exec_securejoin_qr(fiona, alice, &qr).await;
let resent_msg = alice.pop_sent_msg().await;
let fiona_msg = fiona.recv_msg(&resent_msg).await;
assert_eq!(fiona_msg.chat_id, fiona_bc_id);
assert_eq!(fiona_msg.text, "text");
assert_eq!(
alice_msg_id.get_state(alice).await?,
MessageState::OutFailed
);
Ok(())
}
/// - Alice has multiple devices
/// - Alice creates a broadcast and sends a message into it
/// - Alice's second device sees the broadcast
@@ -4765,12 +4599,10 @@ async fn test_sync_delete_chat() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sync_adhoc_grp() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice0 = &tcm.alice().await;
let alice1 = &tcm.alice().await;
let alice0 = &TestContext::new_alice().await;
let alice1 = &TestContext::new_alice().await;
for a in [alice0, alice1] {
a.set_config_bool(Config::SyncMsgs, true).await?;
a.allow_unencrypted().await?;
}
let mut chat_ids = Vec::new();
@@ -5195,31 +5027,6 @@ async fn test_do_not_overwrite_draft() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_outgoing_msg_after_another_from_future() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let chat_id = t.get_self_chat().await.id;
// Simulate sending a message with clock set to the future.
SystemTime::shift(Duration::from_secs(3600));
let msg_id = send_text_msg(t, chat_id, "test".to_string()).await?;
SystemTime::shift_back(Duration::from_secs(3600));
let timestamp_sent: i64 = t
.sql
.query_get_value("SELECT timestamp_sent FROM msgs WHERE id=?", (msg_id,))
.await?
.unwrap();
// Let's have a check here that locally sent messages have zero `timestamp_sent`, it can be a
// useful invariant.
assert_eq!(timestamp_sent, 0);
let msg_id = send_text_msg(t, chat_id, "Fixed my clock".to_string()).await?;
assert_eq!(t.get_last_msg_in(chat_id).await.id, msg_id);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_info_contact_id() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -5711,7 +5518,6 @@ async fn test_restore_backup_after_60_days() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_one_to_one_chat_no_group_member_timestamps() {
let t = TestContext::new_alice().await;
t.allow_unencrypted().await.unwrap();
let chat = t.create_chat_with_contact("bob", "bob@example.com").await;
let sent = t.send_text(chat.id, "Hi!").await;
let payload = sent.payload;
@@ -5889,7 +5695,7 @@ async fn test_send_delete_request() -> Result<()> {
let sent2 = alice.pop_sent_msg().await;
assert_eq!(alice_chat.id.get_msg_cnt(alice).await?, E2EE_INFO_MSGS + 1);
// Bob receives both messages and has nothing at the end
// Bob receives both messages and has nothing the end
let bob_msg = bob.recv_msg(&sent1).await;
assert_eq!(bob_msg.text, "wtf");
assert_eq!(bob_msg.chat_id.get_msg_cnt(bob).await?, E2EE_INFO_MSGS + 2);
@@ -5897,11 +5703,6 @@ async fn test_send_delete_request() -> Result<()> {
bob.recv_msg_opt(&sent2).await;
assert_eq!(bob_msg.chat_id.get_msg_cnt(bob).await?, E2EE_INFO_MSGS + 1);
// ... even if he receives messages in reverse order.
let bob2 = &tcm.bob().await;
bob2.recv_msg_opt(&sent2).await;
assert!(bob2.recv_msg_opt(&sent1).await.is_none());
// Alice has another device, and there is also nothing at the end
let alice2 = &tcm.alice().await;
alice2.recv_msg(&sent0).await;
@@ -5925,7 +5726,6 @@ async fn test_send_delete_request_no_encryption() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
alice.allow_unencrypted().await?;
let alice_chat = alice.create_email_chat(bob).await;
// Alice sends a message, then tries to send a deletion request which fails.
@@ -6144,7 +5944,6 @@ async fn test_no_key_contacts_in_adhoc_chats() -> Result<()> {
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
alice.allow_unencrypted().await?;
let chat_id = receive_imf(
alice,
@@ -6180,7 +5979,6 @@ async fn test_no_key_contacts_in_adhoc_chats() -> Result<()> {
async fn test_create_unencrypted_group_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.allow_unencrypted().await?;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;

View File

@@ -665,7 +665,6 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_search_single_chat() -> anyhow::Result<()> {
let t = TestContext::new_alice().await;
t.allow_unencrypted().await?;
// receive a one-to-one-message
receive_imf(
@@ -726,7 +725,6 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_search_single_chat_without_authname() -> anyhow::Result<()> {
let t = TestContext::new_alice().await;
t.allow_unencrypted().await?;
// receive a one-to-one-message without authname set
receive_imf(

View File

@@ -42,85 +42,50 @@ use crate::{constants, stats};
)]
#[strum(serialize_all = "snake_case")]
pub enum Config {
/// Deprecated(2026-04).
/// Use ConfiguredAddr, [`crate::login_param::EnteredLoginParam`],
/// or add_transport{from_qr}()/list_transports() instead.
///
/// Email address, used in the `From:` field.
Addr,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server hostname.
MailServer,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server username.
MailUser,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server password.
MailPw,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server port.
MailPort,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// IMAP server security (e.g. TLS, STARTTLS).
MailSecurity,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// How to check TLS certificates.
///
/// "IMAP" in the name is for compatibility,
/// this actually applies to both IMAP and SMTP connections.
ImapCertificateChecks,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server hostname.
SendServer,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server username.
SendUser,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server password.
SendPw,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server port.
SendPort,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// SMTP server security (e.g. TLS, STARTTLS).
SendSecurity,
/// Deprecated(2026-04).
/// Use EnteredLoginParam and add_transport{from_qr}()/list_transports() instead.
/// Deprecated option for backwards compatibility.
///
/// Certificate checks for SMTP are actually controlled by `imap_certificate_checks` config.
SmtpCertificateChecks,
/// Whether to use OAuth 2.
///
/// Historically contained other bitflags, which are now deprecated.
@@ -190,10 +155,37 @@ pub enum Config {
#[strum(props(default = "1"))]
MdnsEnabled,
/// True if chat messages should be moved to a separate folder. Auto-sent messages like sync
/// ones are moved there anyway.
#[strum(props(default = "1"))]
MvboxMove,
/// Watch for new messages in the "Mvbox" (aka DeltaChat folder) only.
///
/// This will not entirely disable other folders, e.g. the spam folder will also still
/// be watched for new messages.
#[strum(props(default = "0"))]
OnlyFetchMvbox,
/// Whether to show classic emails or only chat messages.
#[strum(props(default = "2"))] // also change ShowEmails.default() on changes
ShowEmails,
/// Quality of the media files to send.
#[strum(props(default = "0"))] // also change MediaQuality.default() on changes
MediaQuality,
/// Timer in seconds after which the message is deleted from the
/// server.
///
/// 0 means messages are never deleted by Delta Chat.
///
/// Value 1 is treated as "delete at once": messages are deleted
/// immediately, without moving to DeltaChat folder.
///
/// Default is 1 for chatmail accounts without `BccSelf`, 0 otherwise.
DeleteServerAfter,
/// Timer in seconds after which the message is deleted from the
/// device.
///
@@ -205,47 +197,32 @@ pub enum Config {
/// The primary email address.
ConfiguredAddr,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// List of configured IMAP servers as a JSON array.
ConfiguredImapServers,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server hostname.
///
/// This is replaced by `configured_imap_servers` for new configurations.
ConfiguredMailServer,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server port.
///
/// This is replaced by `configured_imap_servers` for new configurations.
ConfiguredMailPort,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server security (e.g. TLS, STARTTLS).
///
/// This is replaced by `configured_imap_servers` for new configurations.
ConfiguredMailSecurity,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server username.
///
/// This is set if user has configured username manually.
ConfiguredMailUser,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured IMAP server password.
ConfiguredMailPw,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured TLS certificate checks.
/// This option is saved on successful configuration
/// and should not be modified manually.
@@ -254,68 +231,52 @@ pub enum Config {
/// but has "IMAP" in the name for backwards compatibility.
ConfiguredImapCertificateChecks,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// List of configured SMTP servers as a JSON array.
ConfiguredSmtpServers,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server hostname.
///
/// This is replaced by `configured_smtp_servers` for new configurations.
ConfiguredSendServer,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server port.
///
/// This is replaced by `configured_smtp_servers` for new configurations.
ConfiguredSendPort,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server security (e.g. TLS, STARTTLS).
///
/// This is replaced by `configured_smtp_servers` for new configurations.
ConfiguredSendSecurity,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server username.
///
/// This is set if user has configured username manually.
ConfiguredSendUser,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
///
/// Configured SMTP server password.
ConfiguredSendPw,
/// Deprecated(2026-04).
/// Use ConfiguredLoginParam and add_transport{from_qr}()/list_transports() instead.
/// Deprecated, stored for backwards compatibility.
///
/// ConfiguredImapCertificateChecks is actually used.
ConfiguredSmtpCertificateChecks,
/// Whether OAuth 2 is used with configured provider.
ConfiguredServerFlags,
/// Configured folder for incoming messages.
ConfiguredInboxFolder,
/// Configured folder for chat messages.
ConfiguredMvboxFolder,
/// Unix timestamp of the last successful configuration.
ConfiguredTimestamp,
/// ID of the configured provider from the provider database.
ConfiguredProvider,
/// Deprecated(2026-04).
/// Use [`Context::is_configured()`] instead.
///
/// True if account is configured.
Configured,
@@ -356,6 +317,11 @@ pub enum Config {
#[strum(props(default = "0"))]
NotifyAboutWrongPw,
/// If a warning about exceeding quota was shown recently,
/// this is the percentage of quota at the time the warning was given.
/// Unset, when quota falls below minimal warning threshold again.
QuotaExceeding,
/// Timestamp of the last time housekeeping was run
LastHousekeeping,
@@ -396,6 +362,15 @@ pub enum Config {
#[strum(props(default = "1"))]
SyncMsgs,
/// Space-separated list of all the authserv-ids which we believe
/// may be the one of our email server.
///
/// See `crate::authres::update_authservid_candidates`.
AuthservIdCandidates,
/// Make all outgoing messages with Autocrypt header "multipart/signed".
SignUnencrypted,
/// Let the core save all events to the database.
/// This value is used internally to remember the MsgId of the logging xdc
#[strum(props(default = "0"))]
@@ -452,6 +427,11 @@ pub enum Config {
/// storing the same token multiple times on the server.
EncryptedDeviceToken,
/// Enables running test hooks, e.g. see `InnerContext::pre_encrypt_mime_hook`.
/// This way is better than conditional compilation, i.e. `#[cfg(test)]`, because tests not
/// using this still run unmodified code.
TestHooks,
/// Return an error from `receive_imf_inner()`. For tests.
SimulateReceiveImfError,
@@ -470,13 +450,6 @@ pub enum Config {
/// Experimental option denoting that the current profile is shared between multiple team members.
/// For now, the only effect of this option is that seen flags are not synchronized.
TeamProfile,
/// Force encryption.
///
/// When enabled, unencrypted messages cannot be sent
/// and incoming unencrypted messages are not fetched and not processed.
#[strum(props(default = "1"))]
ForceEncryption,
}
impl Config {
@@ -494,15 +467,19 @@ impl Config {
self,
Self::Displayname
| Self::MdnsEnabled
| Self::MvboxMove
| Self::ShowEmails
| Self::Selfavatar
| Self::Selfstatus
| Self::ForceEncryption,
| Self::Selfstatus,
)
}
/// Whether the config option needs an IO scheduler restart to take effect.
pub(crate) fn needs_io_restart(&self) -> bool {
matches!(self, Config::ConfiguredAddr)
matches!(
self,
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ConfiguredAddr
)
}
}
@@ -549,6 +526,14 @@ impl Context {
// Default values
let val = match key {
Config::ConfiguredInboxFolder => Some("INBOX".to_string()),
Config::DeleteServerAfter => {
match !Box::pin(self.get_config_bool(Config::BccSelf)).await?
&& Box::pin(self.is_chatmail()).await?
{
true => Some("1".to_string()),
false => Some("0".to_string()),
}
}
Config::Addr => self.get_config_opt(Config::ConfiguredAddr).await?,
_ => key.get_str("default").map(|s| s.to_string()),
};
@@ -609,6 +594,13 @@ impl Context {
.is_some_and(|x| x != 0))
}
/// Returns true if movebox ("DeltaChat" folder) should be watched.
pub(crate) async fn should_watch_mvbox(&self) -> Result<bool> {
Ok(self.get_config_bool(Config::MvboxMove).await?
|| self.get_config_bool(Config::OnlyFetchMvbox).await?
|| !self.get_config_bool(Config::IsChatmail).await?)
}
/// Returns true if sync messages should be sent.
pub(crate) async fn should_send_sync_msgs(&self) -> Result<bool> {
Ok(self.get_config_bool(Config::SyncMsgs).await?
@@ -629,6 +621,23 @@ impl Context {
self.get_config_bool(Config::MdnsEnabled).await
}
/// Gets configured "delete_server_after" value.
///
/// `None` means never delete the message, `Some(0)` means delete
/// at once, `Some(x)` means delete after `x` seconds.
pub async fn get_config_delete_server_after(&self) -> Result<Option<i64>> {
let val = match self
.get_config_parsed::<i64>(Config::DeleteServerAfter)
.await?
.unwrap_or(0)
{
0 => None,
1 => Some(0),
x => Some(x),
};
Ok(val)
}
/// Gets the configured provider.
///
/// The provider is determined by the current primary transport.
@@ -673,10 +682,13 @@ impl Context {
| Config::ProxyEnabled
| Config::BccSelf
| Config::MdnsEnabled
| Config::MvboxMove
| Config::OnlyFetchMvbox
| Config::Configured
| Config::Bot
| Config::NotifyAboutWrongPw
| Config::SyncMsgs
| Config::SignUnencrypted
| Config::DisableIdle => {
ensure!(
matches!(value, None | Some("0") | Some("1")),
@@ -694,6 +706,11 @@ impl Context {
pub async fn set_config(&self, key: Config, value: Option<&str>) -> Result<()> {
Self::check_config(key, value)?;
let n_transports = self.count_transports().await?;
if n_transports > 1 && matches!(key, Config::MvboxMove | Config::OnlyFetchMvbox) {
bail!("Cannot reconfigure {key} when multiple transports are configured");
}
let _pause = match key.needs_io_restart() {
true => self.scheduler.pause(self).await?,
_ => Default::default(),
@@ -772,6 +789,12 @@ impl Context {
.set_raw_config(key.as_ref(), value.map(|s| s.to_lowercase()).as_deref())
.await?;
}
Config::MvboxMove => {
self.sql.set_raw_config(key.as_ref(), value).await?;
self.sql
.set_raw_config(constants::DC_FOLDERS_CONFIGURED_KEY, None)
.await?;
}
Config::ConfiguredAddr => {
let Some(addr) = value else {
bail!("Cannot unset configured_addr");
@@ -910,23 +933,16 @@ impl Context {
/// Determine whether the specified addr maps to the/a self addr.
/// Returns `false` if no addresses are configured.
pub(crate) async fn is_self_addr(&self, addr: &str) -> Result<bool> {
// Employ the config cache to optimize for `ConfiguredAddr` passed.
if !addr.is_empty()
&& addr_cmp(
addr,
&self
.get_config(Config::ConfiguredAddr)
.await?
.unwrap_or_default(),
)
{
return Ok(true);
}
Ok(self
.get_all_self_addrs()
.get_config(Config::ConfiguredAddr)
.await?
.iter()
.any(|a| addr_cmp(addr, a)))
.any(|a| addr_cmp(addr, a))
|| self
.get_secondary_self_addrs()
.await?
.iter()
.any(|a| addr_cmp(addr, a)))
}
/// Sets `primary_new` as the new primary self address and saves the old
@@ -973,6 +989,14 @@ impl Context {
.await
}
/// Returns all secondary self addresses.
pub(crate) async fn get_secondary_self_addrs(&self) -> Result<Vec<String>> {
self.sql.query_map_vec("SELECT addr FROM transports WHERE addr NOT IN (SELECT value FROM config WHERE keyname='configured_addr')", (), |row| {
let addr: String = row.get(0)?;
Ok(addr)
}).await
}
/// Returns all published secondary self addresses.
/// See `[Context::set_transport_unpublished]`
pub(crate) async fn get_published_secondary_self_addrs(&self) -> Result<Vec<String>> {

View File

@@ -142,6 +142,28 @@ async fn test_mdns_default_behaviour() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_server_after_default() -> Result<()> {
let t = &TestContext::new_alice().await;
// Check that the settings are displayed correctly.
assert_eq!(t.get_config(Config::BccSelf).await?, Some("1".to_string()));
assert_eq!(
t.get_config(Config::DeleteServerAfter).await?,
Some("0".to_string())
);
// Leaving emails on the server even w/o `BccSelf` is a good default at least because other
// MUAs do so even if the server doesn't save sent messages to some sentbox (like Gmail
// does).
t.set_config_bool(Config::BccSelf, false).await?;
assert_eq!(
t.get_config(Config::DeleteServerAfter).await?,
Some("0".to_string())
);
Ok(())
}
const SAVED_MESSAGES_DEDUPLICATED_FILE: &str = "969142cb84015bc135767bc2370934a.png";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -174,6 +196,13 @@ async fn test_sync() -> Result<()> {
sync(&alice0, &alice1).await;
assert_eq!(alice1.get_config_bool(Config::MdnsEnabled).await?, false);
for key in [Config::ShowEmails, Config::MvboxMove] {
let val = alice0.get_config_bool(key).await?;
alice0.set_config_bool(key, !val).await?;
sync(&alice0, &alice1).await;
assert_eq!(alice1.get_config_bool(key).await?, !val);
}
// `Config::SyncMsgs` mustn't be synced.
alice0.set_config_bool(Config::SyncMsgs, false).await?;
alice0.set_config_bool(Config::SyncMsgs, true).await?;

View File

@@ -76,7 +76,7 @@ impl Context {
/// Deprecated since 2025-02; use `add_transport_from_qr()`
/// or `add_or_update_transport()` instead.
pub async fn configure(&self) -> Result<()> {
let mut param = EnteredLoginParam::load_legacy(self).await?;
let mut param = EnteredLoginParam::load(self).await?;
self.add_transport_inner(&mut param).await
}
@@ -150,7 +150,7 @@ impl Context {
progress!(self, 0, Some(error_msg.clone()));
bail!(error_msg);
} else {
param.save_legacy(self).await?;
param.save(self).await?;
progress!(self, 1000);
}
@@ -261,7 +261,6 @@ impl Context {
.await?;
send_sync_transports(self).await?;
self.quota.write().await.remove(&removed_transport_id);
self.restart_io_if_running().await;
Ok(())
}
@@ -316,16 +315,31 @@ impl Context {
(&param.addr,),
)
.await?
&& self
{
// Should be checked before `MvboxMove` because the latter makes no sense in presense of
// `OnlyFetchMvbox` and even grayed out in the UIs in this case.
if self.get_config(Config::OnlyFetchMvbox).await?.as_deref() != Some("0") {
bail!(
"To use additional relays, disable the legacy option \"Settings / Advanced / Only Fetch from DeltaChat Folder\"."
);
}
if self.get_config(Config::MvboxMove).await?.as_deref() != Some("0") {
bail!(
"To use additional relays, disable the legacy option \"Settings / Advanced / Move automatically to DeltaChat Folder\"."
);
}
if self
.sql
.count("SELECT COUNT(*) FROM transports", ())
.await?
>= MAX_TRANSPORT_RELAYS
{
bail!(
"You have reached the maximum number of relays ({}).",
MAX_TRANSPORT_RELAYS
)
{
bail!(
"You have reached the maximum number of relays ({}).",
MAX_TRANSPORT_RELAYS
)
}
}
let provider = match configure(self, param).await {
@@ -538,7 +552,6 @@ async fn get_configured_param(
.collect(),
imap_user: param.imap.user.clone(),
imap_password: param.imap.password.clone(),
imap_folder: Some(param.imap.folder.clone()).filter(|folder| !folder.is_empty()),
smtp: servers
.iter()
.filter_map(|params| {
@@ -631,6 +644,10 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
progress!(ctx, 900);
let is_configured = ctx.is_configured().await?;
if !is_configured {
ctx.sql.set_raw_config("mvbox_move", Some("0")).await?;
ctx.sql.set_raw_config("only_fetch_mvbox", None).await?;
}
if !ctx.get_config_bool(Config::FixIsChatmail).await? {
if imap_session.is_chatmail() {
ctx.sql.set_raw_config("is_chatmail", Some("1")).await?;
@@ -680,8 +697,6 @@ async fn get_autoconfig(
param: &EnteredLoginParam,
param_domain: &str,
) -> Option<Vec<ServerParams>> {
let accept_invalid_certificates = param.certificate_checks.accept_invalid_certificates();
// Make sure to not encode `.` as `%2E` here.
// Some servers like murena.io on 2024-11-01 produce incorrect autoconfig XML
// when address is encoded.
@@ -698,7 +713,6 @@ async fn get_autoconfig(
"https://autoconfig.{param_domain}/mail/config-v1.1.xml?emailaddress={param_addr_urlencoded}"
),
&param.addr,
accept_invalid_certificates,
)
.await
{
@@ -710,10 +724,10 @@ async fn get_autoconfig(
ctx,
// the doc does not mention `emailaddress=`, however, Thunderbird adds it, see <https://releases.mozilla.org/pub/thunderbird/>, which makes some sense
&format!(
"https://{param_domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={param_addr_urlencoded}"
"https://{}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={}",
&param_domain, &param_addr_urlencoded
),
&param.addr,
accept_invalid_certificates,
)
.await
{
@@ -724,8 +738,7 @@ async fn get_autoconfig(
// Outlook uses always SSL but different domains (this comment describes the next two steps)
if let Ok(res) = outlk_autodiscover(
ctx,
format!("https://{param_domain}/autodiscover/autodiscover.xml"),
accept_invalid_certificates,
format!("https://{}/autodiscover/autodiscover.xml", &param_domain),
)
.await
{
@@ -735,8 +748,10 @@ async fn get_autoconfig(
if let Ok(res) = outlk_autodiscover(
ctx,
format!("https://autodiscover.{param_domain}/autodiscover/autodiscover.xml",),
accept_invalid_certificates,
format!(
"https://autodiscover.{}/autodiscover/autodiscover.xml",
&param_domain
),
)
.await
{
@@ -747,9 +762,8 @@ async fn get_autoconfig(
// always SSL for Thunderbird's database
if let Ok(res) = moz_autoconfigure(
ctx,
&format!("https://autoconfig.thunderbird.net/v1.1/{param_domain}"),
&format!("https://autoconfig.thunderbird.net/v1.1/{}", &param_domain),
&param.addr,
accept_invalid_certificates,
)
.await
{
@@ -797,7 +811,7 @@ pub enum Error {
mod tests {
use super::*;
use crate::config::Config;
use crate::login_param::EnteredImapLoginParam;
use crate::login_param::EnteredServerLoginParam;
use crate::test_utils::TestContext;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -816,7 +830,7 @@ mod tests {
let entered_param = EnteredLoginParam {
addr: "alice@example.org".to_string(),
imap: EnteredImapLoginParam {
imap: EnteredServerLoginParam {
user: "alice@example.net".to_string(),
password: "foobar".to_string(),
..Default::default()

View File

@@ -10,7 +10,7 @@ use quick_xml::events::{BytesStart, Event};
use super::{Error, ServerParams};
use crate::context::Context;
use crate::log::warn;
use crate::net::read_url_with_tls;
use crate::net::read_url;
use crate::provider::{Protocol, Socket};
#[derive(Debug)]
@@ -249,9 +249,8 @@ pub(crate) async fn moz_autoconfigure(
context: &Context,
url: &str,
addr: &str,
accept_invalid_certificates: bool,
) -> Result<Vec<ServerParams>, Error> {
let xml_raw = read_url_with_tls(context, url, !accept_invalid_certificates).await?;
let xml_raw = read_url(context, url).await?;
let res = parse_serverparams(addr, &xml_raw);
if let Err(err) = &res {

View File

@@ -10,7 +10,7 @@ use quick_xml::events::Event;
use super::{Error, ServerParams};
use crate::context::Context;
use crate::log::warn;
use crate::net::read_url_with_tls;
use crate::net::read_url;
use crate::provider::{Protocol, Socket};
/// Result of parsing a single `Protocol` tag.
@@ -196,11 +196,10 @@ fn protocols_to_serverparams(protocols: Vec<ProtocolTag>) -> Vec<ServerParams> {
pub(crate) async fn outlk_autodiscover(
context: &Context,
mut url: String,
accept_invalid_certificates: bool,
) -> Result<Vec<ServerParams>, Error> {
/* Follow up to 10 xml-redirects (http-redirects are followed in read_url() */
for _i in 0..10 {
let xml_raw = read_url_with_tls(context, &url, !accept_invalid_certificates).await?;
let xml_raw = read_url(context, &url).await?;
let res = parse_xml(&xml_raw);
if let Err(err) = &res {
warn!(context, "{}", err);

View File

@@ -36,6 +36,17 @@ pub enum Blocked {
Request = 2,
}
#[derive(
Debug, Default, Display, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive, FromSql, ToSql,
)]
#[repr(u8)]
pub enum ShowEmails {
Off = 0,
AcceptedContacts = 1,
#[default] // also change Config.ShowEmails props(default) on changes
All = 2,
}
#[derive(
Debug, Default, Display, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive, FromSql, ToSql,
)]
@@ -188,7 +199,7 @@ pub const WORSE_IMAGE_BYTES: usize = 130_000;
// max. width/height and bytes of an avatar
pub(crate) const BALANCED_AVATAR_SIZE: u32 = 512;
pub(crate) const BALANCED_AVATAR_BYTES: usize = 60_000;
pub(crate) const WORSE_AVATAR_SIZE: u32 = 256;
pub(crate) const WORSE_AVATAR_SIZE: u32 = 128;
pub(crate) const WORSE_AVATAR_BYTES: usize = 20_000; // this also fits to Outlook servers don't allowing headers larger than 32k.
// max. width/height of images scaled down because of being too huge
@@ -199,6 +210,11 @@ pub const WORSE_IMAGE_SIZE: u32 = 640;
/// usage by UIs.
pub const MAX_RCVD_IMAGE_PIXELS: u32 = 50_000_000;
// Key for the folder configuration version (see below).
pub(crate) const DC_FOLDERS_CONFIGURED_KEY: &str = "folders_configured";
// this value can be increased if the folder configuration is changed and must be redone on next program start
pub(crate) const DC_FOLDERS_CONFIGURED_VERSION: i32 = 5;
// If more recipients are needed in SMTP's `RCPT TO:` header, the recipient list is split into
// chunks. This does not affect MIME's `To:` header. Can be overwritten by setting
// `max_smtp_rcpt_to` in the provider db.
@@ -233,9 +249,6 @@ Here is what to do:
If you have any questions, please send an email to delta@merlinux.eu or ask at https://support.delta.chat/."#;
/// How many recent messages should be re-sent to a new broadcast member.
pub(crate) const N_MSGS_TO_NEW_BROADCAST_MEMBER: usize = 10;
#[cfg(test)]
mod tests {
use num_traits::FromPrimitive;
@@ -251,6 +264,18 @@ mod tests {
assert_eq!(Chattype::OutBroadcast, Chattype::from_i32(160).unwrap());
}
#[test]
fn test_showemails_values() {
// values may be written to disk and must not change
assert_eq!(ShowEmails::All, ShowEmails::default());
assert_eq!(ShowEmails::Off, ShowEmails::from_i32(0).unwrap());
assert_eq!(
ShowEmails::AcceptedContacts,
ShowEmails::from_i32(1).unwrap()
);
assert_eq!(ShowEmails::All, ShowEmails::from_i32(2).unwrap());
}
#[test]
fn test_blocked_values() {
// values may be written to disk and must not change

View File

@@ -1396,7 +1396,7 @@ WHERE addr=?
let Some(fingerprint_other) = contact.fingerprint() else {
return Ok(stock_str::encr_none(context));
};
let fingerprint_other = fingerprint_other.human_readable();
let fingerprint_other = fingerprint_other.to_string();
let stock_message = if contact.public_key(context).await?.is_some() {
stock_str::messages_are_e2ee(context)
@@ -1410,7 +1410,7 @@ WHERE addr=?
let fingerprint_self = load_self_public_key(context)
.await?
.dc_fingerprint()
.human_readable();
.to_string();
if addr < contact.addr {
cat_fingerprint(
&mut ret,
@@ -2068,6 +2068,7 @@ pub(crate) async fn mark_contact_id_as_verified(
Ok(())
}
#[expect(clippy::arithmetic_side_effects)]
fn cat_fingerprint(ret: &mut String, name: &str, addr: &str, fingerprint: &str) {
*ret += &format!("\n\n{name} ({addr}):\n{fingerprint}");
}

View File

@@ -4,7 +4,6 @@ use super::*;
use crate::chat::{Chat, get_chat_contacts, send_text_msg};
use crate::chatlist::Chatlist;
use crate::receive_imf::receive_imf;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote, sync};
#[test]
@@ -155,50 +154,6 @@ async fn test_get_contacts() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_search_contacts_from_group() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let alice_chat_id = chat::create_group(alice, "").await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
tcm.exec_securejoin_qr(fiona, alice, &qr).await;
// Workaround for "member added" for fiona not sent to bob.
let gossip_period = alice.get_config_int(Config::GossipPeriod).await?;
SystemTime::shift(Duration::from_secs(gossip_period.try_into()?));
send_text_msg(alice, alice_chat_id, "hello".to_string()).await?;
let sent_msg = alice.pop_sent_msg().await;
bob.recv_msg(&sent_msg).await;
fiona.recv_msg(&sent_msg).await;
let contacts = Contact::get_all(bob, 0, None).await?;
let bob_alice_id = bob.add_or_lookup_contact_id(alice).await;
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0], bob_alice_id);
let contacts = Contact::get_all(fiona, 0, None).await?;
let fiona_alice_id = fiona.add_or_lookup_contact_id(alice).await;
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0], fiona_alice_id);
// Sending to the group adds new members to the contact list.
send_text_msg(bob, bob_chat_id, "hello".to_string()).await?;
fiona.recv_msg(&bob.pop_sent_msg().await).await;
let contacts = Contact::get_all(bob, 0, None).await?;
let bob_fiona_id = bob.add_or_lookup_contact_id(fiona).await;
assert_eq!(contacts.len(), 2);
assert_eq!(contacts[0], bob_alice_id);
assert_eq!(contacts[1], bob_fiona_id);
let contacts = Contact::get_all(fiona, 0, None).await?;
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0], fiona_alice_id);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_self_addr() -> Result<()> {
let t = TestContext::new().await;
@@ -335,7 +290,6 @@ async fn test_add_or_lookup() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_contact_name_changes() -> Result<()> {
let t = TestContext::new_alice().await;
t.allow_unencrypted().await?;
// first message creates contact and one-to-one-chat without name set
receive_imf(
@@ -928,12 +882,9 @@ async fn test_synchronize_status() -> Result<()> {
// Alice has two devices.
let alice1 = &tcm.alice().await;
let alice2 = &tcm.alice().await;
alice1.allow_unencrypted().await?;
alice2.allow_unencrypted().await?;
// Bob has one device.
let bob = &tcm.bob().await;
bob.allow_unencrypted().await?;
let default_status = alice1.get_config(Config::Selfstatus).await?;
@@ -1002,18 +953,33 @@ async fn test_selfavatar_changed_event() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_last_seen() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice = TestContext::new_alice().await;
let contact = alice.add_or_lookup_contact(bob).await;
let (contact_id, _) = Contact::add_or_lookup(
&alice,
"Bob",
&ContactAddress::new("bob@example.net")?,
Origin::ManuallyCreated,
)
.await?;
let contact = Contact::get_by_id(&alice, contact_id).await?;
assert_eq!(contact.last_seen(), 0);
let msg = tcm.send_recv(bob, alice, "Hi.").await;
let mime = br#"Subject: Hello
Message-ID: message@example.net
To: Alice <alice@example.org>
From: Bob <bob@example.net>
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
Chat-Version: 1.0
Date: Sun, 22 Mar 2020 22:37:55 +0000
Hi."#;
receive_imf(&alice, mime, false).await?;
let msg = alice.get_last_msg().await;
let timestamp = msg.get_timestamp();
assert!(timestamp > 0);
let contact = Contact::get_by_id(alice, contact.id).await?;
let contact = Contact::get_by_id(&alice, contact_id).await?;
assert_eq!(contact.last_seen(), timestamp);
Ok(())
@@ -1087,7 +1053,6 @@ async fn test_was_seen_recently_event() -> Result<()> {
async fn test_lookup_id_by_addr_recent_ex(accept_unencrypted_chat: bool) -> Result<()> {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
bob.allow_unencrypted().await?;
let raw = include_bytes!("../../test-data/message/thunderbird_with_autocrypt.eml");
assert!(std::str::from_utf8(raw)?.contains("Date: Thu, 24 Nov 2022 20:05:57 +0100"));

View File

@@ -20,15 +20,16 @@ use crate::constants::{self, DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT, DC_VERSI
use crate::contact::{Contact, ContactId};
use crate::debug_logging::DebugLogging;
use crate::events::{Event, EventEmitter, EventType, Events};
use crate::imap::{Imap, ServerMetadata};
use crate::imap::{FolderMeaning, Imap, ServerMetadata};
use crate::key::self_fingerprint;
use crate::log::warn;
use crate::logged_debug_assert;
use crate::message::{self, MessageState, MsgId};
use crate::net::tls::{SpkiHashStore, TlsSessionStore};
use crate::net::tls::TlsSessionStore;
use crate::peer_channels::Iroh;
use crate::push::PushSubscriber;
use crate::quota::QuotaInfo;
use crate::scheduler::{ConnectivityStore, SchedulerState};
use crate::scheduler::{ConnectivityStore, SchedulerState, convert_folder_meaning};
use crate::sql::Sql;
use crate::stock_str::StockStrings;
use crate::timesmearing::SmearedTimestamp;
@@ -307,13 +308,6 @@ pub struct InnerContext {
/// TLS session resumption cache.
pub(crate) tls_session_store: TlsSessionStore,
/// Store for TLS SPKI hashes.
///
/// Used to remember public keys
/// of TLS certificates to accept them
/// even after they expire.
pub(crate) spki_hash_store: SpkiHashStore,
/// Iroh for realtime peer channels.
pub(crate) iroh: Arc<RwLock<Option<Iroh>>>,
@@ -332,6 +326,17 @@ pub struct InnerContext {
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
/// see [`Context::get_connectivity()`].
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
#[expect(clippy::type_complexity)]
/// Transforms the root of the cryptographic payload before encryption.
pub(crate) pre_encrypt_mime_hook: parking_lot::Mutex<
Option<
for<'a> fn(
&Context,
mail_builder::mime::MimePart<'a>,
) -> mail_builder::mime::MimePart<'a>,
>,
>,
}
/// The state of ongoing process.
@@ -506,11 +511,11 @@ impl Context {
push_subscriber,
push_subscribed: AtomicBool::new(false),
tls_session_store: TlsSessionStore::new(),
spki_hash_store: SpkiHashStore::new(),
iroh: Arc::new(RwLock::new(None)),
self_fingerprint: OnceLock::new(),
self_public_key: Mutex::new(None),
connectivities: parking_lot::Mutex::new(Vec::new()),
pre_encrypt_mime_hook: None.into(),
};
let ctx = Context {
@@ -618,10 +623,17 @@ impl Context {
let mut session = connection.prepare(self).await?;
// Fetch IMAP folders.
let folder = connection.folder.clone();
connection
.fetch_move_delete(self, &mut session, &folder)
.await?;
// Inbox is fetched before Mvbox because fetching from Inbox
// may result in moving some messages to Mvbox.
for folder_meaning in [FolderMeaning::Inbox, FolderMeaning::Mvbox] {
if let Some((_folder_config, watch_folder)) =
convert_folder_meaning(self, folder_meaning).await?
{
connection
.fetch_move_delete(self, &mut session, &watch_folder, folder_meaning)
.await?;
}
}
// Update quota (to send warning if full) - but only check it once in a while.
// note: For now this only checks quota of primary transport,
@@ -632,7 +644,7 @@ impl Context {
DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT,
)
.await
&& let Err(err) = self.update_recent_quota(&mut session, &folder).await
&& let Err(err) = self.update_recent_quota(&mut session).await
{
warn!(self, "Failed to update quota: {err:#}.");
}
@@ -830,6 +842,7 @@ impl Context {
/// Returns information about the context as key-value pairs.
pub async fn get_info(&self) -> Result<BTreeMap<&'static str, String>> {
let secondary_addrs = self.get_secondary_self_addrs().await?.join(", ");
let all_transports: Vec<String> = ConfiguredLoginParam::load_all(self)
.await?
.into_iter()
@@ -866,6 +879,27 @@ impl Context {
.sql
.count("SELECT COUNT(*) FROM public_keys;", ())
.await?;
let fingerprint_str = match self_fingerprint(self).await {
Ok(fp) => fp.to_string(),
Err(err) => format!("<key failure: {err}>"),
};
let mvbox_move = self.get_config_int(Config::MvboxMove).await?;
let only_fetch_mvbox = self.get_config_int(Config::OnlyFetchMvbox).await?;
let folders_configured = self
.sql
.get_raw_config_int(constants::DC_FOLDERS_CONFIGURED_KEY)
.await?
.unwrap_or_default();
let configured_inbox_folder = self
.get_config(Config::ConfiguredInboxFolder)
.await?
.unwrap_or_else(|| "<unset>".to_string());
let configured_mvbox_folder = self
.get_config(Config::ConfiguredMvboxFolder)
.await?
.unwrap_or_else(|| "<unset>".to_string());
let mut res = get_info();
@@ -927,6 +961,11 @@ impl Context {
}
}
res.insert("secondary_addrs", secondary_addrs);
res.insert(
"show_emails",
self.get_config_int(Config::ShowEmails).await?.to_string(),
);
res.insert(
"who_can_call_me",
self.get_config_int(Config::WhoCanCallMe).await?.to_string(),
@@ -937,12 +976,21 @@ impl Context {
.await?
.to_string(),
);
res.insert("mvbox_move", mvbox_move.to_string());
res.insert("only_fetch_mvbox", only_fetch_mvbox.to_string());
res.insert(
constants::DC_FOLDERS_CONFIGURED_KEY,
folders_configured.to_string(),
);
res.insert("configured_inbox_folder", configured_inbox_folder);
res.insert("configured_mvbox_folder", configured_mvbox_folder);
res.insert("mdns_enabled", mdns_enabled.to_string());
res.insert("bcc_self", bcc_self.to_string());
res.insert("sync_msgs", sync_msgs.to_string());
res.insert("disable_idle", disable_idle.to_string());
res.insert("private_key_count", prv_key_cnt.to_string());
res.insert("public_key_count", pub_key_cnt.to_string());
res.insert("fingerprint", fingerprint_str);
res.insert(
"media_quality",
self.get_config_int(Config::MediaQuality).await?.to_string(),
@@ -953,6 +1001,12 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"delete_server_after",
self.get_config_int(Config::DeleteServerAfter)
.await?
.to_string(),
);
res.insert(
"last_housekeeping",
self.get_config_int(Config::LastHousekeeping)
@@ -965,6 +1019,24 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"quota_exceeding",
self.get_config_int(Config::QuotaExceeding)
.await?
.to_string(),
);
res.insert(
"authserv_id_candidates",
self.get_config(Config::AuthservIdCandidates)
.await?
.unwrap_or_default(),
);
res.insert(
"sign_unencrypted",
self.get_config_int(Config::SignUnencrypted)
.await?
.to_string(),
);
res.insert(
"debug_logging",
self.get_config_int(Config::DebugLogging).await?.to_string(),
@@ -1030,12 +1102,6 @@ impl Context {
"team_profile",
self.get_config_bool(Config::TeamProfile).await?.to_string(),
);
res.insert(
"force_encryption",
self.get_config_bool(Config::ForceEncryption)
.await?
.to_string(),
);
let elapsed = time_elapsed(&self.creation_time);
res.insert("uptime", duration_to_str(elapsed));
@@ -1076,17 +1142,10 @@ ORDER BY m.timestamp DESC,m.id DESC",
Ok(list)
}
/// (deprecated) Returns a list of messages with database ID higher than requested.
/// Returns a list of messages with database ID higher than requested.
///
/// Blocked contacts and chats are excluded,
/// but self-sent messages and contact requests are included in the results.
///
/// Deprecated 2026-04: This returns the message's id as soon as the first part arrives,
/// even if it is not fully downloaded yet.
/// The bot needs to wait for the message to be fully downloaded.
/// Since this is usually not the desired behavior,
/// bots should instead use the [`EventType::IncomingMsg`]
/// event for getting notified about new messages.
pub async fn get_next_msgs(&self) -> Result<Vec<MsgId>> {
let last_msg_id = match self.get_config(Config::LastMsgId).await? {
Some(s) => MsgId::new(s.parse()?),
@@ -1135,7 +1194,7 @@ ORDER BY m.timestamp DESC,m.id DESC",
Ok(list)
}
/// (deprecated) Returns a list of messages with database ID higher than last marked as seen.
/// Returns a list of messages with database ID higher than last marked as seen.
///
/// This function is supposed to be used by bot to request messages
/// that are not processed yet.
@@ -1145,13 +1204,6 @@ ORDER BY m.timestamp DESC,m.id DESC",
/// shortly after notification or notification is manually triggered
/// to interrupt waiting.
/// Notification may be manually triggered by calling [`Self::stop_io`].
///
/// Deprecated 2026-04: This returns the message's id as soon as the first part arrives,
/// even if it is not fully downloaded yet.
/// The bot needs to wait for the message to be fully downloaded.
/// Since this is usually not the desired behavior,
/// bots should instead use the #DC_EVENT_INCOMING_MSG / [`EventType::IncomingMsg`]
/// event for getting notified about new messages.
pub async fn wait_next_msgs(&self) -> Result<Vec<MsgId>> {
self.new_msgs_notify.notified().await;
let list = self.get_next_msgs().await?;
@@ -1231,6 +1283,12 @@ ORDER BY m.timestamp DESC,m.id DESC",
Ok(list)
}
/// Returns true if given folder name is the name of the "DeltaChat" folder.
pub async fn is_mvbox(&self, folder_name: &str) -> Result<bool> {
let mvbox = self.get_config(Config::ConfiguredMvboxFolder).await?;
Ok(mvbox.as_deref() == Some(folder_name))
}
pub(crate) fn derive_blobdir(dbfile: &Path) -> PathBuf {
let mut blob_fname = OsString::new();
blob_fname.push(dbfile.file_name().unwrap_or_default());

View File

@@ -8,7 +8,7 @@ use crate::chatlist::Chatlist;
use crate::constants::Chattype;
use crate::message::Message;
use crate::receive_imf::receive_imf;
use crate::test_utils::{E2EE_INFO_MSGS, TestContext, TestContextManager};
use crate::test_utils::{E2EE_INFO_MSGS, TestContext};
use crate::tools::{SystemTime, create_outgoing_rfc724_mid};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -54,7 +54,6 @@ async fn receive_msg(t: &TestContext, chat: &Chat) {
async fn test_get_fresh_msgs_and_muted_chats() {
// receive various mails in 3 chats
let t = TestContext::new_alice().await;
t.allow_unencrypted().await.unwrap();
let bob = t.create_chat_with_contact("", "bob@g.it").await;
let claire = t.create_chat_with_contact("", "claire@g.it").await;
let dave = t.create_chat_with_contact("", "dave@g.it").await;
@@ -104,7 +103,6 @@ async fn test_get_fresh_msgs_and_muted_chats() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_fresh_msgs_and_muted_until() {
let t = TestContext::new_alice().await;
t.allow_unencrypted().await.unwrap();
let bob = t.create_chat_with_contact("", "bob@g.it").await;
receive_msg(&t, &bob).await;
assert_eq!(get_chat_msgs(&t, bob.id).await.unwrap().len(), 1);
@@ -160,7 +158,6 @@ async fn test_get_fresh_msgs_and_muted_until() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_muted_context() -> Result<()> {
let t = TestContext::new_alice().await;
t.allow_unencrypted().await?;
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0);
t.set_config(Config::IsMuted, Some("1")).await?;
let chat = t.create_chat_with_contact("", "bob@g.it").await;
@@ -287,6 +284,7 @@ async fn test_get_info_completeness() {
"send_security",
"server_flags",
"skip_start_messages",
"smtp_certificate_checks",
"proxy_url", // May contain passwords, don't leak it to the logs.
"socks5_enabled", // SOCKS5 options are deprecated.
"socks5_host",
@@ -327,7 +325,6 @@ async fn test_get_info_completeness() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_search_msgs() -> Result<()> {
let alice = TestContext::new_alice().await;
alice.allow_unencrypted().await?;
let self_talk = ChatId::create_for_contact(&alice, ContactId::SELF).await?;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.org")
@@ -389,40 +386,49 @@ async fn test_search_msgs() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_search_unaccepted_requests() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let bob = &tcm.bob().await;
bob.set_config(Config::Displayname, Some("BobBar")).await?;
let msg = tcm.send_recv(bob, t, "hello bob, foobar test!").await;
let chat_id = msg.get_chat_id();
let chat = Chat::load_from_db(t, chat_id).await?;
let t = TestContext::new_alice().await;
receive_imf(
&t,
b"From: BobBar <bob@example.org>\n\
To: alice@example.org\n\
Subject: foo\n\
Message-ID: <msg1234@example.org>\n\
Chat-Version: 1.0\n\
Date: Tue, 25 Oct 2022 13:37:00 +0000\n\
\n\
hello bob, foobar test!\n",
false,
)
.await?;
let chat_id = t.get_last_msg().await.get_chat_id();
let chat = Chat::load_from_db(&t, chat_id).await?;
assert_eq!(chat.get_type(), Chattype::Single);
assert!(chat.is_contact_request());
assert_eq!(Chatlist::try_load(t, 0, None, None).await?.len(), 1);
assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 1);
assert_eq!(
Chatlist::try_load(t, 0, Some("BobBar"), None).await?.len(),
Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(),
1
);
assert_eq!(t.search_msgs(None, "foobar").await?.len(), 1);
assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 1);
chat_id.block(t).await?;
chat_id.block(&t).await?;
assert_eq!(Chatlist::try_load(t, 0, None, None).await?.len(), 0);
assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 0);
assert_eq!(
Chatlist::try_load(t, 0, Some("BobBar"), None).await?.len(),
Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(),
0
);
assert_eq!(t.search_msgs(None, "foobar").await?.len(), 0);
assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 0);
let contact_ids = get_chat_contacts(t, chat_id).await?;
Contact::unblock(t, *contact_ids.first().unwrap()).await?;
let contact_ids = get_chat_contacts(&t, chat_id).await?;
Contact::unblock(&t, *contact_ids.first().unwrap()).await?;
assert_eq!(Chatlist::try_load(t, 0, None, None).await?.len(), 1);
assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 1);
assert_eq!(
Chatlist::try_load(t, 0, Some("BobBar"), None).await?.len(),
Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(),
1
);
assert_eq!(t.search_msgs(None, "foobar").await?.len(), 1);
@@ -434,7 +440,6 @@ async fn test_search_unaccepted_requests() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_limit_search_msgs() -> Result<()> {
let alice = TestContext::new_alice().await;
alice.allow_unencrypted().await?;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.org")
.await;
@@ -598,7 +603,10 @@ async fn test_get_next_msgs() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cache_is_cleared_when_io_is_started() -> Result<()> {
let alice = TestContext::new_alice().await;
assert_eq!(alice.get_config(Config::Displayname).await?, None);
assert_eq!(
alice.get_config(Config::ShowEmails).await?,
Some("2".to_string())
);
// Change the config circumventing the cache
// This simulates what the notification plugin on iOS might do
@@ -606,21 +614,24 @@ async fn test_cache_is_cleared_when_io_is_started() -> Result<()> {
alice
.sql
.execute(
"INSERT OR REPLACE INTO config (keyname, value) VALUES ('displayname', 'Alice 2')",
"INSERT OR REPLACE INTO config (keyname, value) VALUES ('show_emails', '0')",
(),
)
.await?;
// Alice's Delta Chat doesn't know about it yet:
assert_eq!(alice.get_config(Config::Displayname).await?, None);
assert_eq!(
alice.get_config(Config::ShowEmails).await?,
Some("2".to_string())
);
// Starting IO will fail of course because no server settings are configured,
// but it should invalidate the caches:
alice.start_io().await;
assert_eq!(
alice.get_config(Config::Displayname).await?,
Some("Alice 2".to_string())
alice.get_config(Config::ShowEmails).await?,
Some("0".to_string())
);
Ok(())

View File

@@ -402,7 +402,6 @@ mod tests {
assert!(get_attachment_mime(&mail).is_some());
let bob = TestContext::new_bob().await;
bob.allow_unencrypted().await?;
receive_imf(&bob, attachment_mime, false).await?;
let msg = bob.get_last_msg().await;
// Subject should be prepended because the attachment doesn't have "Chat-Version".
@@ -417,7 +416,6 @@ mod tests {
// Desktop via MS Exchange (actually made with TB though).
let mixed_up_mime = include_bytes!("../test-data/message/mixed-up-long.eml");
let bob = TestContext::new_bob().await;
bob.allow_unencrypted().await?;
receive_imf(&bob, mixed_up_mime, false).await?;
let msg = bob.get_last_msg().await;
assert!(!msg.get_text().is_empty());

View File

@@ -6,16 +6,21 @@ use anyhow::{Result, anyhow, bail, ensure};
use deltachat_derive::{FromSql, ToSql};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::context::Context;
use crate::imap::session::Session;
use crate::log::warn;
use crate::message::{self, Message, MsgId, rfc724_mid_exists};
use crate::{EventType, chatlist_events, ephemeral};
use crate::{EventType, chatlist_events};
pub(crate) mod post_msg_metadata;
pub(crate) use post_msg_metadata::PostMsgMetadata;
/// If a message is downloaded only partially
/// and `delete_server_after` is set to small timeouts (eg. "at once"),
/// the user might have no chance to actually download that message.
/// `MIN_DELETE_SERVER_AFTER` increases the timeout in this case.
pub(crate) const MIN_DELETE_SERVER_AFTER: i64 = 48 * 60 * 60;
/// From this point onward outgoing messages are considered large
/// and get a Pre-Message, which announces the Post-Message.
/// This is only about sending so we can modify it any time.
@@ -168,17 +173,9 @@ pub(crate) async fn download_msg(
if msg_transport_id != transport_id {
return Ok(None);
}
Box::pin(session.fetch_single_msg(context, &server_folder, server_uid, rfc724_mid)).await?;
let bcc_self = context.get_config_bool(Config::BccSelf).await?;
if ephemeral::should_delete_all_downloaded_messages(bcc_self, session.is_chatmail()) {
// Now that the message was downloaded, it likely needs to be deleted;
// trigger a re-check by interrupting the inbox folder.
// This is mainly needed to make the tests pass;
// on real devices, it would be fine to delete the message at the next iteration.
context.scheduler.interrupt_inbox().await;
}
session
.fetch_single_msg(context, &server_folder, server_uid, rfc724_mid)
.await?;
Ok(Some(()))
}
@@ -207,11 +204,8 @@ impl Session {
let mut uid_message_ids: BTreeMap<u32, String> = BTreeMap::new();
uid_message_ids.insert(uid, rfc724_mid);
let (sender, receiver) = async_channel::unbounded();
{
let _fetch_msgs_lock_guard = context.fetch_msgs_mutex.lock().await;
self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, sender)
.await?;
}
self.fetch_many_msgs(context, folder, vec![uid], &uid_message_ids, sender)
.await?;
if receiver.recv().await.is_err() {
bail!("Failed to fetch UID {uid}");
}
@@ -363,7 +357,7 @@ mod tests {
use super::*;
use crate::chat::send_msg;
use crate::test_utils::TestContextManager;
use crate::test_utils::TestContext;
#[test]
fn test_downloadstate_values() {
@@ -383,14 +377,12 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_update_download_state() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat_id = t.create_chat_id(bob).await;
let t = TestContext::new_alice().await;
let chat = t.create_chat_with_contact("Bob", "bob@example.org").await;
let mut msg = Message::new_text("Hi Bob".to_owned());
let msg_id = send_msg(t, chat_id, &mut msg).await?;
let msg = Message::load_from_db(t, msg_id).await?;
let msg_id = send_msg(&t, chat.id, &mut msg).await?;
let msg = Message::load_from_db(&t, msg_id).await?;
assert_eq!(msg.download_state(), DownloadState::Done);
for s in &[
@@ -400,15 +392,17 @@ mod tests {
DownloadState::Done,
DownloadState::Done,
] {
msg_id.update_download_state(t, *s).await?;
let msg = Message::load_from_db(t, msg_id).await?;
msg_id.update_download_state(&t, *s).await?;
let msg = Message::load_from_db(&t, msg_id).await?;
assert_eq!(msg.download_state(), *s);
}
t.sql
.execute("DELETE FROM msgs WHERE id=?", (msg_id,))
.await?;
// Nothing to do is ok.
msg_id.update_download_state(t, DownloadState::Done).await?;
msg_id
.update_download_state(&t, DownloadState::Done)
.await?;
Ok(())
}

View File

@@ -42,25 +42,12 @@ impl EncryptHelper {
compress: bool,
seipd_version: SeipdVersion,
) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;
let mut raw_message = Vec::new();
let cursor = Cursor::new(&mut raw_message);
mail_to_encrypt.clone().write_part(cursor).ok();
let ctext = self
.encrypt_raw(context, keyring, raw_message, compress, seipd_version)
.await?;
Ok(ctext)
}
pub async fn encrypt_raw(
self,
context: &Context,
keyring: Vec<SignedPublicKey>,
raw_message: Vec<u8>,
compress: bool,
seipd_version: SeipdVersion,
) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;
let ctext =
pgp::pk_encrypt(raw_message, keyring, sign_key, compress, seipd_version).await?;
@@ -92,6 +79,16 @@ impl EncryptHelper {
Ok(ctext)
}
/// Signs the passed-in `mail` using the private key from `context`.
/// Returns the payload and the signature.
pub async fn sign(self, context: &Context, mail: &MimePart<'static>) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;
let mut buffer = Vec::new();
mail.clone().write_part(&mut buffer)?;
let signature = pgp::pk_calc_signature(buffer, &sign_key)?;
Ok(signature)
}
}
/// Ensures a private key exists for the configured user.
@@ -109,11 +106,9 @@ pub async fn ensure_secret_key_exists(context: &Context) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use crate::chat;
use crate::chat::send_text_msg;
use crate::config::Config;
use crate::message::Message;
use crate::mimeparser::SystemMessage;
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager};
@@ -157,34 +152,11 @@ Sent with my Delta Chat Messenger: https://delta.chat";
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cannot_send_unencrypted_by_default() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat = alice.create_email_chat(bob).await;
let mut msg = Message::new_text("Hello!".to_string());
assert!(chat::send_msg(alice, chat.id, &mut msg).await.is_err());
assert_eq!(
msg.error().unwrap(),
"\u{26a0}\u{fe0f} Your email provider example.org requires end-to-end encryption which is not setup yet."
);
let info_msg = alice.get_last_msg().await;
assert_eq!(
info_msg.get_info_type(),
SystemMessage::InvalidUnencryptedMail
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chatmail_can_send_unencrypted() -> Result<()> {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
bob.set_config_bool(Config::IsChatmail, true).await?;
bob.allow_unencrypted().await?;
let bob_chat_id = receive_imf(
bob,
b"From: alice@example.org\n\

View File

@@ -23,15 +23,16 @@
//! ## Device settings
//!
//! In addition to per-chat ephemeral message setting, each device has
//! a global user-configured setting that complements per-chat
//! settings, `delete_device_after`.
//! This setting is not synchronized among devices and applies to all
//! two global user-configured settings that complement per-chat
//! settings: `delete_device_after` and `delete_server_after`. These
//! settings are not synchronized among devices and apply to all
//! messages known to the device, including messages sent or received
//! before configuring the setting.
//!
//! `delete_device_after` configures the maximum time device is
//! storing the messages locally,
//! but does not delete messages from the server.
//! storing the messages locally. `delete_server_after` configures the
//! time after which device will delete the messages it knows about
//! from the server.
//!
//! ## How messages are deleted
//!
@@ -59,8 +60,9 @@
//!
//! Server deletion happens by updating the `imap` table based on
//! the database entries which are expired either according to their
//! ephemeral message timers.
//! ephemeral message timers or global `delete_server_after` setting.
use std::cmp::max;
use std::collections::BTreeSet;
use std::fmt;
use std::num::ParseIntError;
@@ -73,11 +75,10 @@ use serde::{Deserialize, Serialize};
use tokio::time::timeout;
use crate::chat::{ChatId, ChatIdBlocked, send_msg};
use crate::config::Config;
use crate::constants::{DC_CHAT_ID_LAST_SPECIAL, DC_CHAT_ID_TRASH};
use crate::contact::ContactId;
use crate::context::Context;
use crate::download::DownloadState;
use crate::download::MIN_DELETE_SERVER_AFTER;
use crate::events::EventType;
use crate::log::{LogExt, warn};
use crate::message::{Message, MessageState, MsgId, Viewtype};
@@ -454,14 +455,11 @@ WHERE
Ok(rows)
}
/// Locally deletes messages which are expired according to
/// Deletes messages which are expired according to
/// `delete_device_after` setting or `ephemeral_timestamp` column.
///
/// Emits relevant `MsgsChanged` and `WebxdcInstanceDeleted` events
/// if messages are deleted.
///
/// Also see [`delete_expired_imap_messages`],
/// which marks the messages for deletion on the IMAP server.
pub(crate) async fn delete_expired_messages(context: &Context, now: i64) -> Result<()> {
let rows = select_expired_messages(context, now).await?;
@@ -652,99 +650,42 @@ pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiv
}
}
/// Schedules expired IMAP messages for deletion on the server.
///
/// Also see [`delete_expired_messages`],
/// which locally deletes expired messages.
pub(crate) async fn delete_expired_imap_messages(
context: &Context,
transport_id: u32,
is_chatmail: bool,
) -> Result<()> {
/// Schedules expired IMAP messages for deletion.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn delete_expired_imap_messages(context: &Context) -> Result<()> {
let now = time();
let bcc_self = context.get_config_bool(Config::BccSelf).await?;
if should_delete_all_downloaded_messages(bcc_self, is_chatmail) {
// This is the only device using this relay.
// Mark all downloaded messages for deletion, because they are not needed anymore.
//
// For pre- and post-messages, `rfc724_mid` contains the post-message's Message-Id.
// The pre-message's Message-Id is in pre_rfc724_mid, if it exists.
//
// Pre-messages can be deleted even if the message wasn't fully downloaded yet,
// because it's only the post-message that hasn't been downloaded.
context
.sql
.execute(
"UPDATE imap
SET target=''
WHERE transport_id=?1
AND rfc724_mid IN (
SELECT rfc724_mid FROM msgs
WHERE ((ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2) OR download_state=?3)
AND id>9
UNION
SELECT pre_rfc724_mid FROM msgs
WHERE pre_rfc724_mid!=''
AND id>9
)",
(transport_id, now, DownloadState::Done),
)
.await?;
} else if bcc_self {
// There may be other devices using this relay,
// either because there is multi-device or because this is a classical email server.
// Only delete expired ephemeral messages.
context
.sql
.execute(
"UPDATE imap
SET target=''
WHERE transport_id=?1
AND rfc724_mid IN (
SELECT rfc724_mid FROM msgs
WHERE ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2 AND id>9
UNION
SELECT pre_rfc724_mid FROM msgs
WHERE pre_rfc724_mid!=''
AND ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2 AND id>9
)",
(transport_id, now),
)
.await?;
} else {
// Single device.
// Delete all expired and encrypted messages.
context
.sql
.execute(
"UPDATE imap
SET target=''
WHERE transport_id=?1
AND rfc724_mid IN (
SELECT rfc724_mid FROM msgs
WHERE id>9
AND ((ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2) OR
((param GLOB '*\nc=1*' OR param GLOB 'c=1*') AND download_state=?3))
UNION
SELECT pre_rfc724_mid FROM msgs
WHERE pre_rfc724_mid!=''
AND id>9
AND ((ephemeral_timestamp!=0 AND ephemeral_timestamp<=?2) OR
(param GLOB '*\nc=1*' OR param GLOB 'c=1*'))
)",
(transport_id, now, DownloadState::Done),
)
.await?;
}
let (threshold_timestamp, threshold_timestamp_extended) =
match context.get_config_delete_server_after().await? {
None => (0, 0),
Some(delete_server_after) => (
match delete_server_after {
// Guarantee immediate deletion.
0 => i64::MAX,
_ => now - delete_server_after,
},
now - max(delete_server_after, MIN_DELETE_SERVER_AFTER),
),
};
context
.sql
.execute(
"UPDATE imap
SET target=''
WHERE rfc724_mid IN (
SELECT rfc724_mid FROM msgs
WHERE ((download_state = 0 AND timestamp < ?) OR
(download_state != 0 AND timestamp < ?) OR
(ephemeral_timestamp != 0 AND ephemeral_timestamp <= ?))
)",
(threshold_timestamp, threshold_timestamp_extended, now),
)
.await?;
Ok(())
}
pub(crate) fn should_delete_all_downloaded_messages(bcc_self: bool, is_chatmail: bool) -> bool {
!bcc_self && is_chatmail
}
/// Start ephemeral timers for seen messages if they are not started
/// yet.
///

View File

@@ -9,7 +9,6 @@ use crate::download::DownloadState;
use crate::location;
use crate::message::markseen_msgs;
use crate::receive_imf::receive_imf;
use crate::test_utils;
use crate::test_utils::{TestContext, TestContextManager};
use crate::timesmearing::MAX_SECONDS_TO_LEND_FROM_FUTURE;
use crate::{
@@ -311,22 +310,20 @@ async fn test_ephemeral_timer_rollback() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_delete_msgs() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let bob = &tcm.bob().await;
let t = TestContext::new_alice().await;
let self_chat = t.get_self_chat().await;
assert_eq!(next_expiration_timestamp(t).await, None);
assert_eq!(next_expiration_timestamp(&t).await, None);
t.send_text(self_chat.id, "Saved message, which we delete manually")
.await;
let msg = t.get_last_msg_in(self_chat.id).await;
msg.id.trash(t, false).await?;
check_msg_is_deleted(t, &self_chat, msg.id).await;
msg.id.trash(&t, false).await?;
check_msg_is_deleted(&t, &self_chat, msg.id).await;
self_chat
.id
.set_ephemeral_timer(t, Timer::Enabled { duration: 3600 })
.set_ephemeral_timer(&t, Timer::Enabled { duration: 3600 })
.await
.unwrap();
@@ -334,7 +331,7 @@ async fn test_ephemeral_delete_msgs() -> Result<()> {
let now = time();
let msg = t.send_text(self_chat.id, "Message text").await;
check_msg_will_be_deleted(t, msg.sender_msg_id, &self_chat, now + 3599, time() + 3601)
check_msg_will_be_deleted(&t, msg.sender_msg_id, &self_chat, now + 3599, time() + 3601)
.await
.unwrap();
@@ -346,17 +343,17 @@ async fn test_ephemeral_delete_msgs() -> Result<()> {
let now = time();
let msg = t.send_text(self_chat.id, "Message text").await;
check_msg_will_be_deleted(t, msg.sender_msg_id, &self_chat, now + 3559, time() + 3601)
check_msg_will_be_deleted(&t, msg.sender_msg_id, &self_chat, now + 3559, time() + 3601)
.await
.unwrap();
// Send a message to Bob which will be deleted after 1800s because of DeleteDeviceAfter.
let bob_chat = t.create_chat(bob).await;
let bob_chat = t.create_chat_with_contact("", "bob@example.net").await;
let now = time();
let msg = t.send_text(bob_chat.id, "Message text").await;
check_msg_will_be_deleted(
t,
&t,
msg.sender_msg_id,
&bob_chat,
now + 1799,
@@ -371,13 +368,13 @@ async fn test_ephemeral_delete_msgs() -> Result<()> {
// This tests that the message is deleted at min(ephemeral deletion time, DeleteDeviceAfter deletion time).
bob_chat
.id
.set_ephemeral_timer(t, Timer::Enabled { duration: 60 })
.set_ephemeral_timer(&t, Timer::Enabled { duration: 60 })
.await?;
let now = time();
let msg = t.send_text(bob_chat.id, "Message text").await;
check_msg_will_be_deleted(t, msg.sender_msg_id, &bob_chat, now + 59, time() + 61)
check_msg_will_be_deleted(&t, msg.sender_msg_id, &bob_chat, now + 59, time() + 61)
.await
.unwrap();
@@ -452,194 +449,107 @@ async fn check_msg_is_deleted(t: &TestContext, chat: &Chat, msg_id: MsgId) {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_expired_imap_messages() -> Result<()> {
let t = TestContext::new_alice().await;
const HOUR: i64 = 60 * 60;
let now = time();
let transport_id: u32 = 1;
let uidvalidity = 12345u32;
t.set_config_bool(Config::BccSelf, false).await?;
async fn is_deleted(context: &Context, mid: &str) -> Result<bool> {
Ok(context
.sql
.count(
"SELECT COUNT(*) FROM imap WHERE target='' AND rfc724_mid=?",
(mid,),
let transport_id = 1;
let uidvalidity = 12345;
for (id, timestamp, ephemeral_timestamp) in &[
(900, now - 2 * HOUR, 0),
(1000, now - 23 * HOUR - MIN_DELETE_SERVER_AFTER, 0),
(1010, now - 23 * HOUR, 0),
(1020, now - 21 * HOUR, 0),
(1030, now - 19 * HOUR, 0),
(2000, now - 18 * HOUR, now - HOUR),
(2020, now - 17 * HOUR, now + HOUR),
(3000, now + HOUR, 0),
] {
let message_id = id.to_string();
t.sql
.execute(
"INSERT INTO msgs (id, rfc724_mid, timestamp, ephemeral_timestamp) VALUES (?,?,?,?);",
(id, &message_id, timestamp, ephemeral_timestamp),
)
.await?;
t.sql
.execute(
"INSERT INTO imap (transport_id, rfc724_mid, folder, uid, target, uidvalidity) VALUES (?, ?,'INBOX',?, 'INBOX', ?);",
(transport_id, &message_id, id, uidvalidity),
)
.await?
== 1)
.await?;
}
async fn reset_targets(context: &Context) {
async fn test_marked_for_deletion(context: &Context, id: u32) -> Result<()> {
assert_eq!(
context
.sql
.count(
"SELECT COUNT(*) FROM imap WHERE target='' AND rfc724_mid=?",
(id.to_string(),),
)
.await?,
1
);
Ok(())
}
async fn remove_uid(context: &Context, id: u32) -> Result<()> {
context
.sql
.execute("UPDATE imap SET target='INBOX'", ())
.await
.unwrap();
}
// Test messages:
//
// Four messages that were not split into pre- and post- message:
// "expired@localhost" - expired ephemeral message
// "no_expire@localhost" - non-ephemeral message
// "no_expire_unencrypted@localhost" - non-ephemeral message, not encrypted
// "future@localhost" - will expire in the future, but not yet
//
// And four messages that were split into pre- and post-message.
// "expired_*@localhost" - has pre-msg, expired ephemeral message, not downloaded yet
// "no_expire_*@localhost" - has pre-msg, non-ephemeral, not downloaded yet
// "future_*@localhost" - has pre-msg, not expired yet, not downloaded yet
// "done_*@localhost" - Fully downloaded -> post- message can be deleted
//
// The tuple is (rfc724_mid, ephemeral_timestamp, download_state, pre_rfc724_mid, is_encrypted)
let msgs: [(&str, i64, DownloadState, &str, bool); 8] = [
("expired@localhost", now - 1, DownloadState::Done, "", true),
("no_expire@localhost", 0, DownloadState::Done, "", true),
(
"no_expire_unencrypted@localhost",
0,
DownloadState::Done,
"",
false,
),
// Use "now + 3600" rather than "now + 1", otherwise the test may be flaky
// if it is slow and the message expires in a second
(
"future@localhost",
now + 3600,
DownloadState::Done,
"",
true,
),
(
"expired_post@localhost",
now - 1,
DownloadState::Available,
"expired_pre@localhost",
true,
),
(
"no_expire_post@localhost",
0,
DownloadState::Available,
"no_expire_pre@localhost",
true,
),
(
"future_post@localhost",
now + 3600,
DownloadState::Available,
"future_pre@localhost",
true,
),
(
"done_post@localhost",
0,
DownloadState::Done,
"done_pre@localhost",
true,
),
];
for (rfc724_mid, ephemeral_timestamp, download_state, pre_rfc724_mid, is_encrypted) in msgs {
t.sql
.execute(
"INSERT INTO msgs \
(rfc724_mid, timestamp, ephemeral_timestamp, download_state, pre_rfc724_mid, param) \
VALUES (?,?,?,?,?,?)",
(
rfc724_mid,
now,
ephemeral_timestamp,
download_state,
pre_rfc724_mid,
if is_encrypted {
"c=1"
} else {
""
}
),
)
.execute("DELETE FROM imap WHERE rfc724_mid=?", (id.to_string(),))
.await?;
Ok(())
}
let rfc724_mids: Vec<&str> = msgs
.iter()
.flat_map(|(rfc724_mid, _, _, pre_rfc724_mid, _is_encrypted)| {
[*rfc724_mid, *pre_rfc724_mid]
})
.filter(|s| !s.is_empty())
.collect();
for (i, rfc724_mid) in rfc724_mids.iter().enumerate() {
// This should mark message 2000 for deletion.
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 2000).await?;
remove_uid(&t, 2000).await?;
// No other messages are marked for deletion.
assert_eq!(
t.sql
.execute(
"INSERT INTO imap \
(transport_id, rfc724_mid, folder, uid, target, uidvalidity) \
VALUES (?,?,'INBOX',?,'INBOX',?)",
(transport_id, *rfc724_mid, (i + 1) as u32, uidvalidity),
)
.await?;
}
.count("SELECT COUNT(*) FROM imap WHERE target=''", ())
.await?,
0
);
for (is_chatmail, other_transport, bcc_self) in [
(false, false, false),
(false, false, true),
(false, true, false),
(false, true, true),
(true, false, false),
(true, false, true),
(true, true, false),
(true, true, true),
] {
println!(
"Testing combination is_chatmail={is_chatmail}, other_transport={other_transport}, bcc_self={bcc_self}"
);
t.set_config(Config::DeleteServerAfter, Some(&*(25 * HOUR).to_string()))
.await?;
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 1000).await?;
t.set_config_bool(Config::BccSelf, bcc_self).await?;
MsgId::new(1000)
.update_download_state(&t, DownloadState::Available)
.await?;
t.sql
.execute("UPDATE imap SET target=folder WHERE rfc724_mid='1000'", ())
.await?;
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 1000).await?; // Delete downloadable anyway.
remove_uid(&t, 1000).await?;
delete_expired_imap_messages(
&t,
if other_transport {
transport_id + 1
} else {
transport_id
},
is_chatmail,
)
t.set_config(Config::DeleteServerAfter, Some(&*(22 * HOUR).to_string()))
.await?;
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 1010).await?;
t.sql
.execute("UPDATE imap SET target=folder WHERE rfc724_mid='1010'", ())
.await?;
if other_transport {
// Nothing should be deleted on another transport
for rfc724_mid in &rfc724_mids {
assert_eq!(is_deleted(&t, rfc724_mid).await?, false);
}
continue;
}
MsgId::new(1010)
.update_download_state(&t, DownloadState::Available)
.await?;
delete_expired_imap_messages(&t).await?;
// Keep downloadable for now.
assert_eq!(
t.sql
.count("SELECT COUNT(*) FROM imap WHERE target=''", ())
.await?,
0
);
assert_eq!(is_deleted(&t, "expired@localhost").await?, true);
assert_eq!(is_deleted(&t, "no_expire@localhost").await?, !bcc_self);
assert_eq!(
is_deleted(&t, "no_expire_unencrypted@localhost").await?,
is_chatmail && !bcc_self
);
assert_eq!(is_deleted(&t, "future@localhost").await?, !bcc_self);
assert_eq!(is_deleted(&t, "expired_post@localhost").await?, true);
assert_eq!(is_deleted(&t, "expired_pre@localhost").await?, true);
assert_eq!(is_deleted(&t, "no_expire_post@localhost").await?, false);
assert_eq!(is_deleted(&t, "no_expire_pre@localhost").await?, !bcc_self);
assert_eq!(is_deleted(&t, "future_post@localhost").await?, false);
assert_eq!(is_deleted(&t, "future_pre@localhost").await?, !bcc_self);
assert_eq!(is_deleted(&t, "done_pre@localhost").await?, !bcc_self);
assert_eq!(is_deleted(&t, "done_post@localhost").await?, !bcc_self);
reset_targets(&t).await;
}
// With BccSelf=true, non-expired messages are kept even if `is_chatmail` is true
t.set_config_bool(Config::BccSelf, true).await?;
delete_expired_imap_messages(&t, transport_id, true).await?;
assert_eq!(is_deleted(&t, "expired@localhost").await?, true);
assert_eq!(is_deleted(&t, "no_expire@localhost").await?, false);
assert_eq!(is_deleted(&t, "done_pre@localhost").await?, false);
assert_eq!(is_deleted(&t, "done_post@localhost").await?, false);
t.set_config(Config::DeleteServerAfter, Some("1")).await?;
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 3000).await?;
Ok(())
}
@@ -647,54 +557,50 @@ async fn test_delete_expired_imap_messages() -> Result<()> {
// Regression test for a bug in the timer rollback protection.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_timer_references() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice = TestContext::new_alice().await;
// Message with Message-ID <first@example.com> and no timer is received.
let encrypted_msg = test_utils::encrypt_raw_message(
bob,
&[alice],
b"From: Bob <bob@example.net>\r\n\
To: Alice <alice@example.org>\r\n\
Chat-Version: 1.0\r\n\
Subject: Subject\r\n\
Message-ID: <first@example.com>\r\n\
Date: Sun, 22 Mar 2020 00:10:00 +0000\r\n\
\r\n\
hello\r\n",
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: Alice <alice@example.org>\n\
Chat-Version: 1.0\n\
Subject: Subject\n\
Message-ID: <first@example.com>\n\
Date: Sun, 22 Mar 2020 00:10:00 +0000\n\
\n\
hello\n",
false,
)
.await?;
receive_imf(alice, encrypted_msg.as_bytes(), false).await?;
let msg = alice.get_last_msg().await;
let chat_id = msg.chat_id;
assert_eq!(chat_id.get_ephemeral_timer(alice).await?, Timer::Disabled);
assert_eq!(chat_id.get_ephemeral_timer(&alice).await?, Timer::Disabled);
// Message with Message-ID <second@example.com> is received.
let encrypted_msg = test_utils::encrypt_raw_message(
bob,
&[alice],
b"From: Bob <bob@example.net>\r\n\
To: Alice <alice@example.org>\r\n\
Chat-Version: 1.0\r\n\
Subject: Subject\r\n\
Message-ID: <second@example.com>\r\n\
Date: Sun, 22 Mar 2020 00:11:00 +0000\r\n\
Ephemeral-Timer: 60\r\n\
\r\n\
second message\r\n",
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: Alice <alice@example.org>\n\
Chat-Version: 1.0\n\
Subject: Subject\n\
Message-ID: <second@example.com>\n\
Date: Sun, 22 Mar 2020 00:11:00 +0000\n\
Ephemeral-Timer: 60\n\
\n\
second message\n",
false,
)
.await?;
receive_imf(alice, encrypted_msg.as_bytes(), false).await?;
assert_eq!(
chat_id.get_ephemeral_timer(alice).await?,
chat_id.get_ephemeral_timer(&alice).await?,
Timer::Enabled { duration: 60 }
);
let msg = alice.get_last_msg().await;
// Message is deleted when its timer expires.
msg.id.trash(alice, false).await?;
msg.id.trash(&alice, false).await?;
// Message with Message-ID <third@example.com>, referencing <first@example.com> and
// <second@example.com>, is received. The message <second@example.come> is not in the
@@ -708,26 +614,25 @@ async fn test_ephemeral_timer_references() -> Result<()> {
//
// The message also contains a quote of the first message to test that only References:
// header and not In-Reply-To: is consulted by the rollback protection.
let encrypted_msg = test_utils::encrypt_raw_message(
bob,
&[alice],
b"From: Bob <bob@example.net>\r\n\
To: Alice <alice@example.org>\r\n\
Chat-Version: 1.0\r\n\
Subject: Subject\r\n\
Message-ID: <third@example.com>\r\n\
Date: Sun, 22 Mar 2020 00:12:00 +0000\r\n\
References: <first@example.com> <second@example.com>\r\n\
In-Reply-To: <first@example.com>\r\n\
\r\n\
> hello\r\n",
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: Alice <alice@example.org>\n\
Chat-Version: 1.0\n\
Subject: Subject\n\
Message-ID: <third@example.com>\n\
Date: Sun, 22 Mar 2020 00:12:00 +0000\n\
References: <first@example.com> <second@example.com>\n\
In-Reply-To: <first@example.com>\n\
\n\
> hello\n",
false,
)
.await?;
receive_imf(alice, encrypted_msg.as_bytes(), false).await?;
let msg = alice.get_last_msg().await;
assert_eq!(
msg.chat_id.get_ephemeral_timer(alice).await?,
msg.chat_id.get_ephemeral_timer(&alice).await?,
Timer::Disabled
);
@@ -739,20 +644,24 @@ async fn test_ephemeral_timer_references() -> Result<()> {
// successful reconnection.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_msg_offline() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat = alice.create_chat(bob).await;
let alice = TestContext::new_alice().await;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.org")
.await;
let duration = 60;
chat.id
.set_ephemeral_timer(alice, Timer::Enabled { duration })
.set_ephemeral_timer(&alice, Timer::Enabled { duration })
.await?;
let mut msg = Message::new_text("hi".to_string());
assert!(chat::send_msg_sync(alice, chat.id, &mut msg).await.is_err());
assert!(
chat::send_msg_sync(&alice, chat.id, &mut msg)
.await
.is_err()
);
let stmt = "SELECT COUNT(*) FROM smtp WHERE msg_id=?";
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
let now = time();
check_msg_will_be_deleted(alice, msg.id, &chat, now, now + i64::from(duration) + 1).await?;
check_msg_will_be_deleted(&alice, msg.id, &chat, now, now + i64::from(duration) + 1).await?;
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
Ok(())

View File

@@ -89,9 +89,13 @@ mod test_chatlist_events {
.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
} => {
if ev_chat_id == &chat_id {
first_event_is_item.store(true, Ordering::Relaxed);
true
} else {
false
}
}
EventType::ChatlistChanged => true,
_ => false,
@@ -521,7 +525,6 @@ mod test_chatlist_events {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_group() -> Result<()> {
let alice = TestContext::new_alice().await;
alice.allow_unencrypted().await?;
let mime = br#"Subject: First thread
Message-ID: first@example.org
To: Alice <alice@example.org>, Bob <bob@example.net>

View File

@@ -234,7 +234,8 @@ pub enum EventType {
/// Location of one or more contact has changed.
///
/// @param data1 (u32) contact_id of the contact for which the location has changed.
/// If the locations of several contacts have been changed, this parameter is set to `None`.
/// If the locations of several contacts have been changed,
/// eg. after calling dc_delete_all_locations(), this parameter is set to `None`.
LocationChanged(Option<ContactId>),
/// Inform about the configuration progress started by configure().
@@ -418,6 +419,14 @@ pub enum EventType {
chat_id: ChatId,
},
/// Call missed.
CallMissed {
/// ID of the message referring to the call.
msg_id: MsgId,
/// ID of the chat which the message belongs to.
chat_id: ChatId,
},
/// One or more transports has changed or another transport is primary now.
///
/// UI should update the list.

View File

@@ -86,7 +86,8 @@ impl HtmlMsgParser {
/// Function takes a raw mime-message string,
/// searches for the main-text part
/// and returns that as parser.html
pub fn from_bytes<'a>(
#[expect(clippy::arithmetic_side_effects)]
pub async fn from_bytes<'a>(
context: &Context,
rawmime: &'a [u8],
) -> Result<(Self, mailparse::ParsedMail<'a>)> {
@@ -98,14 +99,14 @@ impl HtmlMsgParser {
let parsedmail = mailparse::parse_mail(rawmime).context("Failed to parse mail")?;
parser.collect_texts_recursive(context, &parsedmail)?;
parser.collect_texts_recursive(context, &parsedmail).await?;
if parser.html.is_empty() {
if let Some(plain) = &parser.plain {
parser.html = plain.to_html();
}
} else {
parser.cid_to_data_recursive(context, &parsedmail)?;
parser.cid_to_data_recursive(context, &parsedmail).await?;
}
parser.html += &mem::take(&mut parser.msg_html);
Ok((parser, parsedmail))
@@ -119,7 +120,8 @@ 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>(
#[expect(clippy::arithmetic_side_effects)]
async fn collect_texts_recursive<'a>(
&'a mut self,
context: &'a Context,
mail: &'a mailparse::ParsedMail<'a>,
@@ -127,7 +129,7 @@ impl HtmlMsgParser {
match get_mime_multipart_type(&mail.ctype) {
MimeMultipartType::Multiple => {
for cur_data in &mail.subparts {
self.collect_texts_recursive(context, cur_data)?
Box::pin(self.collect_texts_recursive(context, cur_data)).await?
}
Ok(())
}
@@ -136,7 +138,7 @@ impl HtmlMsgParser {
if raw.is_empty() {
return Ok(());
}
let (parser, mail) = HtmlMsgParser::from_bytes(context, &raw)?;
let (parser, mail) = Box::pin(HtmlMsgParser::from_bytes(context, &raw)).await?;
if !parser.html.is_empty() {
let mut text = "\r\n\r\n".to_string();
for h in mail.headers {
@@ -199,7 +201,7 @@ impl HtmlMsgParser {
/// 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>,
@@ -207,7 +209,7 @@ impl HtmlMsgParser {
match get_mime_multipart_type(&mail.ctype) {
MimeMultipartType::Multiple => {
for cur_data in &mail.subparts {
self.cid_to_data_recursive(context, cur_data)?;
Box::pin(self.cid_to_data_recursive(context, cur_data)).await?;
}
Ok(())
}
@@ -269,7 +271,7 @@ impl MsgId {
let rawmime = rawmime?;
if !rawmime.is_empty() {
match HtmlMsgParser::from_bytes(context, &rawmime) {
match HtmlMsgParser::from_bytes(context, &rawmime).await {
Err(err) => {
warn!(context, "get_html: parser error: {:#}", err);
Ok(None)
@@ -287,7 +289,7 @@ impl MsgId {
mod tests {
use super::*;
use crate::chat::{self, Chat, forward_msgs, save_msgs};
use crate::config::Config;
use crate::constants;
use crate::contact::ContactId;
use crate::message::{MessengerMessage, Viewtype};
@@ -298,14 +300,14 @@ mod tests {
async fn test_htmlparse_plain_unspecified() {
let t = TestContext::new().await;
let raw = include_bytes!("../test-data/message/text_plain_unspecified.eml");
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).await.unwrap();
assert_eq!(
parser.html,
r#"<!DOCTYPE html>
<html><head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="color-scheme" content="light dark" />
</head><body dir="auto" style="unicode-bidi: plaintext">
</head><body>
This message does not have Content-Type nor Subject.<br/>
</body></html>
"#
@@ -316,14 +318,14 @@ This message does not have Content-Type nor Subject.<br/>
async fn test_htmlparse_plain_iso88591() {
let t = TestContext::new().await;
let raw = include_bytes!("../test-data/message/text_plain_iso88591.eml");
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).await.unwrap();
assert_eq!(
parser.html,
r#"<!DOCTYPE html>
<html><head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="color-scheme" content="light dark" />
</head><body dir="auto" style="unicode-bidi: plaintext">
</head><body>
message with a non-UTF-8 encoding: äöüßÄÖÜ<br/>
</body></html>
"#
@@ -334,7 +336,7 @@ message with a non-UTF-8 encoding: äöüßÄÖÜ<br/>
async fn test_htmlparse_plain_flowed() {
let t = TestContext::new().await;
let raw = include_bytes!("../test-data/message/text_plain_flowed.eml");
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).await.unwrap();
assert!(parser.plain.unwrap().flowed);
assert_eq!(
parser.html,
@@ -342,7 +344,7 @@ message with a non-UTF-8 encoding: äöüßÄÖÜ<br/>
<html><head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="color-scheme" content="light dark" />
</head><body dir="auto" style="unicode-bidi: plaintext">
</head><body>
This line ends with a space and will be merged with the next one due to format=flowed.<br/>
<br/>
This line does not end with a space<br/>
@@ -356,14 +358,14 @@ and will be wrapped as usual.<br/>
async fn test_htmlparse_alt_plain() {
let t = TestContext::new().await;
let raw = include_bytes!("../test-data/message/text_alt_plain.eml");
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).await.unwrap();
assert_eq!(
parser.html,
r#"<!DOCTYPE html>
<html><head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="color-scheme" content="light dark" />
</head><body dir="auto" style="unicode-bidi: plaintext">
</head><body>
mime-modified should not be set set as there is no html and no special stuff;<br/>
although not being a delta-message.<br/>
test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x27; :)<br/>
@@ -376,7 +378,7 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
async fn test_htmlparse_html() {
let t = TestContext::new().await;
let raw = include_bytes!("../test-data/message/text_html.eml");
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).await.unwrap();
// on windows, `\r\n` linends are returned from mimeparser,
// however, rust multiline-strings use just `\n`;
@@ -394,7 +396,7 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
async fn test_htmlparse_alt_html() {
let t = TestContext::new().await;
let raw = include_bytes!("../test-data/message/text_alt_html.eml");
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).await.unwrap();
assert_eq!(
parser.html.replace('\r', ""), // see comment in test_htmlparse_html()
r##"<html>
@@ -408,7 +410,7 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
async fn test_htmlparse_alt_plain_html() {
let t = TestContext::new().await;
let raw = include_bytes!("../test-data/message/text_alt_plain_html.eml");
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).await.unwrap();
assert_eq!(
parser.html.replace('\r', ""), // see comment in test_htmlparse_html()
r##"<html>
@@ -432,7 +434,7 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
assert!(test.find("data:").is_none());
// parsing converts cid: to data:
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).unwrap();
let (parser, _) = HtmlMsgParser::from_bytes(&t.ctx, raw).await.unwrap();
assert!(parser.html.contains("<html>"));
assert!(!parser.html.contains("Content-Id:"));
assert!(parser.html.contains("data:image/jpeg;base64,/9j/4AAQ"));
@@ -451,7 +453,6 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
// alice receives a non-delta html-message
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.allow_unencrypted().await?;
let chat = alice
.create_chat_with_contact("", "sender@testrun.org")
.await;
@@ -485,7 +486,6 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
// bob: check that bob also got the html-part of the forwarded message
let bob = &tcm.bob().await;
bob.allow_unencrypted().await?;
let chat_bob = bob.create_chat_with_contact("", "alice@example.org").await;
async fn check_receiver(ctx: &TestContext, chat: &Chat, sender: &TestContext) {
let msg = ctx.recv_msg(&sender.pop_sent_msg().await).await;
@@ -523,30 +523,31 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_html_save_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.allow_unencrypted().await?;
// Alice receives a non-delta html-message
let alice = TestContext::new_alice().await;
let chat = alice
.create_chat_with_contact("", "sender@testrun.org")
.await;
let raw = include_bytes!("../test-data/message/text_alt_plain_html.eml");
receive_imf(alice, raw, false).await?;
receive_imf(&alice, raw, false).await?;
let msg = alice.get_last_msg_in(chat.get_id()).await;
// Alice saves the message
let self_chat = alice.get_self_chat().await;
save_msgs(alice, &[msg.id]).await?;
save_msgs(&alice, &[msg.id]).await?;
let saved_msg = alice.get_last_msg_in(self_chat.get_id()).await;
assert_ne!(saved_msg.id, msg.id);
assert_eq!(saved_msg.get_original_msg_id(alice).await?.unwrap(), msg.id);
assert_eq!(
saved_msg.get_original_msg_id(&alice).await?.unwrap(),
msg.id
);
assert!(!saved_msg.is_forwarded()); // UI should not flag "saved messages" as "forwarded"
assert_ne!(saved_msg.get_from_id(), ContactId::SELF);
assert_eq!(saved_msg.get_from_id(), msg.get_from_id());
assert_eq!(saved_msg.is_dc_message, MessengerMessage::No);
assert!(saved_msg.get_text().contains("this is plain"));
assert!(saved_msg.has_html());
let html = saved_msg.get_id().get_html(alice).await?.unwrap();
let html = saved_msg.get_id().get_html(&alice).await?.unwrap();
assert!(html.contains("this is <b>html</b>"));
Ok(())
@@ -556,8 +557,13 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
async fn test_html_forwarding_encrypted() {
let mut tcm = TestContextManager::new();
// Alice receives a non-delta html-message
// (`ShowEmails=AcceptedContacts` lets Alice actually receive non-delta messages for known
// contacts, the contact is marked as known by creating a chat using `chat_with_contact()`)
let alice = &tcm.alice().await;
alice.allow_unencrypted().await.unwrap();
alice
.set_config(Config::ShowEmails, Some("1"))
.await
.unwrap();
let chat = alice
.create_chat_with_contact("", "sender@testrun.org")
.await;
@@ -575,6 +581,10 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
// receive the message on another device
let alice = &tcm.alice().await;
alice
.set_config(Config::ShowEmails, Some("0"))
.await
.unwrap();
let msg = alice.recv_msg(&msg).await;
assert_eq!(msg.chat_id, alice.get_self_chat().await.id);
assert_eq!(msg.get_from_id(), ContactId::SELF);
@@ -621,20 +631,18 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cp1252_html() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.allow_unencrypted().await?;
let t = TestContext::new_alice().await;
receive_imf(
alice,
&t,
include_bytes!("../test-data/message/cp1252-html.eml"),
false,
)
.await?;
let msg = alice.get_last_msg().await;
let msg = t.get_last_msg().await;
assert_eq!(msg.viewtype, Viewtype::Text);
assert!(msg.text.contains("foo bar ä ö ü ß"));
assert!(msg.has_html());
let html = msg.get_id().get_html(alice).await?.unwrap();
let html = msg.get_id().get_html(&t).await?.unwrap();
println!("{html}");
assert!(html.contains("foo bar ä ö ü ß"));
Ok(())

View File

@@ -21,17 +21,19 @@ use futures_lite::FutureExt;
use ratelimit::Ratelimit;
use url::Url;
use crate::calls::{
UnresolvedIceServer, create_fallback_ice_servers, create_ice_servers_from_metadata,
};
use crate::chat::{self, ChatId, ChatIdBlocked, add_device_msg};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::{Blocked, DC_VERSION_STR};
use crate::constants::{self, Blocked, DC_VERSION_STR};
use crate::contact::ContactId;
use crate::context::Context;
use crate::ensure_and_debug_assert;
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
use crate::log::{LogExt, warn};
use crate::message::{self, Message, MessageState, MsgId};
use crate::message::{self, Message, MessageState, MessengerMessage, MsgId};
use crate::mimeparser;
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionStream;
@@ -46,10 +48,6 @@ use crate::tools::{self, create_id, duration_to_str, time};
use crate::transport::{
ConfiguredLoginParam, ConfiguredServerLoginParam, prioritize_server_login_params,
};
use crate::{
calls::{UnresolvedIceServer, create_fallback_ice_servers, create_ice_servers_from_metadata},
ephemeral::delete_expired_imap_messages,
};
pub(crate) mod capabilities;
mod client;
@@ -93,9 +91,6 @@ pub(crate) struct Imap {
oauth2: bool,
/// Watched folder.
pub(crate) folder: String,
authentication_failed_once: bool,
pub(crate) connectivity: ConnectivityStore,
@@ -167,6 +162,7 @@ pub enum FolderMeaning {
/// Spam folder.
Spam,
Inbox,
Mvbox,
Trash,
/// Virtual folders.
@@ -178,6 +174,19 @@ pub enum FolderMeaning {
Virtual,
}
impl FolderMeaning {
pub fn to_config(self) -> Option<Config> {
match self {
FolderMeaning::Unknown => None,
FolderMeaning::Spam => None,
FolderMeaning::Inbox => Some(Config::ConfiguredInboxFolder),
FolderMeaning::Mvbox => Some(Config::ConfiguredMvboxFolder),
FolderMeaning::Trash => None,
FolderMeaning::Virtual => None,
}
}
}
struct UidGrouper<T: Iterator<Item = (i64, u32, String)>> {
inner: Peekable<T>,
}
@@ -254,11 +263,6 @@ impl Imap {
let addr = &param.addr;
let strict_tls = param.strict_tls(proxy_config.is_some());
let oauth2 = param.oauth2;
let folder = param
.imap_folder
.clone()
.unwrap_or_else(|| "INBOX".to_string());
ensure_and_debug_assert!(!folder.is_empty(), "Watched folder name cannot be empty");
let (resync_request_sender, resync_request_receiver) = async_channel::bounded(1);
Ok(Imap {
transport_id,
@@ -269,7 +273,6 @@ impl Imap {
proxy_config,
strict_tls,
oauth2,
folder,
authentication_failed_once: false,
connectivity: Default::default(),
conn_last_try: UNIX_EPOCH,
@@ -487,14 +490,22 @@ impl Imap {
/// that folders are created and IMAP capabilities are determined.
pub(crate) async fn prepare(&mut self, context: &Context) -> Result<Session> {
let configuring = false;
let session = match self.connect(context, configuring).await {
let mut session = match self.connect(context, configuring).await {
Ok(session) => session,
Err(err) => {
self.connectivity.set_err(context, format!("{err:#}"));
self.connectivity.set_err(context, &err);
return Err(err);
}
};
let folders_configured = context
.sql
.get_raw_config_int(constants::DC_FOLDERS_CONFIGURED_KEY)
.await?;
if folders_configured.unwrap_or_default() < constants::DC_FOLDERS_CONFIGURED_VERSION {
self.configure_folders(context, &mut session).await?;
}
Ok(session)
}
@@ -507,15 +518,15 @@ impl Imap {
context: &Context,
session: &mut Session,
watch_folder: &str,
folder_meaning: FolderMeaning,
) -> Result<()> {
ensure_and_debug_assert!(!watch_folder.is_empty(), "Watched folder cannot be empty");
if !context.sql.is_open().await {
// probably shutdown
bail!("IMAP operation attempted while it is torn down");
}
let msgs_fetched = self
.fetch_new_messages(context, session, watch_folder)
.fetch_new_messages(context, session, watch_folder, folder_meaning)
.await
.context("fetch_new_messages")?;
if msgs_fetched && context.get_config_delete_device_after().await?.is_some() {
@@ -526,12 +537,6 @@ impl Imap {
context.scheduler.interrupt_ephemeral_task().await;
}
// Mark expired messages for deletion. Note that `delete_expired_imap_messages` is
// not well optimized and should not be called before fetching.
delete_expired_imap_messages(context, session.transport_id(), session.is_chatmail())
.await
.context("delete_expired_imap_messages")?;
session
.move_delete_messages(context, watch_folder)
.await
@@ -549,9 +554,19 @@ impl Imap {
context: &Context,
session: &mut Session,
folder: &str,
folder_meaning: FolderMeaning,
) -> Result<bool> {
let transport_id = session.transport_id();
if should_ignore_folder(context, folder, folder_meaning).await? {
info!(
context,
"Transport {transport_id}: Not fetching from {folder:?}."
);
session.new_mail = false;
return Ok(false);
}
let folder_exists = session
.select_with_uidvalidity(context, folder)
.await
@@ -574,8 +589,9 @@ impl Imap {
let mut read_cnt = 0;
loop {
let (n, fetch_more) =
Box::pin(self.fetch_new_msg_batch(context, session, folder)).await?;
let (n, fetch_more) = self
.fetch_new_msg_batch(context, session, folder, folder_meaning)
.await?;
read_cnt += n;
if !fetch_more {
return Ok(read_cnt > 0);
@@ -590,6 +606,7 @@ impl Imap {
context: &Context,
session: &mut Session,
folder: &str,
folder_meaning: FolderMeaning,
) -> Result<(usize, bool)> {
let transport_id = self.transport_id;
let uid_validity = get_uidvalidity(context, transport_id, folder).await?;
@@ -659,7 +676,13 @@ impl Imap {
info!(context, "Deleting locally deleted message {message_id}.");
}
let target = if delete { "" } else { folder };
let _target;
let target = if delete {
""
} else {
_target = target_folder(context, folder, folder_meaning, &headers).await?;
&_target
};
context
.sql
@@ -687,9 +710,18 @@ impl Imap {
// message, move it to the movebox and then download the second message before
// downloading the first one, if downloading from inbox before moving is allowed.
if folder == target
&& prefetch_should_download(context, &headers, &message_id, fetch_response.flags())
.await
.context("prefetch_should_download")?
// Never download messages directly from the spam folder.
// If the sender is known, the message will be moved to the Inbox or Mvbox
// and then we download the message from there.
// Also see `spam_target_folder_cfg()`.
&& folder_meaning != FolderMeaning::Spam
&& prefetch_should_download(
context,
&headers,
&message_id,
fetch_response.flags(),
)
.await.context("prefetch_should_download")?
{
if headers
.get_header_value(HeaderDef::ChatIsPostMessage)
@@ -698,19 +730,10 @@ impl Imap {
info!(context, "{message_id:?} is a post-message.");
available_post_msgs.push(message_id.clone());
let is_bot = context.get_config_bool(Config::Bot).await?;
if is_bot && download_limit.is_none_or(|download_limit| size <= download_limit)
{
uids_fetch.push(uid);
uid_message_ids.insert(uid, message_id);
} else {
if download_limit.is_none_or(|download_limit| size <= download_limit) {
// Download later after all the small messages are downloaded,
// so that large messages don't delay receiving small messages
download_later.push(message_id.clone());
}
largest_uid_skipped = Some(uid);
if download_limit.is_none_or(|download_limit| size <= download_limit) {
download_later.push(message_id.clone());
}
largest_uid_skipped = Some(uid);
} else {
info!(context, "{message_id:?} is not a post-message.");
if download_limit.is_none_or(|download_limit| size <= download_limit) {
@@ -1052,12 +1075,15 @@ impl Session {
if target.is_empty() {
self.delete_message_batch(context, &uid_set, rowid_set)
.await
.with_context(|| format!("cannot delete batch of messages {uid_set:?}"))?;
.with_context(|| format!("cannot delete batch of messages {:?}", &uid_set))?;
} else {
self.move_message_batch(context, &uid_set, rowid_set, &target)
.await
.with_context(|| {
format!("cannot move batch of messages {uid_set:?} to folder {target:?}",)
format!(
"cannot move batch of messages {:?} to folder {:?}",
&uid_set, target
)
})?;
}
}
@@ -1291,10 +1317,9 @@ impl Session {
for (request_uids, set) in build_sequence_sets(&request_uids)? {
info!(context, "Starting UID FETCH of message set \"{}\".", set);
let mut fetch_responses = self
.uid_fetch(&set, BODY_FULL)
.await
.with_context(|| format!("fetching messages {set} from folder {folder:?}"))?;
let mut fetch_responses = self.uid_fetch(&set, BODY_FULL).await.with_context(|| {
format!("fetching messages {} from folder \"{}\"", &set, folder)
})?;
// Map from UIDs to unprocessed FETCH results. We put unprocessed FETCH results here
// when we want to process other messages first.
@@ -1386,8 +1411,6 @@ impl Session {
"Passing message UID {} to receive_imf().", request_uid
);
let res = receive_imf_inner(context, rfc724_mid, body, is_seen).await;
// If there was an error receiving the message, show a device message:
let received_msg = match res {
Err(err) => {
warn!(context, "receive_imf error: {err:#}.");
@@ -1639,8 +1662,13 @@ impl Session {
// Store new encrypted device token on the server
// even if it is the same as the old one.
if let Some(encrypted_device_token) = new_encrypted_device_token {
let folder = context
.get_config(Config::ConfiguredInboxFolder)
.await?
.context("INBOX is not configured")?;
self.run_command_and_check_ok(&format_setmetadata(
"INBOX",
&folder,
&encrypted_device_token,
))
.await
@@ -1685,6 +1713,117 @@ impl Session {
}
Ok(())
}
/// Attempts to configure mvbox.
///
/// Tries to find any folder examining `folders` in the order they go.
/// This method does not use LIST command to ensure that
/// configuration works even if mailbox lookup is forbidden via Access Control List (see
/// <https://datatracker.ietf.org/doc/html/rfc4314>).
///
/// Returns first found folder name.
async fn configure_mvbox<'a>(
&mut self,
context: &Context,
folders: &[&'a str],
) -> Result<Option<&'a str>> {
// Close currently selected folder if needed.
// We are going to select folders using low-level EXAMINE operations below.
self.maybe_close_folder(context).await?;
for folder in folders {
info!(context, "Looking for MVBOX-folder \"{}\"...", &folder);
let res = self.examine(&folder).await;
if res.is_ok() {
info!(
context,
"MVBOX-folder {:?} successfully selected, using it.", &folder
);
self.close().await?;
// Before moving emails to the mvbox we need to remember its UIDVALIDITY, otherwise
// emails moved before that wouldn't be fetched but considered "old" instead.
let folder_exists = self.select_with_uidvalidity(context, folder).await?;
ensure!(folder_exists, "No MVBOX folder {:?}??", &folder);
return Ok(Some(folder));
}
}
Ok(None)
}
}
impl Imap {
pub(crate) async fn configure_folders(
&mut self,
context: &Context,
session: &mut Session,
) -> Result<()> {
let mut folders = session
.list(Some(""), Some("*"))
.await
.context("list_folders failed")?;
let mut delimiter = ".".to_string();
let mut delimiter_is_default = true;
let mut folder_configs = BTreeMap::new();
while let Some(folder) = folders.try_next().await? {
info!(context, "Scanning folder: {:?}", folder);
// Update the delimiter iff there is a different one, but only once.
if let Some(d) = folder.delimiter()
&& delimiter_is_default
&& !d.is_empty()
&& delimiter != d
{
delimiter = d.to_string();
delimiter_is_default = false;
}
let folder_meaning = get_folder_meaning_by_attrs(folder.attributes());
let folder_name_meaning = get_folder_meaning_by_name(folder.name());
if let Some(config) = folder_meaning.to_config() {
// Always takes precedence
folder_configs.insert(config, folder.name().to_string());
} else if let Some(config) = folder_name_meaning.to_config() {
// only set if none has been already set
folder_configs
.entry(config)
.or_insert_with(|| folder.name().to_string());
}
}
drop(folders);
info!(context, "Using \"{}\" as folder-delimiter.", delimiter);
let fallback_folder = format!("INBOX{delimiter}DeltaChat");
let mvbox_folder = session
.configure_mvbox(context, &["DeltaChat", &fallback_folder])
.await
.context("failed to configure mvbox")?;
context
.set_config_internal(Config::ConfiguredInboxFolder, Some("INBOX"))
.await?;
if let Some(mvbox_folder) = mvbox_folder {
info!(context, "Setting MVBOX FOLDER TO {}", &mvbox_folder);
context
.set_config_internal(Config::ConfiguredMvboxFolder, Some(mvbox_folder))
.await?;
}
for (config, name) in folder_configs {
context.set_config_internal(config, Some(&name)).await?;
}
context
.sql
.set_raw_config_int(
constants::DC_FOLDERS_CONFIGURED_KEY,
constants::DC_FOLDERS_CONFIGURED_VERSION,
)
.await?;
info!(context, "FINISHED configuring IMAP-folders.");
Ok(())
}
}
impl Session {
@@ -1818,7 +1957,15 @@ async fn spam_target_folder_cfg(
return Ok(None);
}
Ok(Some(Config::ConfiguredInboxFolder))
if needs_move_to_mvbox(context, headers).await?
// If OnlyFetchMvbox is set, we don't want to move the message to
// the inbox where we wouldn't fetch it again:
|| context.get_config_bool(Config::OnlyFetchMvbox).await?
{
Ok(Some(Config::ConfiguredMvboxFolder))
} else {
Ok(Some(Config::ConfiguredInboxFolder))
}
}
/// Returns `ConfiguredInboxFolder` or `ConfiguredMvboxFolder` if
@@ -1829,12 +1976,16 @@ pub async fn target_folder_cfg(
folder_meaning: FolderMeaning,
headers: &[mailparse::MailHeader<'_>],
) -> Result<Option<Config>> {
if folder == "DeltaChat" {
if context.is_mvbox(folder).await? {
return Ok(None);
}
if folder_meaning == FolderMeaning::Spam {
spam_target_folder_cfg(context, headers).await
} else if folder_meaning == FolderMeaning::Inbox
&& needs_move_to_mvbox(context, headers).await?
{
Ok(Some(Config::ConfiguredMvboxFolder))
} else {
Ok(None)
}
@@ -1855,6 +2006,27 @@ pub async fn target_folder(
}
}
async fn needs_move_to_mvbox(
context: &Context,
headers: &[mailparse::MailHeader<'_>],
) -> Result<bool> {
let has_chat_version = headers.get_header_value(HeaderDef::ChatVersion).is_some();
if !context.get_config_bool(Config::MvboxMove).await? {
return Ok(false);
}
if has_chat_version {
Ok(true)
} else if let Some(parent) = get_prefetch_parent_message(context, headers).await? {
match parent.is_dc_message {
MessengerMessage::No => Ok(false),
MessengerMessage::Yes | MessengerMessage::Reply => Ok(true),
}
} else {
Ok(false)
}
}
/// Try to get the folder meaning by the name of the folder only used if the server does not support XLIST.
// TODO: lots languages missing - maybe there is a list somewhere on other MUAs?
// however, if we fail to find out the sent-folder,
@@ -2003,26 +2175,12 @@ pub(crate) async fn prefetch_should_download(
// prevent_rename=true as this might be a mailing list message and in this case it would be bad if we rename the contact.
// (prevent_rename is the last argument of from_field_to_contact_id())
// New SecureJoin is fully encrypted,
// but for compatibility we still download legacy `Secure-Join: vc-request` messages.
let is_legacy_securejoin = headers.get_header_value(HeaderDef::SecureJoin).is_some();
let is_encrypted = headers
.get_header_value(HeaderDef::ContentType)
.is_some_and(|content_type| {
mailparse::parse_content_type(&content_type).mimetype == "multipart/encrypted"
});
if flags.any(|f| f == Flag::Draft) {
info!(context, "Ignoring draft message");
return Ok(false);
}
let should_download = maybe_ndn
|| (!blocked_contact
&& (is_legacy_securejoin
|| is_encrypted
|| !context.get_config_bool(Config::ForceEncryption).await?));
let should_download = (!blocked_contact) || maybe_ndn;
Ok(should_download)
}
@@ -2199,6 +2357,21 @@ async fn get_modseq(context: &Context, transport_id: u32, folder: &str) -> Resul
.unwrap_or(0))
}
/// Whether to ignore fetching messages from a folder.
///
/// This caters for the [`Config::OnlyFetchMvbox`] setting which means mails from folders
/// not explicitly watched should not be fetched.
async fn should_ignore_folder(
context: &Context,
folder: &str,
folder_meaning: FolderMeaning,
) -> Result<bool> {
if !context.get_config_bool(Config::OnlyFetchMvbox).await? {
return Ok(false);
}
Ok(!(context.is_mvbox(folder).await? || folder_meaning == FolderMeaning::Spam))
}
/// Builds a list of sequence/uid sets. The returned sets have each no more than around 1000
/// characters because according to <https://tools.ietf.org/html/rfc2683#section-3.2.1.5>
/// command lines should not be much more than 1000 chars (servers should allow at least 8000 chars)
@@ -2258,5 +2431,23 @@ impl std::fmt::Display for UidRange {
}
}
pub(crate) async fn get_watched_folder_configs(context: &Context) -> Result<Vec<Config>> {
let mut res = vec![Config::ConfiguredInboxFolder];
if context.should_watch_mvbox().await? {
res.push(Config::ConfiguredMvboxFolder);
}
Ok(res)
}
pub(crate) async fn get_watched_folders(context: &Context) -> Result<Vec<String>> {
let mut res = Vec::new();
for folder_config in get_watched_folder_configs(context).await? {
if let Some(folder) = context.get_config(folder_config).await? {
res.push(folder);
}
}
Ok(res)
}
#[cfg(test)]
mod imap_tests;

View File

@@ -220,8 +220,6 @@ impl Client {
alpn(addr.port()),
logging_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await?;
let buffered_stream = BufWriter::new(tls_stream);
@@ -284,8 +282,6 @@ impl Client {
"",
tcp_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await
.context("STARTTLS upgrade failed")?;
@@ -314,8 +310,6 @@ impl Client {
alpn(port),
proxy_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await?;
let buffered_stream = BufWriter::new(tls_stream);
@@ -379,8 +373,6 @@ impl Client {
"",
proxy_stream,
&context.tls_session_store,
&context.spki_hash_store,
&context.sql,
)
.await
.context("STARTTLS upgrade failed")?;

View File

@@ -120,7 +120,11 @@ impl Session {
impl Imap {
/// Idle using polling.
pub(crate) async fn fake_idle(&mut self, context: &Context, watch_folder: &str) -> Result<()> {
pub(crate) async fn fake_idle(
&mut self,
context: &Context,
watch_folder: String,
) -> Result<()> {
let fake_idle_start_time = tools::Time::now();
info!(context, "IMAP-fake-IDLEing folder={:?}", watch_folder);

View File

@@ -100,16 +100,23 @@ fn test_build_sequence_sets() {
async fn check_target_folder_combination(
folder: &str,
mvbox_move: bool,
chat_msg: bool,
expected_destination: &str,
accepted_chat: bool,
outgoing: bool,
) -> Result<()> {
println!(
"Testing: For folder {folder}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}"
"Testing: For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}"
);
let t = TestContext::new_alice().await;
t.ctx
.set_config(Config::ConfiguredMvboxFolder, Some("DeltaChat"))
.await?;
t.ctx
.set_config(Config::MvboxMove, Some(if mvbox_move { "1" } else { "0" }))
.await?;
if accepted_chat {
let contact_id = Contact::create(&t.ctx, "", "bob@example.net").await?;
@@ -154,42 +161,64 @@ async fn check_target_folder_combination(
assert_eq!(
expected,
actual.as_deref(),
"For folder {folder}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}: expected {expected:?}, got {actual:?}"
"For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}: expected {expected:?}, got {actual:?}"
);
Ok(())
}
// chat_msg means that the message was sent by Delta Chat
// The tuples are (folder, chat_msg, expected_destination)
const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, &str)] = &[
("INBOX", false, "INBOX"),
("INBOX", true, "INBOX"),
("Spam", false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", true, "INBOX"),
// The tuples are (folder, mvbox_move, chat_msg, expected_destination)
const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, bool, &str)] = &[
("INBOX", false, false, "INBOX"),
("INBOX", false, true, "INBOX"),
("INBOX", true, false, "INBOX"),
("INBOX", true, true, "DeltaChat"),
("Spam", false, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", false, true, "INBOX"),
("Spam", true, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", true, true, "DeltaChat"),
];
// These are the same as above, but non-chat messages in Spam stay in Spam
const COMBINATIONS_REQUEST: &[(&str, bool, &str)] = &[
("INBOX", false, "INBOX"),
("INBOX", true, "INBOX"),
("Spam", false, "Spam"),
("Spam", true, "INBOX"),
const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[
("INBOX", false, false, "INBOX"),
("INBOX", false, true, "INBOX"),
("INBOX", true, false, "INBOX"),
("INBOX", true, true, "DeltaChat"),
("Spam", false, false, "Spam"),
("Spam", false, true, "INBOX"),
("Spam", true, false, "Spam"),
("Spam", true, true, "DeltaChat"),
];
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_incoming_accepted() -> Result<()> {
for (folder, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(folder, *chat_msg, expected_destination, true, false)
.await?;
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
true,
false,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_incoming_request() -> Result<()> {
for (folder, chat_msg, expected_destination) in COMBINATIONS_REQUEST {
check_target_folder_combination(folder, *chat_msg, expected_destination, false, false)
.await?;
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_REQUEST {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
false,
false,
)
.await?;
}
Ok(())
}
@@ -197,9 +226,16 @@ async fn test_target_folder_incoming_request() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_outgoing() -> Result<()> {
// Test outgoing emails
for (folder, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(folder, *chat_msg, expected_destination, true, true)
.await?;
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
true,
true,
)
.await?;
}
Ok(())
}

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