Compare commits

..

1 Commits

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

Measurements

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

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

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

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

That is 67.22% of the original average duration.

Related:
- https://github.com/deltachat/deltachat-desktop/issues/5282
2026-02-01 15:19:07 +04:00
182 changed files with 5988 additions and 8426 deletions

View File

@@ -20,7 +20,7 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.94.0
RUST_VERSION: 1.93.0
# Minimum Supported Rust Version
MSRV: 1.88.0
@@ -59,9 +59,9 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979
- uses: EmbarkStudios/cargo-deny-action@76cd80eb775d7bbbd2d80292136d74d39e1b4918
with:
arguments: --workspace --all-features --locked
arguments: --all-features --workspace
command: check
command-arguments: "-Dwarnings"
@@ -174,7 +174,7 @@ jobs:
run: cargo build -p deltachat_ffi
- name: Upload C library
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.os }}-libdeltachat.a
path: target/debug/libdeltachat.a
@@ -200,7 +200,7 @@ jobs:
run: cargo build -p deltachat-rpc-server
- name: Upload deltachat-rpc-server
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.os }}-deltachat-rpc-server
path: ${{ matrix.os == 'windows-latest' && 'target/debug/deltachat-rpc-server.exe' || 'target/debug/deltachat-rpc-server' }}

View File

@@ -34,13 +34,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
- name: Upload binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}-linux
path: result/bin/deltachat-rpc-server
@@ -58,13 +58,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
- name: Upload wheel
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
path: result/*.whl
@@ -82,13 +82,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
- name: Upload binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}
path: result/bin/deltachat-rpc-server.exe
@@ -106,13 +106,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel
- name: Upload wheel
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}-wheel
path: result/*.whl
@@ -139,7 +139,7 @@ jobs:
run: cargo build --release --package deltachat-rpc-server --target ${{ matrix.arch }}-apple-darwin --features vendored
- name: Upload binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}-macos
path: target/${{ matrix.arch }}-apple-darwin/release/deltachat-rpc-server
@@ -157,13 +157,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
- name: Upload binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}-android
path: result/bin/deltachat-rpc-server
@@ -181,13 +181,13 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel
- name: Upload wheel
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}-android-wheel
path: result/*.whl
@@ -208,7 +208,7 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v7
@@ -496,7 +496,7 @@ jobs:
ls -lah
- name: Upload to artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-npm-package
path: deltachat-rpc-server/npm-package/*.tgz

View File

@@ -10,7 +10,7 @@ permissions:
jobs:
dependabot:
runs-on: ubuntu-latest
if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == github.event.pull_request.head.repo.full_name
if: ${{ github.actor == 'dependabot[bot]' }}
steps:
- name: Dependabot metadata
id: metadata

View File

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

View File

@@ -23,7 +23,7 @@ jobs:
working-directory: deltachat-rpc-client
run: python3 -m build
- name: Store the distribution packages
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: python-package-distributions
path: deltachat-rpc-client/dist/

View File

@@ -18,11 +18,11 @@ jobs:
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: repl.exe
path: "result/bin/deltachat-repl.exe"

View File

@@ -1,18 +1,16 @@
name: Build & deploy documentation on rs.delta.chat, c.delta.chat, py.delta.chat and cffi.delta.chat
name: Build & deploy documentation on rs.delta.chat, c.delta.chat, and py.delta.chat
on:
push:
branches:
- main
- build_jsonrpc_docs_ci
permissions: {}
jobs:
build-rs:
runs-on: ubuntu-latest
environment:
name: rs.delta.chat
url: https://rs.delta.chat/
steps:
- uses: actions/checkout@v6
@@ -25,15 +23,12 @@ jobs:
- name: Upload to rs.delta.chat
run: |
mkdir -p "$HOME/.ssh"
echo "${{ secrets.RS_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
echo "${{ secrets.KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/target/doc "${{ secrets.RS_DOCS_SSH_USER }}@rs.delta.chat:/var/www/html/rs.delta.chat/"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/target/doc "${{ secrets.USERNAME }}@rs.delta.chat:/var/www/html/rs/"
build-python:
runs-on: ubuntu-latest
environment:
name: py.delta.chat
url: https://py.delta.chat/
steps:
- uses: actions/checkout@v6
@@ -41,21 +36,18 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build Python documentation
run: nix build .#python-docs
- name: Upload to py.delta.chat
run: |
mkdir -p "$HOME/.ssh"
echo "${{ secrets.PY_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
echo "${{ secrets.CODESPEAK_KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key"
rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/result/html/ "${{ secrets.PY_DOCS_SSH_USER }}@py.delta.chat:/var/www/html/py.delta.chat"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/result/html/ "delta@py.delta.chat:/home/delta/build/master"
build-c:
runs-on: ubuntu-latest
environment:
name: c.delta.chat
url: https://c.delta.chat/
steps:
- uses: actions/checkout@v6
@@ -63,22 +55,18 @@ jobs:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build C documentation
run: nix build .#docs
- name: Upload to c.delta.chat
run: |
mkdir -p "$HOME/.ssh"
echo "${{ secrets.C_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
echo "${{ secrets.CODESPEAK_KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key"
rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/result/html/ "${{ secrets.C_DOCS_SSH_USER }}@c.delta.chat:/var/www/html/c.delta.chat"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/result/html/ "delta@c.delta.chat:/home/delta/build-c/master"
build-ts:
runs-on: ubuntu-latest
environment:
name: js.jsonrpc.delta.chat
url: https://js.jsonrpc.delta.chat/
defaults:
run:
working-directory: ./deltachat-jsonrpc/typescript
@@ -102,27 +90,6 @@ jobs:
- name: Upload to js.jsonrpc.delta.chat
run: |
mkdir -p "$HOME/.ssh"
echo "${{ secrets.JS_JSONRPC_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
echo "${{ secrets.KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key"
rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/deltachat-jsonrpc/typescript/docs/ "${{ secrets.JS_JSONRPC_DOCS_SSH_USER }}@js.jsonrpc.delta.chat:/var/www/html/js.jsonrpc.delta.chat/"
build-cffi:
runs-on: ubuntu-latest
environment:
name: cffi.delta.chat
url: https://cffi.delta.chat/
steps:
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Build the documentation with cargo
run: |
cargo doc --package deltachat_ffi --no-deps
- name: Upload to cffi.delta.chat
run: |
mkdir -p "$HOME/.ssh"
echo "${{ secrets.CFFI_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key"
rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/target/doc/ "${{ secrets.CFFI_DOCS_SSH_USER }}@delta.chat:/var/www/html/cffi.delta.chat/"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/deltachat-jsonrpc/typescript/docs/ "${{ secrets.USERNAME }}@js.jsonrpc.delta.chat:/var/www/html/js-jsonrpc/"

31
.github/workflows/upload-ffi-docs.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
# GitHub Actions workflow
# to build `deltachat_ffi` crate documentation
# and upload it to <https://cffi.delta.chat/>
name: Build & Deploy Documentation on cffi.delta.chat
on:
push:
branches:
- main
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Build the documentation with cargo
run: |
cargo doc --package deltachat_ffi --no-deps
- name: Upload to cffi.delta.chat
run: |
mkdir -p "$HOME/.ssh"
echo "${{ secrets.KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/target/doc/ "${{ secrets.USERNAME }}@delta.chat:/var/www/html/cffi/"

View File

@@ -6,21 +6,26 @@ on:
pull_request:
branches: ["**"]
permissions: {}
jobs:
zizmor:
name: Run zizmor
name: zizmor latest via PyPI
runs-on: ubuntu-latest
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
contents: read
actions: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867
- name: Run zizmor
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
run: uvx zizmor --format sarif . > results.sarif
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: results.sarif
category: zizmor

6
.github/zizmor.yml vendored
View File

@@ -1,6 +0,0 @@
rules:
unpinned-uses:
config:
policies:
actions/*: ref-pin
dependabot/*: ref-pin

View File

@@ -1,417 +1,5 @@
# Changelog
## [2.48.0] - 2026-03-30
### Fixes
- Fix reordering problems in multi-relay setups by not sorting received messages below the last seen one.
- Always sort "Messages are end-to-end encrypted" notice to the beginning.
- Make Message-ID of pre-messages stable across resends ([#8007](https://github.com/chatmail/core/pull/8007)).
- Delete `imap_markseen` entries not corresponding to any `imap` rows.
- Cleanup `imap` and `imap_sync` records without transport in housekeeping.
- When receiving MDN, mark all preceding messages as noticed, even having same timestamp ([#7928](https://github.com/chatmail/core/pull/7928)).
- Remove migration 108 preventing upgrades from core 1.86.0 to the latest version.
### Features / Changes
- Improve IMAP loop logs.
- Add decryption error to the device message about outgoing message decryption failure.
- Log received message sort timestamp.
### Performance
- Move sorting outside of SQL query in `store_seen_flags_on_imap`.
### API-Changes
- Add JSON-RPC API `markfresh_chat()`.
- ffi: Correctly declare `dc_event_channel_new()` as having no params ([#7831](https://github.com/chatmail/core/pull/7831)).
### Refactor
- Remove `wal_checkpoint_mutex`, lock `write_mutex` before getting sql connection instead.
- Replace async `RwLock` with sync `RwLock` for stock strings.
- Cleanup remaining Autocrypt Setup Message processing in `mimeparser`.
- SecureJoin: do not check for self address in forwarding protection.
- Fix clippy warnings.
### CI
- Update {c,py}.delta.chat website deployments.
- Use environments for {rs,cffi,js.jsonrpc}.delta.chat deployments.
- Fix https://docs.zizmor.sh/audits/#bot-conditions.
### Documentation
- Add SQL performance tips to STYLE.md.
### Tests
- Remove `test_old_message_5`.
- Do not rely on loading newest chat in `load_imf_email()`.
- Use `load_imf_email()` more.
- The message is sorted correctly in the chat even if it arrives late.
### Miscellaneous Tasks
- cargo: update rustls-webpki to 0.103.10.
## [2.47.0] - 2026-03-24
### Fixes
- Don't fall into infinite loop if the folder is missing ([#8021](https://github.com/chatmail/core/pull/8021)).
- Delete `available_post_msgs` row if the message is already downloaded.
- Delete `available_post_msgs` row if there is no corresponding IMAP entry.
- Make newlines work in chat descriptions ([#8012](https://github.com/chatmail/core/pull/8012)).
### Features / Changes
- use SEIPDv2 if all recipients support it.
### Documentation
- Add shadowsocks spec to standards.md.
- Document Header Confidentiality Policy.
- `deltachat_rpc_client`: make sphinx documentation display method parameters.
- Remove `draft/aeap-mvp.md` which is superseded by key-contacts and multi-relay.
### Refactor
- Remove code to send messages without intended recipient fingerprint.
### Tests
- Make `add_or_lookup_contact_id_no_key` public.
### Miscellaneous Tasks
- cargo: bump sdp from 0.10.0 to 0.17.1.
- Add RUSTSEC-2026-0049 exception to deny.toml.
## [2.46.0] - 2026-03-19
### API-Changes
- [**breaking**] remove functions for sending and receiving Autocrypt Setup Message.
- Add `list_transports_ex()` and `set_transport_unpublished()` functions.
- Add API `dc_markfresh_chat` to mark messages as "fresh".
### Features / Changes
- add `IncomingCallAccepted.from_this_device`.
- decode `dcaccount://` URLs and error out on empty URLs early.
- enable anonymous OpenPGP key IDs.
- tls: do not verify TLS certificates for hostnames starting with `_`.
### Fixes
- Mark call message as seen when accepting/declining a call ([#7842](https://github.com/chatmail/core/pull/7842)).
- do not send MDNs for hidden messages.
- call sync_all() instead of sync_data() when writing accounts.toml.
- fsync() the rename() of accounts.toml.
- count recipients by Intended Recipient Fingerprints.
### Miscellaneous Tasks
- deps: bump zizmorcore/zizmor-action from 0.5.0 to 0.5.2.
- cargo: bump astral-tokio-tar from 0.5.6 to 0.6.0.
- deps: bump actions/upload-artifact from 6 to 7.
- cargo: bump blake3 from 1.8.2 to 1.8.3.
- add constant_time_eq 0.3.1 to deny.toml.
### Refactor
- use re-exported rustls::pki_types.
- import tokio_rustls::rustls.
- Move transport_tests to their own file.
### Tests
- Shift time even more in flaky test_sync_broadcast_and_send_message.
- test markfresh_chat()
## [2.45.0] - 2026-03-14
### API-Changes
- JSON-RPC: add `createQrSvg` ([#7949](https://github.com/chatmail/core/pull/7949)).
### Features / Changes
- Do not read own public key from the database.
- Securejoin v3, encrypt all securejoin messages ([#7754](https://github.com/chatmail/core/pull/7754)).
- Domain separation between securejoin auth tokens and broadcast channel secrets ([#7981](https://github.com/chatmail/core/pull/7981)).
- Merge OpenPGP certificates and distribute relays in them.
- Advertise SEIPDv2 feature for new keys.
- Don't depend on cleartext `Chat-Version`, `In-Reply-To`, and `References` headers for `prefetch_should_download` ([#7932](https://github.com/chatmail/core/pull/7932)).
- Don't send unencrypted `In-Reply-To` and `References` headers ([#7935](https://github.com/chatmail/core/pull/7935)).
- Don't send unencrypted `Auto-Submitted` header ([#7938](https://github.com/chatmail/core/pull/7938)).
- Remove QR code tokens sync compatibility code.
- Mutex to prevent fetching from multiple IMAP servers at the same time.
- Add support to gif stickers ([#7941](https://github.com/chatmail/core/pull/7941))
### Fixes
- Fix the deadlock by adding a mutex around `wal_checkpoint()`.
- Do not run more than one housekeeping at a time.
- ffi: don't steal Arc in `dc_jsonrpc_init` ([#7962](https://github.com/chatmail/core/pull/7962)).
- Handle the case that the user starts a securejoin, and then deletes the contact ([#7883](https://github.com/chatmail/core/pull/7883)).
- Do not trash pre-message if it is received twice.
- Set `is_chatmail` during initial configuration.
- vCard: Improve property value escaping ([#7931](https://github.com/chatmail/core/pull/7931)).
- Percent-decode the address in `dclogin://` URLs.
- Make broadcast owner and subscriber hidden contacts for each other ([#7856](https://github.com/chatmail/core/pull/7856)).
- Set proper placeholder texts for system messages ([#7953](https://github.com/chatmail/core/pull/7953)).
- Add "member added" messages to `OutBroadcast` when executing `SetPgpContacts` sync message ([#7952](https://github.com/chatmail/core/pull/7952)).
- Correct channel system messages ([#7959](https://github.com/chatmail/core/pull/7959)).
- Drop messages encrypted with the wrong symmetric secret ([#7963](https://github.com/chatmail/core/pull/7963)).
- Fix debug assert message incorrectly talking about past members in the current member branch.
- Update device chats at the end of configuration.
- `deltachat_rpc_client`: make `@futuremethod` decorator keep method metadata.
- Use the correct chat description stock string again ([#7939](https://github.com/chatmail/core/pull/7939)).
- Use correct string for encryption info.
### CI
- Update Rust to 1.94.0.
- Allow non-hash references for `actions/*` and `dependabot/*`.
- update zizmor workflow to use zizmorcore/zizmor-action.
### Documentation
- update `store_self_keypair()` documentation.
- Fix documentation for membership change stock strings ([#7944](https://github.com/chatmail/core/pull/7944)).
- use correct define for 'description changed' info message.
### Refactor
- Un-resultify `KeyPair::new()`.
- Remove `KeyPair` type.
- pgp: do not use legacy key ID except for IssuerKeyId subpacket.
- `use super::*` in qr::dclogin_scheme.
- Move WAL checkpointing into `sql::pool` submodule.
- Order self addresses by addition timestamp.
### Tests
- Remove arbitrary timeouts from `test_4_lowlevel.py`.
- Fix flaky `test_qr_securejoin_broadcast` ([#7937](https://github.com/chatmail/core/pull/7937)).
- Work around `test_sync_broadcast_and_send_message` flakiness.
### Miscellaneous Tasks
- bump version to 2.44.0-dev.
- cargo: bump futures from 0.3.31 to 0.3.32.
- cargo: bump quick-xml from 0.39.0 to 0.39.2.
- cargo: bump criterion from 0.8.1 to 0.8.2.
- cargo: bump tempfile from 3.24.0 to 3.25.0.
- cargo: bump async-imap from 0.11.1 to 0.11.2.
- cargo: bump regex from 1.12.2 to 1.12.3.
- cargo: bump hyper-util from 0.1.19 to 0.1.20.
- cargo: bump anyhow from 1.0.100 to 1.0.102.
- cargo: bump syn from 2.0.114 to 2.0.117.
- cargo: bump proptest from 1.9.0 to 1.10.0.
- cargo: bump strum from 0.27.2 to 0.28.0.
- cargo: bump strum_macros from 0.27.2 to 0.28.0.
- cargo: bump quinn-proto from 0.11.9 to 0.11.14.
## [2.44.0] - 2026-02-27
### Build system
- git-cliff: do not capitalize the first letter of commit message.
### Documentation
- RELEASE.md: add section about dealing with antivirus false positives.
### Features / Changes
- improve logging of connection failures.
- add backup versions to the importing error message.
- add context to message loading failures.
- Add 📱 to all webxdc summaries ([#7790](https://github.com/chatmail/core/pull/7790)).
- Send webxdc name instead of raw file name in pre-messages. Display it in summary ([#7790](https://github.com/chatmail/core/pull/7790)).
- rpc: add startup health-check and propagate server errors.
### Fixes
- imex: do not call `set_config` before running SQL migrations ([#7851](https://github.com/chatmail/core/pull/7851)).
- add missing group description strings to cffi.
- chat-description-changed text in old clients ([#7870](https://github.com/chatmail/core/pull/7870)).
- add cffi type for "Description changed" info message.
- If there was no chat description, and it's set to be an empty string, don't send out a "chat description changed" message ([#7879](https://github.com/chatmail/core/pull/7879)).
- Make clicking on broadcast member-added messages work always ([#7882](https://github.com/chatmail/core/pull/7882)).
- tolerate empty existing directory in Accounts::new() ([#7886](https://github.com/chatmail/core/pull/7886)).
- If importing a backup fails, delete the partially-imported profile ([#7885](https://github.com/chatmail/core/pull/7885)).
- Don't generate new timestamp for re-sent messages ([#7889](https://github.com/chatmail/core/pull/7889)).
### Miscellaneous Tasks
- cargo: update async-native-tls from 0.5.0 to 0.6.0.
- add dev-version bump instructions to RELEASE.md (bumping to 2.44.0-dev).
- deps: bump cachix/install-nix-action from 31.9.0 to 31.9.1.
### Performance
- batched event reception.
### Refactor
- enable clippy::arithmetic_side_effects lint.
- imex: check for overflow when adding blob size.
- http: saturating addition to calculate cache expiration timestamp.
- Move migrations to the end of the file ([#7895](https://github.com/chatmail/core/pull/7895)).
- do not chain Autocrypt key verification to parsing.
### Tests
- fail fast when CHATMAIL_DOMAIN is unset.
## [2.43.0] - 2026-02-17
### Features / Changes
- Group and broadcast channel descriptions ([#7829](https://github.com/chatmail/core/pull/7829)).
### Fixes
- Assign iroh gossip topic to pre-message when post-message is received.
### Miscellaneous Tasks
- Update fast-socks5 to version 1.0.
- cargo: Update keccak from 0.1.5 to 0.1.6.
- deps: Bump astral-sh/setup-uv from 7.1.6 to 7.3.0.
### Performance
- Use recv_direct() instead of recv() on the event channel.
### Refactor
- Enable `clippy::manual_is_variant_and`.
### Tests
- Fix flaky `test_transport_synchronization` ([#7850](https://github.com/chatmail/core/pull/7850)).
## [2.42.0] - 2026-02-10
### Fixes
- Set `mvbox_move` to '0' explicitly for existing chatmail profiles.
It's needed to prevent device message about deprecated `mvbox_move` option from appearing in chatmail profiles.
### Features / Changes
- Do not scan not watched folders.
### Miscellaneous Tasks
- Update rPGP from 0.18.0 to 0.19.0.
- cargo: Bump quick-xml from 0.38.4 to 0.39.0.
### Tests
- Remove test_dont_show_emails.
### Other
- Fix typo in CHANGELOG for marknoticed_all_chats.
## [2.41.0] - 2026-02-06
### Features / Changes
- Do not require `ShowEmails` to be set to `All` for adding second relay.
- Use different strings for audio and video calls.
### Fixes
- Don't set download state to Failure if message is available on another Session's transport ([#7684](https://github.com/chatmail/core/pull/7684)).
- Make use of call stock strings.
### Miscellaneous Tasks
- cargo: Bump `time` from 0.3.37 to 0.3.47.
## [2.40.0] - 2026-02-04
### Features / Changes
- Receive_imf: Log reasoning for chat assignment.
- Use more fitting encryption info message.
- Send Intended Recipient Fingerprint subpackets.
- Trash messages with intended recipient fingerprints, but w/o our one included.
- Do not collect email addresses from messages after configuration.
- Add device message about legacy `mvbox_move`.
- Never create IMAP folders.
- Make summary for pre-messages look like summary for fully downloaded messages ([#7775](https://github.com/chatmail/core/pull/7775)).
- Don't call `BlobObject::create_and_deduplicate()` when forwarding message to the same account.
- Allow clients to specify whether a call has video initially or not ([#7740](https://github.com/chatmail/core/pull/7740)).
- Do not load more than one own key from the keychain.
### Fixes
- Cross-account forwarding of a message which `has_html()` ([#7791](https://github.com/chatmail/core/pull/7791)).
- Make self-contact a key-contact even if key isn't generated yet.
- `apply_group_changes()`: Check whether From is key-contact.
- Don't add SELF to unencrypted chat created from encrypted message ([#7661](https://github.com/chatmail/core/pull/7661)).
- Don't upscale images and test that image resolution isn't changed unnecessarily ([#7769](https://github.com/chatmail/core/pull/7769)).
- Restart i/o when there are new transports in a sync message ([#7640](https://github.com/chatmail/core/pull/7640)).
- `add_or_lookup_key_contacts*()`: Advance fingerprint_iter on invalid address.
- `receive_imf`: Look up key contact by intended recipient fingerprint ([#7661](https://github.com/chatmail/core/pull/7661)).
- Remove `Config::DeleteToTrash` and `Config::ConfiguredTrashFolder`.
### API-Changes
- jsonrpc(python): Process events forever by default.
### CI
- Make scripts/deny.sh test the locked version of dependencies.
### Refactor
- Remove unneeded dbg! statements ([#7776](https://github.com/chatmail/core/pull/7776)).
- Remove unused Context.is_inbox().
- Rename lookup_key_contacts_by_address_list() to lookup_key_contacts_fallback_to_chat().
- Mark `ProviderOptions` as `non_exhaustive`.
### Miscellaneous Tasks
- Update provider database.
- cargo: Update `bytes` from 1.11.0 to 1.11.1.
- cargo: Bump tokio from 1.48.0 to 1.49.0.
- cargo: Bump tokio-util from 0.7.17 to 0.7.18.
- cargo: Bump libc from 0.2.178 to 0.2.180.
- cargo: Bump quote from 1.0.42 to 1.0.44.
- cargo: Bump syn from 2.0.111 to 2.0.114.
- cargo: Bump human-panic from 2.0.4 to 2.0.6.
- cargo: Bump chrono from 0.4.42 to 0.4.43.
- cargo: Bump data-encoding from 2.9.0 to 2.10.0.
- cargo: Bump colorutils-rs from 0.7.5 to 0.7.6.
- Update provider database.
- cargo: Bump thiserror from 2.0.17 to 2.0.18.
- deps: Bump EmbarkStudios/cargo-deny-action from 2.0.14 to 2.0.15.
- Remove RUSTSEC-2026-0002 exception from deny.toml.
- cargo: Bump tokio-stream from 0.1.17 to 0.1.18.
- cargo: Bump toml from 0.9.10+spec-1.1.0 to 0.9.11+spec-1.1.0.
- cargo: Bump serde_json from 1.0.148 to 1.0.149.
- cargo: Bump uuid from 1.19.0 to 1.20.0.
- cargo: Bump rustls-pki-types from 1.13.2 to 1.14.0.
- cargo: Bump tracing-subscriber from 0.3.20 to 0.3.22.
### Tests
- 2nd device receives message via new primary transport.
- Make `test_dont_move_sync_msgs` less flaky.
- Encrypted incoming message goes to encrypted 1:1 chat even if references messages in ad-hoc group.
- Message in blocked chat arrives as InSeen.
- Set `mvbox_move` to 0 for test rust accounts.
## [2.39.0] - 2026-01-23
### CI
@@ -440,7 +28,7 @@
- [**breaking**] Jsonrpc: remove `contacts` from `FullChat`. To migrate load contacts on demand via `get_contacts_by_ids` using `FullChat.contactIds` ([#7282](https://github.com/chatmail/core/pull/7282)).
- jsonrpc: Add run_until parameter for bots ([#7688](https://github.com/chatmail/core/pull/7688)).
- rust, jsonrpc: Add `get_message_read_receipt_count` method ([#7732](https://github.com/chatmail/core/pull/7732)).
- rust and jsonrpc: Marknoticed_all_chats method to mark all chats as noticed, including muted ones. ([#7709](https://github.com/chatmail/core/pull/7709)).
- rust and jsonrpc: Marknoticed_all_chats method to mark all chats as notices, including muted ones. ([#7709](https://github.com/chatmail/core/pull/7709)).
- Public re-export of Connectivity ([#7737](https://github.com/chatmail/core/pull/7737)).
### Documentation
@@ -672,7 +260,7 @@ that failed to be published for 2.31.0 due to not configured "trusted publishers
### Features / Changes
- `lookup_or_create_adhoc_group()`: Add context to SQL errors ([#7554](https://github.com/chatmail/core/pull/7554)).
- Lookup_or_create_adhoc_group(): Add context to SQL errors ([#7554](https://github.com/chatmail/core/pull/7554)).
## [2.31.0] - 2025-12-04
@@ -8031,13 +7619,3 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.36.0]: https://github.com/chatmail/core/compare/v2.35.0..v2.36.0
[2.37.0]: https://github.com/chatmail/core/compare/v2.36.0..v2.37.0
[2.38.0]: https://github.com/chatmail/core/compare/v2.37.0..v2.38.0
[2.39.0]: https://github.com/chatmail/core/compare/v2.38.0..v2.39.0
[2.40.0]: https://github.com/chatmail/core/compare/v2.39.0..v2.40.0
[2.41.0]: https://github.com/chatmail/core/compare/v2.40.0..v2.41.0
[2.42.0]: https://github.com/chatmail/core/compare/v2.41.0..v2.42.0
[2.43.0]: https://github.com/chatmail/core/compare/v2.42.0..v2.43.0
[2.44.0]: https://github.com/chatmail/core/compare/v2.43.0..v2.44.0
[2.45.0]: https://github.com/chatmail/core/compare/v2.44.0..v2.45.0
[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

543
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.48.0"
version = "2.39.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.88"
@@ -45,7 +45,7 @@ anyhow = { workspace = true }
async-broadcast = "0.7.2"
async-channel = { workspace = true }
async-imap = { version = "0.11.1", default-features = false, features = ["runtime-tokio", "compress"] }
async-native-tls = { version = "0.6", default-features = false, features = ["runtime-tokio"] }
async-native-tls = { version = "0.5", default-features = false, features = ["runtime-tokio"] }
async-smtp = { version = "0.10.2", default-features = false, features = ["runtime-tokio"] }
async_zip = { version = "0.0.18", default-features = false, features = ["deflate", "tokio-fs"] }
base64 = { workspace = true }
@@ -56,7 +56,7 @@ chrono = { workspace = true, features = ["alloc", "clock", "std"] }
colorutils-rs = { version = "0.7.5", default-features = false }
data-encoding = "2.9.0"
escaper = "0.1"
fast-socks5 = "1"
fast-socks5 = "0.10"
fd-lock = "4"
futures-lite = { workspace = true }
futures = { workspace = true }
@@ -78,16 +78,17 @@ num-derive = "0.4"
num-traits = { workspace = true }
parking_lot = "0.12.4"
percent-encoding = "2.3"
pgp = { version = "0.19.0", default-features = false }
pgp = { version = "0.18.0", default-features = false }
pin-project = "1"
qrcodegen = "1.7.0"
quick-xml = { version = "0.39", features = ["escape-html"] }
quick-xml = { version = "0.38", features = ["escape-html"] }
rand-old = { package = "rand", version = "0.8" }
rand = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rustls-pki-types = "1.12.0"
sanitize-filename = { workspace = true }
sdp = "0.17.1"
sdp = "0.10.0"
serde_json = { workspace = true }
serde_urlencoded = "0.7.1"
serde = { workspace = true, features = ["derive"] }
@@ -95,15 +96,15 @@ sha-1 = "0.10"
sha2 = "0.10"
shadowsocks = { version = "1.23.1", default-features = false, features = ["aead-cipher", "aead-cipher-2022"] }
smallvec = "1.15.1"
strum = "0.28"
strum_macros = "0.28"
strum = "0.27"
strum_macros = "0.27"
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 }
tokio-stream = { version = "0.1.17", features = ["fs"] }
astral-tokio-tar = { version = "0.6", default-features = false }
astral-tokio-tar = { version = "0.5.6", default-features = false }
tokio-util = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.9"
@@ -181,11 +182,11 @@ harness = false
anyhow = "1"
async-channel = "2.5.0"
base64 = "0.22"
chrono = { version = "0.4.43", default-features = false }
chrono = { version = "0.4.42", default-features = false }
deltachat-contact-tools = { path = "deltachat-contact-tools" }
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
deltachat = { path = ".", default-features = false }
futures = "0.3.32"
futures = "0.3.31"
futures-lite = "2.6.1"
libc = "0.2"
log = "0.4"
@@ -193,15 +194,15 @@ mailparse = "0.16.1"
nu-ansi-term = "0.50"
num-traits = "0.2"
rand = "0.9"
regex = "1.12"
regex = "1.10"
rusqlite = "0.37"
sanitize-filename = "0.6"
serde = "1.0"
serde_json = "1"
tempfile = "3.25.0"
tempfile = "3.24.0"
thiserror = "2"
tokio = "1"
tokio-util = "0.7.18"
tokio-util = "0.7.17"
tracing-subscriber = "0.3"
yerpc = "0.6.4"

View File

@@ -22,44 +22,6 @@ For example, to release version 1.116.0 of the core, do the following steps.
9. Create a GitHub release: `gh release create v1.116.0 --notes ''`.
10. Update the version to the next development version:
`scripts/set_core_version.py 1.117.0-dev`.
11. Commit and push the change:
`git commit -m "chore: bump version to 1.117.0-dev" && git push origin main`.
12. Once the binaries are generated and [published](https://github.com/chatmail/core/releases),
check Windows binaries for false positive detections at [VirusTotal].
Either upload the binaries directly or submit a direct link to the artifact.
You can use [old browsers interface](https://www.virustotal.com/old-browsers/)
if there are problems with using the default website.
If you submit a direct link and get to the page saying
"No security vendors flagged this URL as malicious",
it does not mean that the file itself is not detected.
You need to go to the "details" tab
and click on the SHA-256 hash in the "Body SHA-256" section.
If any false positive is detected,
open an issue to track removing it.
See <https://github.com/chatmail/core/issues/7847>
for an example of false positive detection issue.
If there is a false positive "Microsoft" detection,
mark the issue as a blocker.
[VirusTotal]: https://www.virustotal.com/
## Dealing with antivirus false positives
If Windows release is incorrectly detected by some antivirus, submit requests to remove detection.
"Microsoft" antivirus is built in Windows and will break user setups so removing its detection should be highest priority.
To submit false positive to Microsoft, go to <https://www.microsoft.com/en-us/wdsi/filesubmission> and select "Submit file as a ... Software developer" option.
False positive contacts for other vendors can be found at <https://docs.virustotal.com/docs/false-positive-contacts>.
Not all of them may be up to date, so check the links below first.
Previously we successfully used the following contacts:
- [ESET-NOD32](mailto:samples@eset.com)
- [Symantec](https://symsubmit.symantec.com/)
## Dealing with failed releases
Once you make a GitHub release,

View File

@@ -68,12 +68,6 @@ keyword doesn't help here.
Consider adding context to `anyhow` errors for SQL statements using `.context()` so that it's
possible to understand from logs which statement failed. See [Errors](#errors) for more info.
When changing complex SQL queries, test them on a new database with `EXPLAIN QUERY PLAN`
to make sure that indexes are used and large tables are not going to be scanned.
Never run `ANALYZE` on the databases,
this makes query planner unpredictable
and may make performance significantly worse: <https://github.com/chatmail/core/issues/6585>
## Errors
Delta Chat core mostly uses [`anyhow`](https://docs.rs/anyhow/) errors.

View File

@@ -8,47 +8,43 @@
//! cargo bench --bench decrypting --features="internals"
//! ```
//!
//! or, if you want to only run e.g. the 'Decrypt and parse a symmetrically encrypted message' benchmark:
//! or, if you want to only run e.g. the 'Decrypt a symmetrically encrypted message' benchmark:
//!
//! ```text
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt and parse a symmetrically encrypted message'
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt a symmetrically encrypted message'
//! ```
//!
//! You can also pass a substring:
//! You can also pass a substring.
//! So, you can run all 'Decrypt and parse' benchmarks with:
//!
//! ```text
//! cargo bench --bench decrypting --features="internals" -- 'symmetrically'
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt and parse'
//! ```
//!
//! Symmetric decryption has to try out all known secrets,
//! You can benchmark this by adapting the `NUM_SECRETS` variable.
use std::hint::black_box;
use std::sync::LazyLock;
use criterion::{Criterion, criterion_group, criterion_main};
use deltachat::internals_for_benches::create_broadcast_secret;
use deltachat::internals_for_benches::create_dummy_keypair;
use deltachat::internals_for_benches::save_broadcast_secret;
use deltachat::securejoin::get_securejoin_qr;
use deltachat::{
Events, chat::ChatId, config::Config, context::Context, internals_for_benches::key_from_asc,
internals_for_benches::parse_and_get_text, internals_for_benches::store_self_keypair,
Events,
chat::ChatId,
config::Config,
context::Context,
internals_for_benches::key_from_asc,
internals_for_benches::parse_and_get_text,
internals_for_benches::store_self_keypair,
pgp::{KeyPair, SeipdVersion, decrypt, pk_encrypt, symm_encrypt_message},
stock_str::StockStrings,
};
use rand::{Rng, rng};
use tempfile::tempdir;
static NUM_BROADCAST_SECRETS: LazyLock<usize> = LazyLock::new(|| {
std::env::var("NUM_BROADCAST_SECRETS")
.unwrap_or("500".to_string())
.parse()
.unwrap()
});
static NUM_AUTH_TOKENS: LazyLock<usize> = LazyLock::new(|| {
std::env::var("NUM_AUTH_TOKENS")
.unwrap_or("5000".to_string())
.parse()
.unwrap()
});
const NUM_SECRETS: usize = 500;
async fn create_context() -> Context {
let dir = tempdir().unwrap();
@@ -62,7 +58,9 @@ async fn create_context() -> Context {
.await
.unwrap();
let secret = key_from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap();
store_self_keypair(&context, &secret)
let public = secret.signed_public_key();
let key_pair = KeyPair { public, secret };
store_self_keypair(&context, &key_pair)
.await
.expect("Failed to save key");
@@ -72,6 +70,66 @@ async fn create_context() -> Context {
fn criterion_benchmark(c: &mut Criterion) {
let mut group = c.benchmark_group("Decrypt");
// ===========================================================================================
// Benchmarks for decryption only, without any other parsing
// ===========================================================================================
group.sample_size(10);
group.bench_function("Decrypt a symmetrically encrypted message", |b| {
let plain = generate_plaintext();
let secrets = generate_secrets();
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
let secret = secrets[NUM_SECRETS / 2].clone();
symm_encrypt_message(
plain.clone(),
create_dummy_keypair("alice@example.org").unwrap().secret,
black_box(&secret),
true,
)
.await
.unwrap()
});
b.iter(|| {
let mut msg =
decrypt(encrypted.clone().into_bytes(), &[], black_box(&secrets)).unwrap();
let decrypted = msg.as_data_vec().unwrap();
assert_eq!(black_box(decrypted), plain);
});
});
group.bench_function("Decrypt a public-key encrypted message", |b| {
let plain = generate_plaintext();
let key_pair = create_dummy_keypair("alice@example.org").unwrap();
let secrets = generate_secrets();
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
pk_encrypt(
plain.clone(),
vec![black_box(key_pair.public.clone())],
key_pair.secret.clone(),
true,
true,
SeipdVersion::V2,
)
.await
.unwrap()
});
b.iter(|| {
let mut msg = decrypt(
encrypted.clone().into_bytes(),
std::slice::from_ref(&key_pair.secret),
black_box(&secrets),
)
.unwrap();
let decrypted = msg.as_data_vec().unwrap();
assert_eq!(black_box(decrypted), plain);
});
});
// ===========================================================================================
// Benchmarks for the whole parsing pipeline, incl. decryption (but excl. receive_imf())
// ===========================================================================================
@@ -81,7 +139,7 @@ fn criterion_benchmark(c: &mut Criterion) {
// "secret" is the shared secret that was used to encrypt text_symmetrically_encrypted.eml.
// Put it into the middle of our secrets:
secrets[*NUM_BROADCAST_SECRETS / 2] = "secret".to_string();
secrets[NUM_SECRETS / 2] = "secret".to_string();
let context = rt.block_on(async {
let context = create_context().await;
@@ -90,10 +148,6 @@ fn criterion_benchmark(c: &mut Criterion) {
.await
.unwrap();
}
for _i in 0..*NUM_AUTH_TOKENS {
get_securejoin_qr(&context, None).await.unwrap();
}
println!("NUM_AUTH_TOKENS={}", *NUM_AUTH_TOKENS);
context
});
@@ -107,7 +161,7 @@ fn criterion_benchmark(c: &mut Criterion) {
)
.await
.unwrap();
assert_eq!(black_box(text), "Symmetrically encrypted message");
assert_eq!(text, "Symmetrically encrypted message");
}
});
});
@@ -122,7 +176,7 @@ fn criterion_benchmark(c: &mut Criterion) {
)
.await
.unwrap();
assert_eq!(black_box(text), "hi");
assert_eq!(text, "hi");
}
});
});
@@ -131,12 +185,17 @@ fn criterion_benchmark(c: &mut Criterion) {
}
fn generate_secrets() -> Vec<String> {
let secrets: Vec<String> = (0..*NUM_BROADCAST_SECRETS)
let secrets: Vec<String> = (0..NUM_SECRETS)
.map(|_| create_broadcast_secret())
.collect();
println!("NUM_BROADCAST_SECRETS={}", *NUM_BROADCAST_SECRETS);
secrets
}
fn generate_plaintext() -> Vec<u8> {
let mut plain: Vec<u8> = vec![0; 500];
rng().fill(&mut plain[..]);
plain
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

View File

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

View File

@@ -36,45 +36,6 @@ impl VcardContact {
}
}
fn escape(s: &str) -> String {
// https://www.rfc-editor.org/rfc/rfc6350.html#section-3.4
s
// backslash must be first!
.replace(r"\", r"\\")
.replace(',', r"\,")
.replace(';', r"\;")
.replace('\n', r"\n")
}
fn unescape(s: &str) -> String {
// https://www.rfc-editor.org/rfc/rfc6350.html#section-3.4
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(next) = chars.next() {
match next {
'\\' | ',' | ';' => out.push(next),
'n' | 'N' => out.push('\n'),
_ => {
// Invalid escape sequence (keep unchanged)
out.push('\\');
out.push(next);
}
}
} else {
// Invalid escape sequence (keep unchanged)
out.push('\\');
}
} else {
out.push(c);
}
}
out
}
/// Returns a vCard containing given contacts.
///
/// Calling [`parse_vcard()`] on the returned result is a reverse operation.
@@ -85,6 +46,10 @@ pub fn make_vcard(contacts: &[VcardContact]) -> String {
Some(datetime.format("%Y%m%dT%H%M%SZ").to_string())
}
fn escape(s: &str) -> String {
s.replace(',', "\\,")
}
let mut res = "".to_string();
for c in contacts {
// Mustn't contain ',', but it's easier to escape than to error out.
@@ -159,7 +124,7 @@ pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
fn vcard_property<'a>(line: &'a str, property: &str) -> Option<(&'a str, String)> {
let (params, value) = vcard_property_raw(line, property)?;
// Some fields can't contain commas, but unescape them everywhere for safety.
Some((params, unescape(value)))
Some((params, value.replace("\\,", ",")))
}
fn base64_key(line: &str) -> Option<&str> {
let (params, value) = vcard_property_raw(line, "key")?;

View File

@@ -91,7 +91,7 @@ fn test_make_and_parse_vcard() {
authname: "Alice Wonderland".to_string(),
key: Some("[base64-data]".to_string()),
profile_image: Some("image in Base64".to_string()),
biography: Some("Hi,\nI'm Alice; and this is a backslash: \\".to_string()),
biography: Some("Hi, I'm Alice".to_string()),
timestamp: Ok(1713465762),
},
VcardContact {
@@ -110,7 +110,7 @@ fn test_make_and_parse_vcard() {
FN:Alice Wonderland\r\n\
KEY:data:application/pgp-keys;base64\\,[base64-data]\r\n\
PHOTO:data:image/jpeg;base64\\,image in Base64\r\n\
NOTE:Hi\\,\\nI'm Alice\\; and this is a backslash: \\\\\r\n\
NOTE:Hi\\, I'm Alice\r\n\
REV:20240418T184242Z\r\n\
END:VCARD\r\n",
"BEGIN:VCARD\r\n\
@@ -276,14 +276,3 @@ END:VCARD",
assert!(contacts[0].timestamp.is_err());
assert_eq!(contacts[0].profile_image.as_ref().unwrap(), "/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z");
}
#[test]
fn test_vcard_value_escape_unescape() {
let original = "Text, with; chars and a \\ and a newline\nand a literal newline \\n";
let expected_escaped = r"Text\, with\; chars and a \\ and a newline\nand a literal newline \\n";
let escaped = escape(original);
assert_eq!(escaped, expected_escaped);
let unescaped = unescape(&escaped);
assert_eq!(original, unescaped);
}

View File

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

View File

@@ -364,14 +364,18 @@ uint32_t dc_get_id (dc_context_t* context);
* To get these events, you have to create an event emitter using this function
* and call dc_get_next_event() on the emitter.
*
* Events are broadcasted to all existing event emitters.
* Events emitted before creation of event emitter
* are not available to event emitter.
*
* @memberof dc_context_t
* @param context The context object as created by dc_context_new().
* @return Returns the event emitter, NULL on errors.
* Must be freed using dc_event_emitter_unref() after usage.
*
* Note: Use only one event emitter per context.
* The result of having multiple event emitters is unspecified.
* Currently events are broadcasted to all existing event emitters,
* but previous versions delivered events to only one event emitter
* and this behavior may change again in the future.
* Events emitted before creation of event emitter
* may or may not be available to event emitter.
*/
dc_event_emitter_t* dc_get_event_emitter(dc_context_t* context);
@@ -1238,12 +1242,9 @@ uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t c
* This needs to be a one-to-one chat.
* @param place_call_info any data that other devices receive
* in #DC_EVENT_INCOMING_CALL.
* @param has_video Whether the call has video initially.
* This allows the recipient's client to adjust incoming call UX.
* A call can be upgraded to include video later.
* @return ID of the system message announcing the call.
*/
uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info, int has_video);
uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info);
/**
@@ -1565,7 +1566,7 @@ dc_array_t* dc_wait_next_msgs (dc_context_t* context);
* (read receipts aren't sent for noticed messages).
*
* Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
* See also dc_markseen_msgs() and dc_markfresh_chat().
* See also dc_markseen_msgs().
*
* @memberof dc_context_t
* @param context The context object as returned from dc_context_new().
@@ -1574,29 +1575,6 @@ dc_array_t* dc_wait_next_msgs (dc_context_t* context);
void dc_marknoticed_chat (dc_context_t* context, uint32_t chat_id);
/**
* Mark the last incoming message in chat as _fresh_.
*
* UI can use this to offer a "mark unread" option,
* so that already noticed chats (see dc_marknoticed_chat()) get a badge counter again.
*
* dc_get_fresh_msg_cnt() and dc_get_fresh_msgs() usually is increased by one afterwards.
*
* #DC_EVENT_MSGS_CHANGED is fired as usual,
* however, #DC_EVENT_INCOMING_MSG is _not_ fired again.
* This is to not add complexity to incoming messages code,
* e.g. UI usually does not add notifications for manually unread chats.
* If the UI wants to update system badge counters,
* they should do so directly after calling dc_markfresh_chat().
*
* @memberof dc_context_t
* @param context The context object as returned from dc_context_new().
* @param chat_id The chat ID of which the last incoming message should be marked as fresh.
* If the chat does not have incoming messages, nothing happens.
*/
void dc_markfresh_chat (dc_context_t* context, uint32_t chat_id);
/**
* Returns all message IDs of the given types in a given chat or any chat.
* Typically used to show a gallery.
@@ -1873,16 +1851,15 @@ int dc_remove_contact_from_chat (dc_context_t* context, uint32_t ch
/**
* Set the name of a group or broadcast channel.
* Set group name.
*
* If the group is already _promoted_ (any message was sent to the group),
* or if this is a brodacast channel,
* all members are informed by a special status message that is sent automatically by this function.
* all group members are informed by a special status message that is sent automatically by this function.
*
* Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
*
* @memberof dc_context_t
* @param chat_id The chat ID to set the name for. Must be a group chat or broadcast channel.
* @param chat_id The chat ID to set the name for. Must be a group chat.
* @param name New name of the group.
* @param context The context object.
* @return 1=success, 0=error
@@ -1909,11 +1886,10 @@ int dc_set_chat_name (dc_context_t* context, uint32_t ch
int dc_set_chat_ephemeral_timer (dc_context_t* context, uint32_t chat_id, uint32_t timer);
/**
* Set group or broadcast channel profile image.
* Set group profile image.
*
* If the group is already _promoted_ (any message was sent to the group),
* or if this is a brodacast channel,
* all members are informed by a special status message that is sent automatically by this function.
* all group members are informed by a special status message that is sent automatically by this function.
*
* Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
*
@@ -1921,7 +1897,7 @@ int dc_set_chat_ephemeral_timer (dc_context_t* context, uint32_t chat_id, uint32
*
* @memberof dc_context_t
* @param context The context object.
* @param chat_id The chat ID to set the image for. Must be a group chat or broadcast channel.
* @param chat_id The chat ID to set the image for.
* @param image Full path of the image to use as the group image. The image will immediately be copied to the
* `blobdir`; the original image will not be needed anymore.
* If you pass NULL here, the group image is deleted (for promoted groups, all members are informed about
@@ -2490,6 +2466,76 @@ void dc_imex (dc_context_t* context, int what, c
char* dc_imex_has_backup (dc_context_t* context, const char* dir);
/**
* Initiate Autocrypt Setup Transfer.
* Before starting the setup transfer with this function, the user should be asked:
*
* ~~~
* "An 'Autocrypt Setup Message' securely shares your end-to-end setup with other Autocrypt-compliant apps.
* The setup will be encrypted by a setup code which is displayed here and must be typed on the other device.
* ~~~
*
* After that, this function should be called to send the Autocrypt Setup Message.
* The function creates the setup message and adds it to outgoing message queue.
* The message is sent asynchronously.
*
* The required setup code is returned in the following format:
*
* ~~~
* 1234-1234-1234-1234-1234-1234-1234-1234-1234
* ~~~
*
* The setup code should be shown to the user then:
*
* ~~~
* "Your key has been sent to yourself. Switch to the other device and
* open the setup message. You should be prompted for a setup code. Type
* the following digits into the prompt:
*
* 1234 - 1234 - 1234 -
* 1234 - 1234 - 1234 -
* 1234 - 1234 - 1234
*
* Once you're done, your other device will be ready to use Autocrypt."
* ~~~
*
* On the _other device_ you will call dc_continue_key_transfer() then
* for setup messages identified by dc_msg_is_setupmessage().
*
* For more details about the Autocrypt setup process, please refer to
* https://autocrypt.org/en/latest/level1.html#autocrypt-setup-message
*
* @memberof dc_context_t
* @param context The context object.
* @return The setup code. Must be released using dc_str_unref() after usage.
* On errors, e.g. if the message could not be sent, NULL is returned.
*/
char* dc_initiate_key_transfer (dc_context_t* context);
/**
* Continue the Autocrypt Key Transfer on another device.
*
* If you have started the key transfer on another device using dc_initiate_key_transfer()
* and you've detected a setup message with dc_msg_is_setupmessage(), you should prompt the
* user for the setup code and call this function then.
*
* You can use dc_msg_get_setupcodebegin() to give the user a hint about the code (useful if the user
* has created several messages and should not enter the wrong code).
*
* @memberof dc_context_t
* @param context The context object.
* @param msg_id The ID of the setup message to decrypt.
* @param setup_code The setup code entered by the user. This is the same setup code as returned from
* dc_initiate_key_transfer() on the other device.
* There is no need to format the string correctly, the function will remove all spaces and other characters and
* insert the `-` characters at the correct places.
* @return 1=key successfully decrypted and imported; both devices will use the same key now;
* 0=key transfer failed e.g. due to a bad setup code.
*/
int dc_continue_key_transfer (dc_context_t* context, uint32_t msg_id, const char* setup_code);
/**
* Signal an ongoing process to stop.
*
@@ -3319,14 +3365,18 @@ void dc_accounts_set_push_device_token (dc_accounts_t* accounts, const
* This is similar to dc_get_event_emitter(), which, however,
* must not be called for accounts handled by the account manager.
*
* Events are broadcasted to all existing event emitters.
* Events emitted before creation of event emitter
* are not available to event emitter.
*
* @memberof dc_accounts_t
* @param accounts The account manager as created by dc_accounts_new().
* @return Returns the event emitter, NULL on errors.
* Must be freed using dc_event_emitter_unref() after usage.
*
* Note: Use only one event emitter per account manager.
* The result of having multiple event emitters is unspecified.
* Currently events are broadcasted to all existing event emitters,
* but previous versions delivered events to only one event emitter
* and this behavior may change again in the future.
* Events emitted before creation of event emitter
* are not available to event emitter.
*/
dc_event_emitter_t* dc_accounts_get_event_emitter (dc_accounts_t* accounts);
@@ -4557,7 +4607,6 @@ int dc_msg_is_info (const dc_msg_t* msg);
* and also offer a way to fix the encryption, eg. by a button offering a QR scan
* - DC_INFO_WEBXDC_INFO_MESSAGE (32) - Info-message created by webxdc app sending `update.info`
* - DC_INFO_CHAT_E2EE (50) - Info-message for "Chat is end-to-end-encrypted"
* - DC_INFO_GROUP_DESCRIPTION_CHANGED (70) - Info-message "Description changed", UI should open the profile with the description
*
* For the messages that refer to a CONTACT,
* dc_msg_get_info_contact_id() returns the contact ID.
@@ -4604,10 +4653,7 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
#define DC_INFO_GROUP_IMAGE_CHANGED 3
#define DC_INFO_MEMBER_ADDED_TO_GROUP 4
#define DC_INFO_MEMBER_REMOVED_FROM_GROUP 5
// Deprecated as of 2026-03-16, not used for new messages.
#define DC_INFO_AUTOCRYPT_SETUP_MESSAGE 6
#define DC_INFO_SECURE_JOIN_MESSAGE 7
#define DC_INFO_LOCATIONSTREAMING_ENABLED 8
#define DC_INFO_LOCATION_ONLY 9
@@ -4616,7 +4662,6 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
#define DC_INFO_INVALID_UNENCRYPTED_MAIL 13
#define DC_INFO_WEBXDC_INFO_MESSAGE 32
#define DC_INFO_CHAT_E2EE 50
#define DC_INFO_GROUP_DESCRIPTION_CHANGED 70
/**
@@ -4637,6 +4682,40 @@ uint32_t dc_msg_get_info_contact_id (const dc_msg_t* msg);
char* dc_msg_get_webxdc_href (const dc_msg_t* msg);
/**
* Check if the message is an Autocrypt Setup Message.
*
* Setup messages should be shown in an unique way e.g. using a different text color.
* On a click or another action, the user should be prompted for the setup code
* which is forwarded to dc_continue_key_transfer() then.
*
* Setup message are typically generated by dc_initiate_key_transfer() on another device.
*
* @memberof dc_msg_t
* @param msg The message object.
* @return 1=message is a setup message, 0=no setup message.
* For setup messages, dc_msg_get_viewtype() returns #DC_MSG_FILE.
*/
int dc_msg_is_setupmessage (const dc_msg_t* msg);
/**
* Get the first characters of the setup code.
*
* Typically, this is used to pre-fill the first entry field of the setup code.
* If the user has several setup messages, he can be sure typing in the correct digits.
*
* To check, if a message is a setup message, use dc_msg_is_setupmessage().
* To decrypt a secret key from a setup message, use dc_continue_key_transfer().
*
* @memberof dc_msg_t
* @param msg The message object.
* @return Typically the first two digits of the setup code or an empty string if unknown.
* NULL is never returned. Must be released using dc_str_unref() when done.
*/
char* dc_msg_get_setupcodebegin (const dc_msg_t* msg);
/**
* Gets the error status of the message.
* If there is no error associated with the message, NULL is returned.
@@ -5951,7 +6030,7 @@ char* dc_jsonrpc_blocking_call(dc_jsonrpc_instance_t* jsonrpc_instance, const ch
* @memberof dc_event_channel_t
* @return An event channel wrapper object (dc_event_channel_t).
*/
dc_event_channel_t* dc_event_channel_new(void);
dc_event_channel_t* dc_event_channel_new();
/**
* Release/free the events channel structure.
@@ -5971,14 +6050,21 @@ void dc_event_channel_unref(dc_event_channel_t* event_channel);
* To get these events, you have to create an event emitter using this function
* and call dc_get_next_event() on the emitter.
*
* Events are broadcasted to all existing event emitters.
* Events emitted before creation of event emitter
* are not available to event emitter.
* This is similar to dc_get_event_emitter(), which, however,
* must not be called for accounts handled by the account manager.
*
* @memberof dc_event_channel_t
* @param The event channel.
* @return Returns the event emitter, NULL on errors.
* Must be freed using dc_event_emitter_unref() after usage.
*
* Note: Use only one event emitter per account manager / event channel.
* The result of having multiple event emitters is unspecified.
* Currently events are broadcasted to all existing event emitters,
* but previous versions delivered events to only one event emitter
* and this behavior may change again in the future.
* Events emitted before creation of event emitter
* are not available to event emitter.
*/
dc_event_emitter_t* dc_event_channel_get_event_emitter(dc_event_channel_t* event_channel);
@@ -6223,7 +6309,7 @@ void dc_event_unref(dc_event_t* event);
* should not be disturbed by a dialog or so. Instead, use a bubble or so.
*
* However, for ongoing processes (e.g. dc_configure())
* or for functions that are expected to fail
* or for functions that are expected to fail (e.g. dc_continue_key_transfer())
* it might be better to delay showing these events until the function has really
* failed (returned false). It should be sufficient to report only the _last_ error
* in a message box then.
@@ -6662,7 +6748,6 @@ 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) 1 if the call was accepted from this device (process).
*/
#define DC_EVENT_INCOMING_CALL_ACCEPTED 2560
@@ -6691,8 +6776,8 @@ void dc_event_unref(dc_event_t* event);
* UI should update the list.
*
* The event is emitted when the transports are modified on another device
* using the JSON-RPC calls `add_or_update_transport`, `add_transport_from_qr`, `delete_transport`,
* `set_transport_unpublished` or `set_config(configured_addr)`.
* using the JSON-RPC calls `add_or_update_transport`, `add_transport_from_qr`, `delete_transport`
* or `set_config(configured_addr)`.
*/
#define DC_EVENT_TRANSPORTS_MODIFIED 2600
@@ -7404,7 +7489,7 @@ void dc_event_unref(dc_event_t* event);
/// "Messages are end-to-end encrypted."
///
/// Used in info-messages, UI may add smth. as "Tap to learn more."
/// Used in info messages.
#define DC_STR_CHAT_PROTECTION_ENABLED 170
/// "Others will only see this group after you sent a first message."
@@ -7456,6 +7541,12 @@ void dc_event_unref(dc_event_t* event);
/// "❤️ Seems you're enjoying Delta Chat!"… (donation request device message)
#define DC_STR_DONATION_REQUEST 193
/// "Outgoing call"
#define DC_STR_OUTGOING_CALL 194
/// "Incoming call"
#define DC_STR_INCOMING_CALL 195
/// "Declined call"
#define DC_STR_DECLINED_CALL 196
@@ -7487,19 +7578,6 @@ void dc_event_unref(dc_event_t* event);
/// `%1$s` and `%2$s` will both be replaced by the name of the inviter.
#define DC_STR_SECURE_JOIN_CHANNEL_STARTED 203
/// "Channel name changed from %1$s to %2$s."
///
/// Used in status messages.
///
/// `%1$s` will be replaced by the old channel name.
/// `%2$s` will be replaced by the new channel name.
#define DC_STR_CHANNEL_NAME_CHANGED 204
/// "Channel image changed."
///
/// Used in status messages.
#define DC_STR_CHANNEL_IMAGE_CHANGED 205
/// "The attachment contains anonymous usage statistics, which help us improve Delta Chat. Thank you!"
///
/// Used as the message body for statistics sent out.
@@ -7520,29 +7598,6 @@ void dc_event_unref(dc_event_t* event);
/// Used as the first info messages in newly created classic email threads.
#define DC_STR_CHAT_UNENCRYPTED_EXPLANATON 230
/// "Outgoing audio call"
#define DC_STR_OUTGOING_AUDIO_CALL 232
/// "Outgoing video call"
#define DC_STR_OUTGOING_VIDEO_CALL 233
/// "Incoming audio call"
#define DC_STR_INCOMING_AUDIO_CALL 234
/// "Incoming video call"
#define DC_STR_INCOMING_VIDEO_CALL 235
/// "You changed the chat description."
#define DC_STR_GROUP_DESCRIPTION_CHANGED_BY_YOU 240
/// "Chat description changed by %1$s."
#define DC_STR_GROUP_DESCRIPTION_CHANGED_BY_OTHER 241
/// "Messages are end-to-end encrypted."
///
/// Used when creating text for the "Encryption Info" dialogs.
#define DC_STR_MESSAGES_ARE_E2EE 242
/**
* @}
*/

View File

@@ -15,7 +15,6 @@ use std::collections::BTreeMap;
use std::convert::TryFrom;
use std::fmt::Write;
use std::future::Future;
use std::mem::ManuallyDrop;
use std::ptr;
use std::str::FromStr;
use std::sync::{Arc, LazyLock, Mutex};
@@ -306,17 +305,20 @@ pub unsafe extern "C" fn dc_set_stock_translation(
let msg = to_string_lossy(stock_msg);
let ctx = &*context;
match StockMessage::from_u32(stock_id)
.with_context(|| format!("Invalid stock message ID {stock_id}"))
.log_err(ctx)
{
Ok(id) => ctx
.set_stock_translation(id, msg)
.context("set_stock_translation failed")
block_on(async move {
match StockMessage::from_u32(stock_id)
.with_context(|| format!("Invalid stock message ID {stock_id}"))
.log_err(ctx)
.is_ok() as libc::c_int,
Err(_) => 0,
}
{
Ok(id) => ctx
.set_stock_translation(id, msg)
.await
.context("set_stock_translation failed")
.log_err(ctx)
.is_ok() as libc::c_int,
Err(_) => 0,
}
})
}
#[no_mangle]
@@ -677,6 +679,7 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
| EventType::ChatModified(_)
| EventType::ChatDeleted { .. }
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::IncomingCallAccepted { .. }
| EventType::OutgoingCallAccepted { .. }
| EventType::CallEnded { .. }
| EventType::EventChannelOverflow { .. }
@@ -699,9 +702,6 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
} => status_update_serial.to_u32() as libc::c_int,
EventType::WebxdcRealtimeData { data, .. } => data.len() as libc::c_int,
EventType::IncomingCall { has_video, .. } => *has_video as libc::c_int,
EventType::IncomingCallAccepted {
from_this_device, ..
} => *from_this_device as libc::c_int,
#[allow(unreachable_patterns)]
#[cfg(test)]
@@ -1181,7 +1181,6 @@ pub unsafe extern "C" fn dc_place_outgoing_call(
context: *mut dc_context_t,
chat_id: u32,
place_call_info: *const libc::c_char,
has_video: bool,
) -> u32 {
if context.is_null() || chat_id == 0 {
eprintln!("ignoring careless call to dc_place_outgoing_call()");
@@ -1191,7 +1190,7 @@ pub unsafe extern "C" fn dc_place_outgoing_call(
let chat_id = ChatId::new(chat_id);
let place_call_info = to_string_lossy(place_call_info);
block_on(ctx.place_outgoing_call(chat_id, place_call_info, has_video))
block_on(ctx.place_outgoing_call(chat_id, place_call_info))
.context("Failed to place call")
.log_err(ctx)
.map(|msg_id| msg_id.to_u32())
@@ -1520,23 +1519,6 @@ pub unsafe extern "C" fn dc_marknoticed_chat(context: *mut dc_context_t, chat_id
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_markfresh_chat(context: *mut dc_context_t, chat_id: u32) {
if context.is_null() {
eprintln!("ignoring careless call to dc_markfresh_chat()");
return;
}
let ctx = &*context;
block_on(async move {
chat::markfresh_chat(ctx, ChatId::new(chat_id))
.await
.context("Failed markfresh chat")
.log_err(ctx)
.unwrap_or(())
})
}
fn from_prim<S, T>(s: S) -> Option<T>
where
T: FromPrimitive,
@@ -2443,6 +2425,45 @@ pub unsafe extern "C" fn dc_imex_has_backup(
}
}
#[no_mangle]
pub unsafe extern "C" fn dc_initiate_key_transfer(context: *mut dc_context_t) -> *mut libc::c_char {
if context.is_null() {
eprintln!("ignoring careless call to dc_initiate_key_transfer()");
return ptr::null_mut(); // NULL explicitly defined as "error"
}
let ctx = &*context;
match block_on(imex::initiate_key_transfer(ctx))
.context("dc_initiate_key_transfer()")
.log_err(ctx)
{
Ok(res) => res.strdup(),
Err(_) => ptr::null_mut(),
}
}
#[no_mangle]
pub unsafe extern "C" fn dc_continue_key_transfer(
context: *mut dc_context_t,
msg_id: u32,
setup_code: *const libc::c_char,
) -> libc::c_int {
if context.is_null() || msg_id <= constants::DC_MSG_ID_LAST_SPECIAL || setup_code.is_null() {
eprintln!("ignoring careless call to dc_continue_key_transfer()");
return 0;
}
let ctx = &*context;
block_on(imex::continue_key_transfer(
ctx,
MsgId::new(msg_id),
&to_string_lossy(setup_code),
))
.context("dc_continue_key_transfer")
.log_err(ctx)
.is_ok() as libc::c_int
}
#[no_mangle]
pub unsafe extern "C" fn dc_stop_ongoing_process(context: *mut dc_context_t) {
if context.is_null() {
@@ -3765,6 +3786,16 @@ pub unsafe extern "C" fn dc_msg_get_webxdc_href(msg: *mut dc_msg_t) -> *mut libc
ffi_msg.message.get_webxdc_href().strdup()
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_is_setupmessage(msg: *mut dc_msg_t) -> libc::c_int {
if msg.is_null() {
eprintln!("ignoring careless call to dc_msg_is_setupmessage()");
return 0;
}
let ffi_msg = &*msg;
ffi_msg.message.is_setupmessage().into()
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_has_html(msg: *mut dc_msg_t) -> libc::c_int {
if msg.is_null() {
@@ -3775,6 +3806,20 @@ pub unsafe extern "C" fn dc_msg_has_html(msg: *mut dc_msg_t) -> libc::c_int {
ffi_msg.message.has_html().into()
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_get_setupcodebegin(msg: *mut dc_msg_t) -> *mut libc::c_char {
if msg.is_null() {
eprintln!("ignoring careless call to dc_msg_get_setupcodebegin()");
return "".strdup();
}
let ffi_msg = &*msg;
let ctx = &*ffi_msg.context;
block_on(ffi_msg.message.get_setupcodebegin(ctx))
.unwrap_or_default()
.strdup()
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_set_text(msg: *mut dc_msg_t, text: *const libc::c_char) {
if msg.is_null() {
@@ -5104,10 +5149,10 @@ pub unsafe extern "C" fn dc_jsonrpc_init(
return ptr::null_mut();
}
let account_manager = ManuallyDrop::new(Arc::from_raw(account_manager));
let cmd_api = block_on(deltachat_jsonrpc::api::CommandApi::from_arc(Arc::clone(
&account_manager,
)));
let account_manager = Arc::from_raw(account_manager);
let cmd_api = block_on(deltachat_jsonrpc::api::CommandApi::from_arc(
account_manager.clone(),
));
let (request_handle, receiver) = RpcClient::new();
let handle = RpcSession::new(request_handle, cmd_api);

View File

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

View File

@@ -11,8 +11,8 @@ use deltachat::blob::BlobObject;
use deltachat::calls::ice_servers;
use deltachat::chat::{
self, add_contact_to_chat, forward_msgs, forward_msgs_2ctx, get_chat_media, get_chat_msgs,
get_chat_msgs_ex, markfresh_chat, marknoticed_all_chats, marknoticed_chat,
remove_contact_from_chat, Chat, ChatId, ChatItem, MessageListOptions,
get_chat_msgs_ex, marknoticed_all_chats, marknoticed_chat, remove_contact_from_chat, Chat,
ChatId, ChatItem, MessageListOptions,
};
use deltachat::chatlist::Chatlist;
use deltachat::config::{get_all_ui_config_keys, Config};
@@ -31,7 +31,7 @@ use deltachat::peer_channels::{
};
use deltachat::provider::get_provider_info;
use deltachat::qr::{self, Qr};
use deltachat::qr_code_generator::{create_qr_svg, generate_backup_qr, get_securejoin_qr_svg};
use deltachat::qr_code_generator::{generate_backup_qr, get_securejoin_qr_svg};
use deltachat::reaction::{get_msg_reactions, send_reaction};
use deltachat::securejoin;
use deltachat::stock_str::StockMessage;
@@ -68,7 +68,6 @@ use self::types::{
},
};
use crate::api::types::chat_list::{get_chat_list_item_by_id, ChatListItemFetchResult};
use crate::api::types::login_param::TransportListEntry;
use crate::api::types::qr::{QrObject, SecurejoinSource, SecurejoinUiPath};
#[derive(Debug)]
@@ -195,16 +194,6 @@ impl CommandApi {
.context("event channel is closed")
}
/// Waits for at least one event and return a batch of events.
async fn get_next_event_batch(&self) -> Vec<Event> {
self.event_emitter
.recv_batch()
.await
.into_iter()
.map(|event| event.into())
.collect()
}
// ---------------------------------------------
// Account Management
// ---------------------------------------------
@@ -473,7 +462,9 @@ impl CommandApi {
let accounts = self.accounts.read().await;
for (stock_id, stock_message) in strings {
if let Some(stock_id) = StockMessage::from_u32(stock_id) {
accounts.set_stock_translation(stock_id, stock_message)?;
accounts
.set_stock_translation(stock_id, stock_message)
.await?;
}
}
Ok(())
@@ -527,7 +518,6 @@ impl CommandApi {
/// from a server encoded in a QR code.
/// - [Self::list_transports()] to get a list of all configured transports.
/// - [Self::delete_transport()] to remove a transport.
/// - [Self::set_transport_unpublished()] to set whether contacts see this transport.
async fn add_or_update_transport(
&self,
account_id: u32,
@@ -553,23 +543,7 @@ impl CommandApi {
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to delete a transport.
/// Use [Self::list_transports_ex()] to additionally query
/// whether the transports are marked as 'unpublished'.
async fn list_transports(&self, account_id: u32) -> Result<Vec<EnteredLoginParam>> {
let ctx = self.get_context(account_id).await?;
let res = ctx
.list_transports()
.await?
.into_iter()
.map(|t| t.param.into())
.collect();
Ok(res)
}
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to delete a transport.
async fn list_transports_ex(&self, account_id: u32) -> Result<Vec<TransportListEntry>> {
let ctx = self.get_context(account_id).await?;
let res = ctx
.list_transports()
@@ -587,26 +561,6 @@ impl CommandApi {
ctx.delete_transport(&addr).await
}
/// Change whether the transport is unpublished.
///
/// Unpublished transports are not advertised to contacts,
/// and self-sent messages are not sent there,
/// so that we don't cause extra messages to the corresponding inbox,
/// but can still receive messages from contacts who don't know our new transport addresses yet.
///
/// The default is false, but when the user updates from a version that didn't have this flag,
/// existing secondary transports are set to unpublished,
/// so that an existing transport address doesn't suddenly get spammed with a lot of messages.
async fn set_transport_unpublished(
&self,
account_id: u32,
addr: String,
unpublished: bool,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.set_transport_unpublished(&addr, unpublished).await
}
/// Signal an ongoing process to stop.
async fn stop_ongoing_process(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
@@ -735,6 +689,25 @@ impl CommandApi {
message::estimate_deletion_cnt(&ctx, from_server, seconds).await
}
// ---------------------------------------------
// autocrypt
// ---------------------------------------------
async fn initiate_autocrypt_key_transfer(&self, account_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
deltachat::imex::initiate_key_transfer(&ctx).await
}
async fn continue_autocrypt_key_transfer(
&self,
account_id: u32,
message_id: u32,
setup_code: String,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
deltachat::imex::continue_key_transfer(&ctx, MsgId::new(message_id), &setup_code).await
}
// ---------------------------------------------
// chat list
// ---------------------------------------------
@@ -881,8 +854,6 @@ impl CommandApi {
/// if `checkQr()` returns `askVerifyContact` or `askVerifyGroup`
/// an out-of-band-verification can be joined using `secure_join()`
///
/// @deprecated as of 2026-03; use create_qr_svg(get_chat_securejoin_qr_code()) instead.
///
/// chat_id: If set to a group-chat-id,
/// the Verified-Group-Invite protocol is offered in the QR code;
/// works for protected groups as well as for normal groups.
@@ -1097,8 +1068,7 @@ impl CommandApi {
/// Set group name.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
/// all group members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
async fn set_chat_name(&self, account_id: u32, chat_id: u32, new_name: String) -> Result<()> {
@@ -1106,39 +1076,10 @@ impl CommandApi {
chat::set_chat_name(&ctx, ChatId::new(chat_id), &new_name).await
}
/// Set group or broadcast channel description.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
///
/// See also [`Self::get_chat_description`] / `getChatDescription()`.
async fn set_chat_description(
&self,
account_id: u32,
chat_id: u32,
description: String,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
chat::set_chat_description(&ctx, ChatId::new(chat_id), &description).await
}
/// Load the chat description from the database.
///
/// UIs show this in the profile page of the chat,
/// it is settable by [`Self::set_chat_description`] / `setChatDescription()`.
async fn get_chat_description(&self, account_id: u32, chat_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
chat::get_chat_description(&ctx, ChatId::new(chat_id)).await
}
/// Set group profile image.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
/// all group members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
///
@@ -1249,15 +1190,6 @@ impl CommandApi {
marknoticed_chat(&ctx, ChatId::new(chat_id)).await
}
/// Marks the last incoming message in the chat as _fresh_.
///
/// UI can use this to offer a "mark unread" option,
/// so that already noticed chats get a badge counter again.
async fn markfresh_chat(&self, account_id: u32, chat_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
markfresh_chat(&ctx, ChatId::new(chat_id)).await
}
/// Returns the message that is immediately followed by the last seen
/// message.
/// From the point of view of the user this is effectively
@@ -2008,8 +1940,6 @@ impl CommandApi {
/// even if there is no concurrent call to [`CommandApi::provide_backup`],
/// but will fail after 60 seconds to avoid deadlocks.
///
/// @deprecated as of 2026-03; use `create_qr_svg(get_backup_qr())` instead.
///
/// Returns the QR code rendered as an SVG image.
async fn get_backup_qr_svg(&self, account_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
@@ -2023,11 +1953,6 @@ impl CommandApi {
generate_backup_qr(&ctx, &qr).await
}
/// Renders the given text as a QR code SVG image.
async fn create_qr_svg(&self, text: String) -> Result<String> {
create_qr_svg(&text)
}
/// Gets a backup from a remote provider.
///
/// This retrieves the backup from a remote device over the network and imports it into
@@ -2242,11 +2167,10 @@ impl CommandApi {
account_id: u32,
chat_id: u32,
place_call_info: String,
has_video: bool,
) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
let msg_id = ctx
.place_outgoing_call(ChatId::new(chat_id), place_call_info, has_video)
.place_outgoing_call(ChatId::new(chat_id), place_call_info)
.await?;
Ok(msg_id.to_u32())
}
@@ -2541,10 +2465,7 @@ impl CommandApi {
continue;
}
let sticker_name = sticker_entry.file_name().into_string().unwrap_or_default();
if sticker_name.ends_with(".png")
|| sticker_name.ends_with(".webp")
|| sticker_name.ends_with(".gif")
{
if sticker_name.ends_with(".png") || sticker_name.ends_with(".webp") {
sticker_paths.push(
sticker_entry
.path()

View File

@@ -1,6 +1,6 @@
use anyhow::{Context as _, Result};
use deltachat::calls::{call_state, CallState};
use deltachat::calls::{call_state, sdp_has_video, CallState};
use deltachat::context::Context;
use deltachat::message::MsgId;
use serde::Serialize;
@@ -15,7 +15,7 @@ pub struct JsonrpcCallInfo {
/// even if incoming call event was missed.
pub sdp_offer: String,
/// True if the call is started as a video call.
/// True if SDP offer has a video.
pub has_video: bool,
/// Call state.
@@ -30,7 +30,7 @@ impl JsonrpcCallInfo {
format!("Attempting to get call state of non-call message {msg_id}")
})?;
let sdp_offer = call_info.place_call_info.clone();
let has_video = call_info.has_video_initially();
let has_video = sdp_has_video(&sdp_offer).unwrap_or_default();
let state = JsonrpcCallState::from_msg_id(context, msg_id).await?;
Ok(JsonrpcCallInfo {

View File

@@ -441,8 +441,6 @@ pub enum EventType {
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
/// The call was accepted from this device (process).
from_this_device: bool,
},
/// Outgoing call accepted.
@@ -636,14 +634,9 @@ impl From<CoreEventType> for EventType {
place_call_info,
has_video,
},
CoreEventType::IncomingCallAccepted {
msg_id,
chat_id,
from_this_device,
} => IncomingCallAccepted {
CoreEventType::IncomingCallAccepted { msg_id, chat_id } => IncomingCallAccepted {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
from_this_device,
},
CoreEventType::OutgoingCallAccepted {
msg_id,

View File

@@ -4,16 +4,6 @@ use serde::Deserialize;
use serde::Serialize;
use yerpc::TypeDef;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct TransportListEntry {
/// The login data entered by the user.
pub param: EnteredLoginParam,
/// Whether this transport is set to 'unpublished'.
/// See `set_transport_unpublished` / `setTransportUnpublished` for details.
pub is_unpublished: bool,
}
/// Login parameters entered by the user.
///
/// Usually it will be enough to only set `addr` and `password`,
@@ -66,15 +56,6 @@ pub struct EnteredLoginParam {
pub oauth2: Option<bool>,
}
impl From<dc::TransportListEntry> for TransportListEntry {
fn from(transport: dc::TransportListEntry) -> Self {
TransportListEntry {
param: transport.param.into(),
is_unpublished: transport.is_unpublished,
}
}
}
impl From<dc::EnteredLoginParam> for EnteredLoginParam {
fn from(param: dc::EnteredLoginParam) -> Self {
let imap_security: Socket = param.imap.security.into();

View File

@@ -68,6 +68,7 @@ pub struct MessageObject {
/// if `show_padlock` is `false`,
/// and nothing if it is `true`.
show_padlock: bool,
is_setupmessage: bool,
is_info: bool,
is_forwarded: bool,
@@ -87,6 +88,8 @@ pub struct MessageObject {
override_sender_name: Option<String>,
sender: ContactObject,
setup_code_begin: Option<String>,
file: Option<String>,
file_mime: Option<String>,
@@ -223,6 +226,7 @@ impl MessageObject {
subject: message.get_subject().to_owned(),
show_padlock: message.get_showpadlock(),
is_setupmessage: message.is_setupmessage(),
is_info: message.is_info(),
is_forwarded: message.is_forwarded(),
is_bot: message.is_bot(),
@@ -239,6 +243,8 @@ impl MessageObject {
override_sender_name,
sender,
setup_code_begin: message.get_setupcodebegin(context).await,
file: match message.get_file(context) {
Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()),
None => None,
@@ -382,7 +388,6 @@ impl From<download::DownloadState> for DownloadState {
pub enum SystemMessageType {
Unknown,
GroupNameChanged,
GroupDescriptionChanged,
GroupImageChanged,
MemberAddedToGroup,
MemberRemovedFromGroup,
@@ -435,7 +440,6 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
match system_message_type {
SystemMessage::Unknown => SystemMessageType::Unknown,
SystemMessage::GroupNameChanged => SystemMessageType::GroupNameChanged,
SystemMessage::GroupDescriptionChanged => SystemMessageType::GroupDescriptionChanged,
SystemMessage::GroupImageChanged => SystemMessageType::GroupImageChanged,
SystemMessage::MemberAddedToGroup => SystemMessageType::MemberAddedToGroup,
SystemMessage::MemberRemovedFromGroup => SystemMessageType::MemberRemovedFromGroup,

View File

@@ -19,8 +19,6 @@ pub enum QrObject {
invitenumber: String,
/// Authentication code.
authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Ask the user whether to join the group.
AskVerifyGroup {
@@ -36,8 +34,6 @@ pub enum QrObject {
invitenumber: String,
/// Authentication code.
authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Ask the user whether to join the broadcast channel.
AskJoinBroadcast {
@@ -58,8 +54,6 @@ pub enum QrObject {
invitenumber: String,
/// Authentication code.
authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Contact fingerprint is verified.
///
@@ -235,7 +229,6 @@ impl From<Qr> for QrObject {
fingerprint,
invitenumber,
authcode,
is_v3,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
@@ -244,7 +237,6 @@ impl From<Qr> for QrObject {
fingerprint,
invitenumber,
authcode,
is_v3,
}
}
Qr::AskVerifyGroup {
@@ -254,7 +246,6 @@ impl From<Qr> for QrObject {
fingerprint,
invitenumber,
authcode,
is_v3,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
@@ -265,7 +256,6 @@ impl From<Qr> for QrObject {
fingerprint,
invitenumber,
authcode,
is_v3,
}
}
Qr::AskJoinBroadcast {
@@ -275,7 +265,6 @@ impl From<Qr> for QrObject {
fingerprint,
authcode,
invitenumber,
is_v3,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.to_string();
@@ -286,7 +275,6 @@ impl From<Qr> for QrObject {
fingerprint,
authcode,
invitenumber,
is_v3,
}
}
Qr::FprOk { contact_id } => {

View File

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

View File

@@ -40,32 +40,62 @@ export class BaseDeltaChat<
* and emitting the respective events on this class.
*/
startEventLoop: boolean,
options?: {
/**
* @see {@linkcode BaseDeltaChat.eventLoop}.
*
* Has no effect if {@linkcode startEventLoop} === false.
*/
eventLoopRequestPoolSize?: number;
},
) {
super();
this.rpc = new RawClient(this.transport);
if (startEventLoop) {
this.eventTask = this.eventLoop();
this.eventTask = this.eventLoop({
eventLoopRequestPoolSize: options?.eventLoopRequestPoolSize,
});
}
}
/**
* @see the constructor's `startEventLoop`
*/
async eventLoop(): Promise<void> {
while (true) {
for (const event of await this.rpc.getNextEventBatch()) {
//@ts-ignore
this.emit(event.event.kind, event.contextId, event.event);
this.emit("ALL", event.contextId, event.event);
async eventLoop(options?: {
/**
* How many {@linkcode RawClient.getNextEvent} to constantly keep open.
* Having a value > 1 improves performance
* when dealing with bursts of events.
*
* Must be >= 1.
*
* @default 20
*/
eventLoopRequestPoolSize?: number;
}): Promise<void> {
const promises: ReturnType<typeof this.rpc.getNextEvent>[] = [];
for (let i = 0; i < (options?.eventLoopRequestPoolSize ?? 20); i++) {
promises.push(this.rpc.getNextEvent());
}
const bufferLength = promises.length;
let currInd = 0;
if (this.contextEmitters[event.contextId]) {
this.contextEmitters[event.contextId].emit(
event.event.kind,
//@ts-ignore
event.event as any,
);
this.contextEmitters[event.contextId].emit("ALL", event.event as any);
}
while (true) {
const event = await promises[currInd];
promises[currInd] = this.rpc.getNextEvent();
currInd = (currInd + 1) % bufferLength;
//@ts-ignore
this.emit(event.event.kind, event.contextId, event.event);
this.emit("ALL", event.contextId, event.event);
if (this.contextEmitters[event.contextId]) {
this.contextEmitters[event.contextId].emit(
event.event.kind,
//@ts-ignore
event.event as any,
);
this.contextEmitters[event.contextId].emit("ALL", event.event as any);
}
}
}

View File

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

View File

@@ -302,6 +302,9 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
// TODO: reuse commands definition in main.rs.
"imex" => println!(
"====================Import/Export commands==\n\
initiate-key-transfer\n\
get-setupcodebegin <msg-id>\n\
continue-key-transfer <msg-id> <setup-code>\n\
has-backup\n\
export-backup\n\
import-backup <backup-file>\n\
@@ -340,7 +343,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
addmember <contact-id>\n\
removemember <contact-id>\n\
groupname <name>\n\
groupdescription <description>\n\
groupimage <image>\n\
chatinfo\n\
sendlocations <seconds>\n\
@@ -405,6 +407,34 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
============================================="
),
},
"initiate-key-transfer" => match initiate_key_transfer(&context).await {
Ok(setup_code) => {
println!("Setup code for the transferred setup message: {setup_code}",)
}
Err(err) => bail!("Failed to generate setup code: {err}"),
},
"get-setupcodebegin" => {
ensure!(!arg1.is_empty(), "Argument <msg-id> missing.");
let msg_id: MsgId = MsgId::new(arg1.parse()?);
let msg = Message::load_from_db(&context, msg_id).await?;
if msg.is_setupmessage() {
let setupcodebegin = msg.get_setupcodebegin(&context).await;
println!(
"The setup code for setup message {} starts with: {}",
msg_id,
setupcodebegin.unwrap_or_default(),
);
} else {
bail!("{msg_id} is no setup message.",);
}
}
"continue-key-transfer" => {
ensure!(
!arg1.is_empty() && !arg2.is_empty(),
"Arguments <msg-id> <setup-code> expected"
);
continue_key_transfer(&context, MsgId::new(arg1.parse()?), arg2).await?;
}
"has-backup" => {
has_backup(&context, blobdir).await?;
}
@@ -740,13 +770,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Chat name set");
}
"groupdescription" => {
ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "Argument <description> missing.");
chat::set_chat_description(&context, sel_chat.as_ref().unwrap().get_id(), arg1).await?;
println!("Chat description set");
}
"groupimage" => {
ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "Argument <image> missing.");

View File

@@ -149,7 +149,10 @@ impl Completer for DcHelper {
}
}
const IMEX_COMMANDS: [&str; 10] = [
const IMEX_COMMANDS: [&str; 13] = [
"initiate-key-transfer",
"get-setupcodebegin",
"continue-key-transfer",
"has-backup",
"export-backup",
"import-backup",
@@ -176,7 +179,7 @@ const DB_COMMANDS: [&str; 11] = [
"housekeeping",
];
const CHAT_COMMANDS: [&str; 40] = [
const CHAT_COMMANDS: [&str; 39] = [
"listchats",
"listarchived",
"start-realtime",
@@ -189,7 +192,6 @@ const CHAT_COMMANDS: [&str; 40] = [
"addmember",
"removemember",
"groupname",
"groupdescription",
"groupimage",
"chatinfo",
"sendlocations",

View File

@@ -2,9 +2,6 @@
RPC client connects to standalone Delta Chat RPC server `deltachat-rpc-server`
and provides asynchronous interface to it.
`rpc.start()` performs a health-check RPC call to verify the server
started successfully and will raise an error if startup fails
(e.g. if the accounts directory could not be used).
## Getting started

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import argparse
import functools
import os
import re
import sys
@@ -190,6 +189,9 @@ class futuremethod: # noqa: N801
self._func = func
def __get__(self, instance, owner=None):
if instance is None:
return self
def future(*args):
generator = self._func(instance, *args)
res = next(generator)
@@ -202,7 +204,6 @@ class futuremethod: # noqa: N801
return f
@functools.wraps(self._func)
def wrapper(*args):
f = future(*args)
return f()

View File

@@ -483,6 +483,10 @@ class Account:
passphrase = "" # Importing passphrase-protected keys is currently not supported.
self._rpc.import_self_keys(self.id, str(path), passphrase)
def initiate_autocrypt_key_transfer(self) -> None:
"""Send Autocrypt Setup Message."""
return self._rpc.initiate_autocrypt_key_transfer(self.id)
def ice_servers(self) -> list:
"""Return ICE servers for WebRTC configuration."""
ice_servers_json = self._rpc.ice_servers(self.id)

View File

@@ -219,10 +219,6 @@ class Chat:
"""Mark all messages in this chat as noticed."""
self._rpc.marknoticed_chat(self.account.id, self.id)
def mark_fresh(self) -> None:
"""Mark the last incoming message in the chat as fresh."""
self._rpc.markfresh_chat(self.account.id, self.id)
def add_contact(self, *contact: Union[int, str, Contact, "Account"]) -> None:
"""Add contacts to this group."""
from .account import Account
@@ -307,7 +303,7 @@ class Chat:
f.flush()
self._rpc.send_msg(self.account.id, self.id, {"viewtype": ViewType.VCARD, "file": f.name})
def place_outgoing_call(self, place_call_info: str, has_video_initially: bool) -> Message:
def place_outgoing_call(self, place_call_info: str) -> Message:
"""Starts an outgoing call."""
msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info, has_video_initially)
msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info)
return Message(self.account, msg_id)

View File

@@ -72,6 +72,14 @@ class Message:
"""Return True if the message exists."""
return bool(self._rpc.get_existing_msg_ids(self.account.id, [self.id]))
def continue_autocrypt_key_transfer(self, setup_code: str) -> None:
"""Continue the Autocrypt Setup Message key transfer.
This function can be called on received Autocrypt Setup Message
to import the key encrypted with the provided setup code.
"""
self._rpc.continue_autocrypt_key_transfer(self.account.id, self.id, setup_code)
def send_webxdc_status_update(self, update: Union[dict, str], description: str) -> None:
"""Send a webxdc status update. This message must be a webxdc."""
if not isinstance(update, str):

View File

@@ -54,13 +54,13 @@ class ACFactory:
def get_credentials(self) -> (str, str):
"""Generate new credentials for chatmail account."""
domain = os.environ["CHATMAIL_DOMAIN"]
domain = os.getenv("CHATMAIL_DOMAIN")
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
return f"{username}@{domain}", f"{username}${username}"
def get_account_qr(self):
"""Return "dcaccount:" QR code for testing chatmail relay."""
domain = os.environ["CHATMAIL_DOMAIN"]
domain = os.getenv("CHATMAIL_DOMAIN")
return f"dcaccount:{domain}"
@futuremethod

View File

@@ -54,12 +54,7 @@ class RpcMethod:
class Rpc:
"""RPC client."""
def __init__(
self,
accounts_dir: Optional[str] = None,
rpc_server_path="deltachat-rpc-server",
**kwargs,
):
def __init__(self, accounts_dir: Optional[str] = None, rpc_server_path="deltachat-rpc-server", **kwargs):
"""Initialize RPC client.
The 'kwargs' arguments will be passed to subprocess.Popen().
@@ -84,15 +79,8 @@ class Rpc:
self.events_thread: Thread
def start(self) -> None:
"""Start RPC server subprocess and wait for successful initialization.
This method blocks until the RPC server responds to an initial
health-check RPC call (get_system_info).
If the server fails to start
(e.g., due to an invalid accounts directory),
a JsonRpcError is raised.
"""
popen_kwargs = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE}
"""Start RPC server subprocess."""
popen_kwargs = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE}
if sys.version_info >= (3, 11):
# Prevent subprocess from capturing SIGINT.
popen_kwargs["process_group"] = 0
@@ -102,7 +90,6 @@ class Rpc:
popen_kwargs.update(self._kwargs)
self.process = subprocess.Popen(self.rpc_server_path, **popen_kwargs)
self.id_iterator = itertools.count(start=1)
self.event_queues = {}
self.request_results = {}
@@ -115,22 +102,6 @@ class Rpc:
self.events_thread = Thread(target=self.events_loop)
self.events_thread.start()
# Perform a health-check RPC call to ensure the server started
# successfully and the accounts directory is usable.
try:
system_info = self.get_system_info()
except (JsonRpcError, Exception) as e:
# The reader_loop already saw EOF on stdout, so the process
# has exited and stderr is available.
stderr = self.process.stderr.read().decode(errors="replace").strip()
if stderr:
raise JsonRpcError(f"RPC server failed to start: {stderr}") from e
raise JsonRpcError(f"RPC server startup check failed: {e}") from e
logging.info(
"RPC server ready. Core version: %s",
system_info.get("deltachat_core_version", "unknown"),
)
def close(self) -> None:
"""Terminate RPC server process and wait until the reader loop finishes."""
self.closing = True
@@ -161,10 +132,6 @@ class Rpc:
except Exception:
# Log an exception if the reader loop dies.
logging.exception("Exception in the reader loop")
finally:
# Unblock any pending requests when the server closes stdout.
for _request_id, queue in self.request_results.items():
queue.put({"error": {"code": -32000, "message": "RPC server closed"}})
def writer_loop(self) -> None:
"""Writer loop ensuring only a single thread writes requests."""
@@ -173,6 +140,7 @@ class Rpc:
data = (json.dumps(request) + "\n").encode()
self.process.stdin.write(data)
self.process.stdin.flush()
except Exception:
# Log an exception if the writer loop dies.
logging.exception("Exception in the writer loop")
@@ -186,15 +154,15 @@ class Rpc:
def events_loop(self) -> None:
"""Request new events and distributes them between queues."""
try:
while events := self.get_next_event_batch():
for event in events:
account_id = event["contextId"]
queue = self.get_queue(account_id)
payload = event["event"]
logging.debug("account_id=%d got an event %s", account_id, payload)
queue.put(payload)
while True:
if self.closing:
return
event = self.get_next_event()
account_id = event["contextId"]
queue = self.get_queue(account_id)
event = event["event"]
logging.debug("account_id=%d got an event %s", account_id, event)
queue.put(event)
except Exception:
# Log an exception if the event loop dies.
logging.exception("Exception in the event loop")

View File

@@ -10,15 +10,15 @@ def test_calls(acfactory) -> None:
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info, has_video_initially=True)
outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info)
assert outgoing_call_message.get_call_info().state.kind == "Alerting"
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
assert incoming_call_event.place_call_info == place_call_info
assert incoming_call_event.has_video
assert not incoming_call_event.has_video # Cannot be parsed as SDP, so false by default
incoming_call_message = Message(bob, incoming_call_event.msg_id)
assert incoming_call_message.get_call_info().state.kind == "Alerting"
assert incoming_call_message.get_call_info().has_video
assert not incoming_call_message.get_call_info().has_video
incoming_call_message.accept_incoming_call(accept_call_info)
assert incoming_call_message.get_call_info().sdp_offer == place_call_info
@@ -41,38 +41,46 @@ def test_video_call(acfactory) -> None:
#
# `s=` cannot be empty according to RFC 3264,
# so it is more clear as `s=-`.
place_call_info = """v=0\r
o=alice 2890844526 2890844526 IN IP6 2001:db8::3\r
s=-\r
c=IN IP6 2001:db8::3\r
t=0 0\r
a=group:BUNDLE foo bar\r
\r
m=audio 10000 RTP/AVP 0 8 97\r
b=AS:200\r
a=mid:foo\r
a=rtcp-mux\r
a=rtpmap:0 PCMU/8000\r
a=rtpmap:8 PCMA/8000\r
a=rtpmap:97 iLBC/8000\r
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r
\r
m=video 10002 RTP/AVP 31 32\r
b=AS:1000\r
a=mid:bar\r
a=rtcp-mux\r
a=rtpmap:31 H261/90000\r
a=rtpmap:32 MPV/90000\r
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r
"""
alice, bob = acfactory.get_online_accounts(2)
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
alice_chat_bob.place_outgoing_call(place_call_info)
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
assert incoming_call_event.place_call_info == "offer"
assert incoming_call_event.place_call_info == place_call_info
assert incoming_call_event.has_video
incoming_call_message = Message(bob, incoming_call_event.msg_id)
assert incoming_call_message.get_call_info().has_video
def test_audio_call(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.place_outgoing_call("offer", has_video_initially=False)
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
assert incoming_call_event.place_call_info == "offer"
assert not incoming_call_event.has_video
incoming_call_message = Message(bob, incoming_call_event.msg_id)
assert not incoming_call_message.get_call_info().has_video
def test_ice_servers(acfactory) -> None:
alice = acfactory.get_online_account()
@@ -84,7 +92,7 @@ def test_no_contact_request_call(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
alice_chat_bob.place_outgoing_call("offer")
alice_chat_bob.send_text("Hello!")
# Notification for "Hello!" message should arrive
@@ -111,7 +119,7 @@ def test_who_can_call_me_nobody(acfactory) -> None:
bob.create_chat(alice)
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
alice_chat_bob.place_outgoing_call("offer")
alice_chat_bob.send_text("Hello!")
# Notification for "Hello!" message should arrive
@@ -136,7 +144,7 @@ def test_who_can_call_me_everybody(acfactory) -> None:
bob.set_config("who_can_call_me", "0")
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
alice_chat_bob.place_outgoing_call("offer")
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
incoming_call_message = Message(bob, incoming_call_event.msg_id)

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
import pytest
from deltachat_rpc_client import EventType
from deltachat_rpc_client.rpc import JsonRpcError
def wait_for_autocrypt_setup_message(account):
while True:
event = account.wait_for_event()
if event.kind == EventType.MSGS_CHANGED and event.msg_id != 0:
msg_id = event.msg_id
msg = account.get_message_by_id(msg_id)
if msg.get_snapshot().is_setupmessage:
return msg
def test_autocrypt_setup_message_key_transfer(acfactory):
alice1 = acfactory.get_online_account()
alice2 = acfactory.get_unconfigured_account()
alice2.add_or_update_transport({"addr": alice1.get_config("addr"), "password": alice1.get_config("mail_pw")})
alice2.bring_online()
setup_code = alice1.initiate_autocrypt_key_transfer()
msg = wait_for_autocrypt_setup_message(alice2)
# Test that entering wrong code returns an error.
with pytest.raises(JsonRpcError):
msg.continue_autocrypt_key_transfer("7037-0673-6287-3013-4095-7956-5617-6806-6756")
msg.continue_autocrypt_key_transfer(setup_code)
def test_ac_setup_message_twice(acfactory):
alice1 = acfactory.get_online_account()
alice2 = acfactory.get_unconfigured_account()
alice2.add_or_update_transport({"addr": alice1.get_config("addr"), "password": alice1.get_config("mail_pw")})
alice2.bring_online()
# Send the first Autocrypt Setup Message and ignore it.
_setup_code = alice1.initiate_autocrypt_key_transfer()
wait_for_autocrypt_setup_message(alice2)
# Send the second Autocrypt Setup Message and import it.
setup_code = alice1.initiate_autocrypt_key_transfer()
msg = wait_for_autocrypt_setup_message(alice2)
msg.continue_autocrypt_key_transfer(setup_code)

View File

@@ -1,7 +1,6 @@
import pytest
from deltachat_rpc_client import EventType
from deltachat_rpc_client.const import ChatType, DownloadState
from deltachat_rpc_client.rpc import JsonRpcError
@@ -38,8 +37,8 @@ def test_add_second_address(acfactory) -> None:
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")
with pytest.raises(JsonRpcError):
account.set_config("show_emails", "0")
@pytest.mark.parametrize("key", ["mvbox_move", "only_fetch_mvbox"])
@@ -58,8 +57,8 @@ def test_no_second_transport_with_mvbox(acfactory, key) -> None:
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."""
def test_no_second_transport_without_classic_emails(acfactory) -> None:
"""Test that second transport cannot be configured if classic emails are not fetched."""
account = acfactory.new_configured_account()
assert len(account.list_transports()) == 1
@@ -68,7 +67,8 @@ def test_second_transport_without_classic_emails(acfactory) -> None:
qr = acfactory.get_account_qr()
account.set_config("show_emails", "0")
account.add_transport_from_qr(qr)
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
def test_change_address(acfactory) -> None:
@@ -120,33 +120,6 @@ def test_change_address(acfactory) -> None:
assert sender_addr2 == new_alice_addr
def test_download_on_demand(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
alice.set_config("download_limit", "1")
alice.stop_io()
qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr)
alice.start_io()
alice.create_chat(bob)
chat_bob_alice = bob.create_chat(alice)
chat_bob_alice.send_message(file="../test-data/image/screenshot.jpg")
msg = alice.wait_for_incoming_msg()
snapshot = msg.get_snapshot()
assert snapshot.download_state == DownloadState.AVAILABLE
chat_id = snapshot.chat_id
# Actually the message isn't available yet. Wait somehow for the post-message to arrive.
chat_bob_alice.send_message("Now you can download my previous message")
alice.wait_for_incoming_msg()
alice._rpc.download_full_message(alice.id, msg.id)
for dstate in [DownloadState.IN_PROGRESS, DownloadState.DONE]:
event = alice.wait_for_event(EventType.MSGS_CHANGED)
assert event.chat_id == chat_id
assert event.msg_id == msg.id
assert msg.get_snapshot().download_state == dstate
@pytest.mark.parametrize("is_chatmail", ["0", "1"])
def test_mvbox_move_first_transport(acfactory, is_chatmail) -> None:
"""Test that mvbox_move is disabled by default even for non-chatmail accounts.
@@ -188,13 +161,6 @@ def test_reconfigure_transport(acfactory) -> None:
def test_transport_synchronization(acfactory, log) -> None:
"""Test synchronization of transports between devices."""
def wait_for_io_started(ac):
while True:
ev = ac.wait_for_event(EventType.INFO)
if "scheduler is running" in ev.msg:
return
ac1, ac2 = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone()
ac1_clone.bring_online()
@@ -203,13 +169,11 @@ def test_transport_synchronization(acfactory, log) -> None:
ac1.add_transport_from_qr(qr)
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1_clone)
assert len(ac1.list_transports()) == 2
assert len(ac1_clone.list_transports()) == 2
ac1_clone.add_transport_from_qr(qr)
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1)
assert len(ac1.list_transports()) == 3
assert len(ac1_clone.list_transports()) == 3
@@ -219,15 +183,11 @@ def test_transport_synchronization(acfactory, log) -> None:
ac1_clone.delete_transport(transport2["addr"])
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1)
[transport1, transport3] = ac1.list_transports()
log.section("ac1 changes the primary transport")
ac1.set_config("configured_addr", transport3["addr"])
# One event for updated `add_timestamp` of the new primary transport,
# one event for the `configured_addr` update.
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
[transport1, transport3] = ac1_clone.list_transports()
assert ac1_clone.get_config("configured_addr") == addr3
@@ -236,7 +196,6 @@ def test_transport_synchronization(acfactory, log) -> None:
ac1.delete_transport(transport1["addr"])
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1_clone)
[transport3] = ac1_clone.list_transports()
assert transport3["addr"] == addr3
assert ac1_clone.get_config("configured_addr") == addr3
@@ -318,10 +277,11 @@ def test_transport_limit(acfactory) -> None:
account.add_transport_from_qr(qr)
def test_message_info_imap_urls(acfactory) -> None:
def test_message_info_imap_urls(acfactory, log) -> None:
"""Test that message info contains IMAP URLs of where the message was received."""
alice, bob = acfactory.get_online_accounts(2)
log.section("Alice adds ac1 clone removes second transport")
qr = acfactory.get_account_qr()
for i in range(3):
alice.add_transport_from_qr(qr)
@@ -329,6 +289,9 @@ def test_message_info_imap_urls(acfactory) -> None:
for _ in range(i + 1):
alice.bring_online()
new_alice_addr = alice.list_transports()[2]["addr"]
alice.set_config("configured_addr", new_alice_addr)
# Enable multi-device mode so messages are not deleted immediately.
alice.set_config("bcc_self", "1")
@@ -336,53 +299,12 @@ def test_message_info_imap_urls(acfactory) -> None:
# This is where he will send the message.
bob_chat = bob.create_chat(alice)
# Alice switches to another transport and removes the rest of the transports.
new_alice_addr = alice.list_transports()[1]["addr"]
alice.set_config("configured_addr", new_alice_addr)
removed_addrs = []
for transport in alice.list_transports():
if transport["addr"] != new_alice_addr:
alice.delete_transport(transport["addr"])
removed_addrs.append(transport["addr"])
alice.stop_io()
alice.start_io()
# Alice changes the transport again.
alice.set_config("configured_addr", alice.list_transports()[3]["addr"])
bob_chat.send_text("Hello!")
msg = alice.wait_for_incoming_msg()
msg_info = msg.get_info()
assert new_alice_addr in msg_info
for removed_addr in removed_addrs:
assert removed_addr not in msg_info
assert f"{new_alice_addr}/INBOX" in msg_info
def test_remove_primary_transport(acfactory, log) -> None:
"""Test that after removing the primary relay, Alice can still receive messages."""
alice, bob = acfactory.get_online_accounts(2)
qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr)
alice.bring_online()
bob_chat = bob.create_chat(alice)
alice.create_chat(bob)
log.section("Alice sets up second transport")
[transport1, transport2] = alice.list_transports()
alice.set_config("configured_addr", transport2["addr"])
bob_chat.send_text("Hello!")
msg1 = alice.wait_for_incoming_msg().get_snapshot()
assert msg1.text == "Hello!"
log.section("Alice removes the primary relay")
alice.delete_transport(transport1["addr"])
alice.stop_io()
alice.start_io()
bob_chat.send_text("Hello again!")
msg2 = alice.wait_for_incoming_msg().get_snapshot()
assert msg2.text == "Hello again!"
assert msg2.chat.get_basic_snapshot().chat_type == ChatType.SINGLE
assert msg2.chat == alice.create_chat(bob)
for alice_transport in alice.list_transports():
addr = alice_transport["addr"]
assert (addr == new_alice_addr) == (addr in msg.get_info())

View File

@@ -167,16 +167,11 @@ def test_qr_securejoin_broadcast(acfactory, all_devices_online):
assert "invited you to join this channel" in first_msg.text
assert first_msg.is_info
member_added_msg = chat_msgs.pop(0).get_snapshot()
if inviter_side:
member_added_msg = chat_msgs.pop(0).get_snapshot()
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
assert member_added_msg.info_contact_id == contact_snapshot.id
else:
if chat_msgs[0].get_snapshot().text == "You joined the channel.":
member_added_msg = chat_msgs.pop(0).get_snapshot()
else:
member_added_msg = chat_msgs.pop(1).get_snapshot()
assert member_added_msg.text == "You joined the channel."
assert member_added_msg.text == "You joined the channel."
assert member_added_msg.is_info
hello_msg = chat_msgs.pop(0).get_snapshot()

View File

@@ -13,7 +13,7 @@ import pytest
from deltachat_rpc_client import EventType, events
from deltachat_rpc_client.const import DownloadState, MessageState
from deltachat_rpc_client.pytestplugin import E2EE_INFO_MSGS
from deltachat_rpc_client.rpc import JsonRpcError, Rpc
from deltachat_rpc_client.rpc import JsonRpcError
def test_system_info(rpc) -> None:
@@ -273,9 +273,6 @@ def test_chat(acfactory) -> None:
assert group.get_messages()
group.get_fresh_message_count()
group.mark_noticed()
assert group.get_fresh_message_count() == 0
group.mark_fresh()
assert group.get_fresh_message_count() > 0
assert group.get_contacts()
assert group.get_past_contacts() == []
group.remove_contact(alice_contact_bob)
@@ -442,35 +439,6 @@ def test_reaction_seen_on_another_dev(acfactory) -> None:
assert chat_id == alice2_chat_bob.id
def test_2nd_device_events_when_msgs_are_seen(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
alice2 = alice.clone()
alice2.start_io()
# Get an accepted chat, otherwise alice2 won't be notified about the 2nd message.
chat_alice2 = alice2.create_chat(bob)
chat_id_alice2 = chat_alice2.get_basic_snapshot().id
chat_bob_alice = bob.create_chat(alice)
chat_bob_alice.send_text("Hello!")
msg_alice = alice.wait_for_incoming_msg()
assert alice2.wait_for_incoming_msg_event().chat_id == chat_id_alice2
chat_bob_alice.send_text("What's new?")
assert alice2.wait_for_incoming_msg_event().chat_id == chat_id_alice2
chat_alice2 = alice2.get_chat_by_id(chat_id_alice2)
assert chat_alice2.get_fresh_message_count() == 2
msg_alice.mark_seen()
assert alice2.wait_for_msgs_changed_event().chat_id == chat_id_alice2
assert chat_alice2.get_fresh_message_count() == 1
msg_id = alice.wait_for_msgs_changed_event().msg_id
msg = alice.get_message_by_id(msg_id)
msg.mark_seen()
assert alice2.wait_for_event(EventType.MSGS_NOTICED).chat_id == chat_id_alice2
assert chat_alice2.get_fresh_message_count() == 0
def test_is_bot(acfactory) -> None:
"""Test that we can recognize messages submitted by bots."""
alice, bob = acfactory.get_online_accounts(2)
@@ -697,24 +665,6 @@ def test_openrpc_command_line() -> None:
assert "methods" in openrpc
def test_early_failure(tmp_path) -> None:
"""Test that Rpc.start() raises on invalid accounts directories."""
# A file instead of a directory.
file_path = tmp_path / "not_a_dir"
file_path.write_text("I am a file, not a directory")
rpc = Rpc(accounts_dir=str(file_path))
with pytest.raises(JsonRpcError, match="(?i)directory"):
rpc.start()
# A non-empty directory that is not a deltachat accounts directory.
non_dc_dir = tmp_path / "invalid_dir"
non_dc_dir.mkdir()
(non_dc_dir / "some_file").write_text("content")
rpc = Rpc(accounts_dir=str(non_dc_dir))
with pytest.raises(JsonRpcError, match="invalid_dir"):
rpc.start()
def test_provider_info(rpc) -> None:
account_id = rpc.add_account()
@@ -1076,7 +1026,6 @@ def test_no_old_msg_is_fresh(acfactory):
assert ac1.create_chat(ac2).get_fresh_message_count() == 1
assert len(list(ac1.get_fresh_messages())) == 1
ac1_clone.wait_for_incoming_msg_event()
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
logging.info("Send a message from ac1_clone to ac2 and check that ac1 marks the first message as 'noticed'")

View File

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

View File

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

View File

@@ -18,12 +18,10 @@ ignore = [
# this should be fixed by upgrading to iroh 1.0 once it is released.
"RUSTSEC-2025-0134",
# rustls-webpki v0.102.8
# We cannot upgrade to >=0.103.10 because
# it is a transitive dependency of iroh 0.35.0
# which depends on ^0.102.
# <https://rustsec.org/advisories/RUSTSEC-2026-0049>
"RUSTSEC-2026-0049",
# Old versions of "lru" are transitive dependencies of iroh 0.35.0.
# <https://rustsec.org/advisories/RUSTSEC-2026-0002>
# <https://github.com/chatmail/core/issues/7692>
"RUSTSEC-2026-0002",
]
[bans]
@@ -34,7 +32,6 @@ ignore = [
skip = [
{ name = "async-channel", version = "1.9.0" },
{ name = "bitflags", version = "1.3.2" },
{ name = "constant_time_eq", version = "0.3.1" },
{ name = "derive_more-impl", version = "1.0.0" },
{ name = "derive_more", version = "1.0.0" },
{ name = "event-listener", version = "2.5.3" },
@@ -49,7 +46,6 @@ skip = [
{ name = "rand_core", version = "0.6.4" },
{ name = "rand", version = "0.8.5" },
{ name = "rustix", version = "0.38.44" },
{ name = "rustls-webpki", version = "0.102.8" },
{ name = "serdect", version = "0.2.0" },
{ name = "socket2", version = "0.5.9" },
{ name = "spin", version = "0.9.8" },

127
draft/aeap-mvp.md Normal file
View File

@@ -0,0 +1,127 @@
AEAP MVP
========
Changes to the UIs
------------------
- The secondary self addresses (see below) are shown in the UI, but not editable.
- When the user changed the email address in the configure screen, show a dialog to the user, either directly explaining things or with a link to the FAQ (see "Other" below)
Changes in the core
-------------------
- [x] We have one primary self address and any number of secondary self addresses. `is_self_addr()` checks all of them.
- [x] If the user does a reconfigure and changes the email address, the previous address is added as a secondary self address.
- don't forget to deduplicate secondary self addresses in case the user switches back and forth between addresses).
- The key stays the same.
- [x] No changes for 1:1 chats, there simply is a new one. (This works since, contrary to group messages, messages sent to a 1:1 chat are not assigned to the group chat but always to the 1:1 chat with the sender. So it's not a problem that the new messages might be put into the old chat if they are a reply to a message there.)
- [ ] When sending a message: If any of the secondary self addrs is in the chat's member list, remove it locally (because we just transitioned away from it). We add a log message for this (alternatively, a system message in the chat would be more visible).
- [x] ([#3385](https://github.com/deltachat/deltachat-core-rust/pull/3385)) When receiving a message: If the key exists, but belongs to another address (we may want to benchmark this)
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 <sup>[[1]](#myfootnote1)</sup>\
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.
- Note that we can't simply compare the keys byte-by-byte, since the UID may have changed, or the sender may have rotated the key and signed the new key with the old one.
<a name="myfootnote1">[1]</a>: Without this check, an attacker could replay a message from Alice to Bob. Then Bob's device would do an AEAP transition from Alice's to the attacker's address, allowing for easier phishing.
<details>
<summary>More details about this</summary>
Suppose Alice sends a message to Evil (or to a group with both Evil and Bob). Evil then forwards the message to Bob, changing the From and To headers (and if necessary Message-Id) and replacing `addr=alice@example.org;` in the autocrypt header with `addr=evil@example.org;`.
Then Bob's device sees that there is a message which is signed by Alice's key and comes from Evil's address and would do the AEAP transition, i.e. replace Alice with Evil in all groups and show a message "Alice changed their address from alice@example.org to evil@example.org". Disadvantages for Evil are that Bob's message will be shown on Alice's device, possibly creating confusion/suspicion, and that the usual "Setup changed for..." message will be shown the next time Evil sends a message (because Evil doesn't know Alice's private key).
Possible mitigations:
- if we make the AEAP device message sth. like "Automatically removed alice@example.org and added evil@example.org", then this will create more suspicion, making the phishing harder (we didn't talk about what what the wording should be at all yet).
- Add something similar to replay protection to our Autocrypt implementation. This could be done e.g. by adding a second `From` header to the protected headers. If it's present, the receiver then requires it to be the same as the outer `From`, and if it's not present, we don't do AEAP --> **That's what we implemented**
Note that usually a mail is signed by a key that has a UID matching the from address.
That's not mandatory for Autocrypt (and in fact, we just keep the old UID when changing the self address, so with AEAP the UID will actually be different than the from address sometimes)
https://autocrypt.org/level1.html#openpgp-based-key-data says:
> The content of the user id packet is only decorative
</details>
### Notes:
- We treat protected and non-protected chats the same
- We leave the aeap transition statement away since it seems not to be needed, makes things harder on the sending side, wastes some network traffic, and is worse for privacy (since more people know what old addresses you had).
- As soon as we encrypt read receipts, sending a read receipt will be enough to tell a lot of people that you transitioned
- AEAP will make the problem of inconsistent group state worse, both because it doesn't work if the message is unencrypted (even if the design allowed it, it would be problematic security-wise) and because some chat partners may have gotten the transition and some not. We should do something against this at some point in the future, like asking the user whether they want to add/remove the members to restore consistent group state.
#### Downsides of this design:
- Inconsistent group state: Suppose Alice does an AEAP transition and sends a 1:1 message to Bob, so Bob rewrites Alice's contact. Alice, Bob and Charlie are together in a group. Before Alice writes to this group, Bob and Charlie will have different membership lists, and Bob will send messages to Alice's new address, while Charlie will send them to her old address.
#### Upsides:
- With this approach, it's easy to switch to a model where the info about the transition is encoded in the PGP key. Since the key is gossiped, the information about the transition will spread virally.
- Faster transition: If you send a message to e.g. "Delta Chat Dev", all members of the "sub-group" "delta android" will know of your transition.
### Alternatives and old discussions/plans:
- Change the contact instead of rewriting the group member lists. This seems to call for more trouble since we will end up with multiple contacts having the same email address.
- If needed, we could add a header a) indicating that the sender did an address transition or b) listing all the secondary (old) addresses. For now, there is no big enough benefit to warrant introducing another header and its processing on the receiver side (including all the necessary checks and handling of error cases). Instead, we only check for the `Chat-Version` header to prevent accidental transitions when an MUA user sends a message from another email address with the same key.
- The condition for a transition temporarily was:
> When receiving a message: If we are going to assign a message to a chat, but the sender is not a member of this chat\
> AND the signing key is the same as the direct (non-gossiped) key of one of the chat members\
> AND ...
However, this would mean that in 1:1 messages can't trigger a transition, since we don't assign private messages to the parent chat, but always to the 1:1 chat with the sender.
<details>
<summary>Some previous state of the discussion, which temporarily lived in an issue description</summary>
Summarizing the discussions from https://github.com/deltachat/deltachat-core-rust/pull/2896, mostly quoting @hpk42:
1. (DONE) At the time of configure we push the current primary to become a secondary.
2. When a message is sent out to a chat, and the message is encrypted, and we have secondary addresses, then we
a) add a protected "AEAP-Replacement" header that contains all secondary addresses
b) if any of the secondary addresses is in the chat's member list, we remove it and leave a system message that we did so
3. When an encrypted message with a replacement header is received, replace the e-mail address of all secondary contacts (if they exist) with the new primary and drop a sysmessage in all chats the secondary is member off. This might (in edge cases) result in chats that have two or more contacts with the same e-mail address. We might ignore this for a first release and just log a warning. Let's maybe not get hung up on this case before everything else works.
Notes:
- for now we will send out aeap replacement headers forever, there is no termination condition other than lack of secondary addresses. I think that's fine for now. Later on we might introduce options to remove secondary addresses but i wouldn't do this for a first release/PR.
- the design is resilient against changing e-mail providers from A to B to C and then back to A, with partially updated chats and diverging views from recipients/contacts on this transition. In the end, you will have a primary and some secondaries, and when you start sending out messages everybody will eventually synchronize when they receive the current state of primaries/secondaries.
- of course on incoming message for need to check for each stated secondary address in the replacement header that it uses the same signature as the signature we verified as valid with the incoming message **--> Also we have to somehow make sure that the signing key was not just gossiped from some random other person in some group.**
- there are no extra flags/columns in the database needed (i hope)
#### Downsides of the chosen approach:
- Inconsistent group state: Suppose Alice does an AEAP transition and sends a 1:1 message to Bob, so Bob rewrites Alice's contact. Alice, Bob and Charlie are together in a group. Before Alice writes to this group, Bob and Charlie will have different membership lists, and Bob will send messages to Alice's new address, while Charlie will send them to her old address.
- There will be multiple contacts with the same address in the database. We will have to do something against this at some point.
The most obvious alternative would be to create a new contact with the new address and replace the old contact in the groups.
#### Upsides:
- With this approach, it's easier to switch to a model where the info about the transition is encoded in the PGP key. Since the key is gossiped, the information about the transition will spread virally.
- (Also, less important: Slightly faster transition: If you send a message to e.g. "Delta Chat Dev", all members of the "sub-group" "delta android" will know of your transition.)
- It's easier to implement (if too many problems turn up, we can still switch to another approach and didn't waste that much development time.)
[full messages](https://github.com/deltachat/deltachat-core-rust/pull/2896#discussion_r852002161)
_end of the previous state of the discussion_
</details>
Other
-----
- The user is responsible that messages to the old address arrive at the new address, for example by configuring the old provider to forward all emails to the new one.
Notes during implementing
========================
- As far as I understand the code, unencrypted messages are unsigned. So, the transition only works if both sides have the other side's key.

View File

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

View File

@@ -553,6 +553,17 @@ class Account:
def imex(self, path: str, imex_cmd: int, passphrase: Optional[str] = None) -> None:
lib.dc_imex(self._dc_context, imex_cmd, as_dc_charpointer(path), as_dc_charpointer(passphrase))
def initiate_key_transfer(self) -> str:
"""return setup code after a Autocrypt setup message
has been successfully sent to our own e-mail address ("self-sent message").
If sending out was unsuccessful, a RuntimeError is raised.
"""
self.check_is_configured()
res = lib.dc_initiate_key_transfer(self._dc_context)
if res == ffi.NULL:
raise RuntimeError("could not send out autocrypt setup message")
return from_dc_charpointer(res)
def get_setup_contact_qr(self) -> str:
"""get/create Setup-Contact QR Code as ascii-string.

View File

@@ -167,6 +167,14 @@ class Message:
"""return True if this message is a system/info message."""
return bool(lib.dc_msg_is_info(self._dc_msg))
def is_setup_message(self):
"""return True if this message is a setup message."""
return lib.dc_msg_is_setupmessage(self._dc_msg)
def get_setupcodebegin(self) -> str:
"""return the first characters of a setup code in a setup message."""
return from_dc_charpointer(lib.dc_msg_get_setupcodebegin(self._dc_msg))
def is_encrypted(self):
"""return True if this message was encrypted."""
return bool(lib.dc_msg_get_showpadlock(self._dc_msg))
@@ -190,6 +198,12 @@ class Message:
"""Get a message summary as a single line of text. Typically used for notifications."""
return from_dc_charpointer(lib.dc_msg_get_summarytext(self._dc_msg, width))
def continue_key_transfer(self, setup_code):
"""extract key and use it as primary key for this account."""
res = lib.dc_continue_key_transfer(self.account._dc_context, self.id, as_dc_charpointer(setup_code))
if res == 0:
raise ValueError("Importing the key from Autocrypt Setup Message failed")
@props.with_doc
def time_sent(self):
"""UTC time when the message was sent.

View File

@@ -932,6 +932,7 @@ def test_set_get_group_image(acfactory, data, lp):
def test_connectivity(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1._evtracker.wait_for_connectivity(dc.const.DC_CONNECTIVITY_CONNECTED)

View File

@@ -558,12 +558,6 @@ class TestOfflineChat:
assert messages[0 + E2EE_INFO_MSGS].text == "msg1"
assert os.path.exists(messages[1 + E2EE_INFO_MSGS].filename)
@pytest.mark.skip(
reason="We didn't find a way to correctly reset an account after a failed import attempt "
"while simultaneously making sure "
"that the password of an encrypted account survives a failed import attempt. "
"Since passphrases are not really supported anymore, we decided to just disable the test.",
)
def test_import_encrypted_bak_into_encrypted_acct(self, acfactory, tmp_path):
"""
Test that account passphrase isn't lost if backup failed to be imported.

View File

@@ -111,7 +111,7 @@ def test_dc_close_events(acfactory):
register_global_plugin(ShutdownPlugin())
assert hasattr(ac1, "_dc_context")
ac1.shutdown()
shutdowns.get()
shutdowns.get(timeout=2)
def test_wrong_db(tmp_path):
@@ -221,7 +221,7 @@ def test_logged_ac_process_ffi_failure(acfactory):
# cause any event eg contact added/changed
ac1.create_contact("something@example.org")
res = cap.get()
res = cap.get(timeout=10)
assert "ac_process_ffi_event" in res
assert "ZeroDivisionError" in res
assert "Traceback" in res

View File

@@ -1 +1 @@
2026-03-30
2026-01-23

View File

@@ -7,7 +7,7 @@ set -euo pipefail
#
# Avoid using rustup here as it depends on reading /proc/self/exe and
# has problems running under QEMU.
RUST_VERSION=1.94.0
RUST_VERSION=1.93.0
ARCH="$(uname -m)"
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu

View File

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

View File

@@ -100,7 +100,7 @@ def main():
today = datetime.date.today().isoformat()
if not newversion.endswith("-dev"):
if "alpha" not in newversion:
found = False
for line in Path("CHANGELOG.md").open():
if line == f"## [{newversion}] - {today}\n":

View File

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

21
spec.md
View File

@@ -39,24 +39,9 @@ Messages SHOULD be encrypted by the
[Autocrypt](https://autocrypt.org/level1.html) standard;
`prefer-encrypt=mutual` MAY be set by default.
Meta data SHOULD be encrypted
by the [Header Protection](https://www.rfc-editor.org/rfc/rfc9788.html) standard
with the following [Header Confidentiality Policy](https://www.rfc-editor.org/rfc/rfc9788.html#name-header-confidentiality-poli):
```
hcp_chat(name, val_in) → val_out:
if lower(name) is 'from':
assert that val_in is an RFC 5322 mailbox
return the RFC 5322 addr-spec part of val_in
else if lower(name) is 'to':
return '"hidden-recipients": ;'
else if lower(name) is 'date':
return the UTC form of a random date within the last 7 days
else if lower(name) is 'subject':
return '[...]'
else if lower(name) is in ['message-id', 'chat-is-post-message']:
return val_in
return null
```
Meta data (at least the subject and all chat-headers) SHOULD be encrypted
by the [Protected Headers](https://tools.ietf.org/id/draft-autocrypt-lamps-protected-headers-02.html) standard.
# Outgoing messages

View File

@@ -57,8 +57,8 @@ pub struct Accounts {
impl Accounts {
/// Loads or creates an accounts folder at the given `dir`.
pub async fn new(dir: PathBuf, writable: bool) -> Result<Self> {
if writable {
Self::ensure_accounts_dir(&dir).await?;
if writable && !dir.exists() {
Accounts::create(&dir).await?;
}
let events = Events::new();
Accounts::open(events, dir, writable).await
@@ -67,9 +67,10 @@ impl Accounts {
/// Loads or creates an accounts folder at the given `dir`.
/// Uses an existing events channel.
pub async fn new_with_events(dir: PathBuf, writable: bool, events: Events) -> Result<Self> {
if writable {
Self::ensure_accounts_dir(&dir).await?;
if writable && !dir.exists() {
Accounts::create(&dir).await?;
}
Accounts::open(events, dir, writable).await
}
@@ -81,20 +82,14 @@ impl Accounts {
0
}
/// Ensures the accounts directory and config file exist.
/// Creates them if the directory doesn't exist, or if it exists but is empty.
/// Errors if the directory exists with files but no config.
async fn ensure_accounts_dir(dir: &Path) -> Result<()> {
if !dir.exists() {
fs::create_dir_all(dir)
.await
.context("Failed to create folder")?;
Config::new(dir).await?;
} else if !dir.join(CONFIG_NAME).exists() {
let mut rd = fs::read_dir(dir).await?;
ensure!(rd.next_entry().await?.is_none(), "{dir:?} is not empty");
Config::new(dir).await?;
}
/// Creates a new default structure.
async fn create(dir: &Path) -> Result<()> {
fs::create_dir_all(dir)
.await
.context("failed to create folder")?;
Config::new(dir).await?;
Ok(())
}
@@ -591,7 +586,6 @@ impl Config {
}
#[cfg(not(target_os = "ios"))]
#[expect(clippy::arithmetic_side_effects)]
async fn create_lock_task(dir: PathBuf) -> Result<Option<JoinHandle<anyhow::Result<()>>>> {
let lockfile = dir.join(LOCKFILE_NAME);
let mut lock = fd_lock::RwLock::new(fs::File::create(lockfile).await?);
@@ -686,27 +680,13 @@ impl Config {
file.write_all(toml::to_string_pretty(&self.inner)?.as_bytes())
.await
.context("failed to write a tmp config")?;
// We use `sync_all()` and not `sync_data()` here.
// This translates to `fsync()` instead of `fdatasync()`.
// `fdatasync()` may be insufficient for newely created files
// and may not even synchronize the file size on some operating systems,
// resulting in a truncated file.
file.sync_all()
file.sync_data()
.await
.context("failed to sync a tmp config")?;
drop(file);
fs::rename(&tmp_path, &self.file)
.await
.context("failed to rename config")?;
// Sync the rename().
#[cfg(not(windows))]
{
let parent = self.file.parent().context("No parent directory")?;
let parent_file = fs::File::open(parent).await?;
parent_file.sync_all().await?;
}
Ok(())
}
@@ -772,7 +752,6 @@ impl Config {
}
/// Creates a new account in the account manager directory.
#[expect(clippy::arithmetic_side_effects)]
async fn new_account(&mut self) -> Result<AccountConfig> {
let id = {
let id = self.inner.next_id;
@@ -862,7 +841,6 @@ impl Config {
///
/// Without this workaround removing account may fail on Windows with an error
/// "The process cannot access the file because it is being used by another process. (os error 32)".
#[expect(clippy::arithmetic_side_effects)]
async fn try_many_times<F, Fut, T>(f: F) -> std::result::Result<(), T>
where
F: Fn() -> Fut,
@@ -934,26 +912,6 @@ mod tests {
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_account_new_empty_existing_dir() {
let dir = tempfile::tempdir().unwrap();
let p: PathBuf = dir.path().join("accounts");
// A non-empty directory without accounts.toml should fail.
fs::create_dir_all(&p).await.unwrap();
fs::write(p.join("stray_file.txt"), b"hello").await.unwrap();
assert!(Accounts::new(p.clone(), true).await.is_err());
// Clean up to an empty directory.
fs::remove_file(p.join("stray_file.txt")).await.unwrap();
// An empty directory without accounts.toml should succeed.
let mut accounts = Accounts::new(p.clone(), true).await.unwrap();
assert_eq!(accounts.accounts.len(), 0);
let id = accounts.add_account().await.unwrap();
assert_eq!(id, 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_account_new_open_conflict() {
let dir = tempfile::tempdir().unwrap();
@@ -1249,11 +1207,13 @@ mod tests {
let account1 = accounts.get_account(1).context("failed to get account 1")?;
let account2 = accounts.get_account(2).context("failed to get account 2")?;
assert_eq!(stock_str::no_messages(&account1), "No messages.");
assert_eq!(stock_str::no_messages(&account2), "No messages.");
account1.set_stock_translation(StockMessage::NoMessages, "foobar".to_string())?;
assert_eq!(stock_str::no_messages(&account1), "foobar");
assert_eq!(stock_str::no_messages(&account2), "foobar");
assert_eq!(stock_str::no_messages(&account1).await, "No messages.");
assert_eq!(stock_str::no_messages(&account2).await, "No messages.");
account1
.set_stock_translation(StockMessage::NoMessages, "foobar".to_string())
.await?;
assert_eq!(stock_str::no_messages(&account1).await, "foobar");
assert_eq!(stock_str::no_messages(&account2).await, "foobar");
Ok(())
}

View File

@@ -47,11 +47,11 @@ pub struct Aheader {
pub public_key: SignedPublicKey,
pub prefer_encrypt: EncryptPreference,
/// Whether `_verified` attribute is present.
///
/// `_verified` attribute is an extension to `Autocrypt-Gossip`
/// header that is used to tell that the sender
/// marked this key as verified.
// Whether `_verified` attribute is present.
//
// `_verified` attribute is an extension to `Autocrypt-Gossip`
// header that is used to tell that the sender
// marked this key as verified.
pub verified: bool,
}
@@ -73,7 +73,6 @@ impl fmt::Display for Aheader {
let keydata = self.public_key.to_base64().chars().enumerate().fold(
String::new(),
|mut res, (i, c)| {
#[expect(clippy::arithmetic_side_effects)]
if i % 78 == 78 - "keydata=".len() {
res.push(' ')
}
@@ -108,11 +107,13 @@ impl FromStr for Aheader {
.remove("keydata")
.context("keydata attribute is not found")
.and_then(|raw| {
SignedPublicKey::from_base64(&raw).context("Autocrypt key cannot be decoded")
SignedPublicKey::from_base64(&raw).context("autocrypt key cannot be decoded")
})
.and_then(|key| {
key.verify()
.and(Ok(key))
.context("autocrypt key cannot be verified")
})?;
public_key
.verify_bindings()
.context("Autocrypt key cannot be verified")?;
let prefer_encrypt = attributes
.remove("prefer-encrypt")

View File

@@ -321,7 +321,6 @@ impl<'a> BlobObject<'a> {
/// then the updated user-visible filename will be returned;
/// this may be necessary because the format may be changed to JPG,
/// i.e. "image.png" -> "image.jpg".
#[expect(clippy::arithmetic_side_effects)]
fn check_or_recode_to_size(
&mut self,
context: &Context,

View File

@@ -11,16 +11,17 @@ use crate::context::{Context, WeakContext};
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::log::warn;
use crate::message::{Message, MsgId, Viewtype, markseen_msgs};
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::net::dns::lookup_host_with_cache;
use crate::param::Param;
use crate::stock_str;
use crate::tools::{normalize_text, time};
use anyhow::{Context as _, Result, ensure};
use deltachat_derive::{FromSql, ToSql};
use num_traits::FromPrimitive;
use sdp::SessionDescription;
use serde::Serialize;
use std::io::Cursor;
use std::str::FromStr;
use std::time::Duration;
use tokio::task;
@@ -79,7 +80,6 @@ impl CallInfo {
}
fn remaining_ring_seconds(&self) -> i64 {
#[expect(clippy::arithmetic_side_effects)]
let remaining_seconds = self.msg.timestamp_sent + RINGING_SECONDS - time();
remaining_seconds.clamp(0, RINGING_SECONDS)
}
@@ -104,12 +104,10 @@ impl CallInfo {
};
if self.is_incoming() {
let incoming_call_str = stock_str::incoming_call(context, self.has_video_initially());
self.update_text(context, &format!("{incoming_call_str}\n{duration}"))
self.update_text(context, &format!("Incoming call\n{duration}"))
.await?;
} else {
let outgoing_call_str = stock_str::outgoing_call(context, self.has_video_initially());
self.update_text(context, &format!("{outgoing_call_str}\n{duration}"))
self.update_text(context, &format!("Outgoing call\n{duration}"))
.await?;
}
Ok(())
@@ -128,14 +126,6 @@ impl CallInfo {
self.msg.param.exists(CALL_ACCEPTED_TIMESTAMP)
}
/// Returns true if the call is started as a video call.
pub fn has_video_initially(&self) -> bool {
self.msg
.param
.get_bool(Param::WebrtcHasVideoInitially)
.unwrap_or(false)
}
/// Returns true if the call is missed
/// because the caller canceled it
/// explicitly before ringing stopped.
@@ -174,7 +164,6 @@ impl CallInfo {
}
/// Returns call duration in seconds.
#[expect(clippy::arithmetic_side_effects)]
pub fn duration_seconds(&self) -> i64 {
if let (Some(start), Some(end)) = (
self.msg.param.get_i64(CALL_ACCEPTED_TIMESTAMP),
@@ -196,7 +185,6 @@ impl Context {
&self,
chat_id: ChatId,
place_call_info: String,
has_video_initially: bool,
) -> Result<MsgId> {
let chat = Chat::load_from_db(self, chat_id).await?;
ensure!(
@@ -205,15 +193,12 @@ impl Context {
);
ensure!(!chat.is_self_talk(), "Cannot call self");
let outgoing_call_str = stock_str::outgoing_call(self, has_video_initially);
let mut call = Message {
viewtype: Viewtype::Call,
text: outgoing_call_str,
text: "Outgoing call".into(),
..Default::default()
};
call.param.set(Param::WebrtcRoom, &place_call_info);
call.param
.set_int(Param::WebrtcHasVideoInitially, has_video_initially.into());
call.id = send_msg(self, chat_id, &mut call).await?;
let wait = RINGING_SECONDS;
@@ -247,7 +232,6 @@ impl Context {
if chat.is_contact_request() {
chat.id.accept(self).await?;
}
markseen_msgs(self, vec![call_id]).await?;
// send an acceptance message around: to the caller as well as to the other devices of the callee
let mut msg = Message {
@@ -264,7 +248,6 @@ impl Context {
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
from_this_device: true,
});
self.emit_msgs_changed(call.msg.chat_id, call_id);
Ok(())
@@ -283,13 +266,10 @@ impl Context {
if !call.is_accepted() {
if call.is_incoming() {
call.mark_as_ended(self).await?;
markseen_msgs(self, vec![call_id]).await?;
let declined_call_str = stock_str::declined_call(self);
call.update_text(self, &declined_call_str).await?;
call.update_text(self, "Declined call").await?;
} else {
call.mark_as_canceled(self).await?;
let canceled_call_str = stock_str::canceled_call(self);
call.update_text(self, &canceled_call_str).await?;
call.update_text(self, "Canceled call").await?;
}
} else {
call.mark_as_ended(self).await?;
@@ -331,12 +311,10 @@ impl Context {
if !call.is_accepted() && !call.is_ended() {
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?;
call.update_text(&context, "Missed call").await?;
} else {
call.mark_as_ended(&context).await?;
let canceled_call_str = stock_str::canceled_call(&context);
call.update_text(&context, &canceled_call_str).await?;
call.update_text(&context, "Canceled call").await?;
}
context.emit_msgs_changed(call.msg.chat_id, call_id);
context.emit_event(EventType::CallEnded {
@@ -361,14 +339,18 @@ 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?;
call.update_text(self, "Missed call").await?;
self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call
} else {
let incoming_call_str =
stock_str::incoming_call(self, call.has_video_initially());
call.update_text(self, &incoming_call_str).await?;
call.update_text(self, "Incoming call").await?;
self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified
let has_video = match sdp_has_video(&call.place_call_info) {
Ok(has_video) => has_video,
Err(err) => {
warn!(self, "Failed to determine if SDP offer has video: {err:#}.");
false
}
};
let can_call_me = match who_can_call_me(self).await? {
WhoCanCallMe::Contacts => ChatIdBlocked::lookup_by_contact(self, from_id)
.await?
@@ -395,7 +377,7 @@ impl Context {
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(),
has_video,
});
}
let wait = call.remaining_ring_seconds();
@@ -407,8 +389,7 @@ impl Context {
));
}
} else {
let outgoing_call_str = stock_str::outgoing_call(self, call.has_video_initially());
call.update_text(self, &outgoing_call_str).await?;
call.update_text(self, "Outgoing call").await?;
self.emit_msgs_changed(call.msg.chat_id, call_id);
}
} else {
@@ -430,7 +411,6 @@ impl Context {
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
from_this_device: false,
});
} else {
let accept_call_info = mime_message
@@ -459,23 +439,19 @@ impl Context {
if call.is_incoming() {
if from_id == ContactId::SELF {
call.mark_as_ended(self).await?;
let declined_call_str = stock_str::declined_call(self);
call.update_text(self, &declined_call_str).await?;
call.update_text(self, "Declined call").await?;
} else {
call.mark_as_canceled(self).await?;
let missed_call_str = stock_str::missed_call(self);
call.update_text(self, &missed_call_str).await?;
call.update_text(self, "Missed call").await?;
}
} else {
// outgoing
if from_id == ContactId::SELF {
call.mark_as_canceled(self).await?;
let canceled_call_str = stock_str::canceled_call(self);
call.update_text(self, &canceled_call_str).await?;
call.update_text(self, "Canceled call").await?;
} else {
call.mark_as_ended(self).await?;
let declined_call_str = stock_str::declined_call(self);
call.update_text(self, &declined_call_str).await?;
call.update_text(self, "Declined call").await?;
}
}
} else {
@@ -531,6 +507,19 @@ impl Context {
}
}
/// Returns true if SDP offer has a video.
pub fn sdp_has_video(sdp: &str) -> Result<bool> {
let mut cursor = Cursor::new(sdp);
let session_description =
SessionDescription::unmarshal(&mut cursor).context("Failed to parse SDP")?;
for media_description in &session_description.media_descriptions {
if media_description.media_name.media == "video" {
return Ok(true);
}
}
Ok(false)
}
/// State of the call for display in the message bubble.
#[derive(Debug, PartialEq, Eq)]
pub enum CallState {

View File

@@ -2,7 +2,6 @@ use super::*;
use crate::chat::forward_msgs;
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::{TestContext, TestContextManager};
@@ -26,6 +25,13 @@ async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()>
const PLACE_INFO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP4 host.anywhere.com\r\ns=-\r\nc=IN IP4 host.anywhere.com\r\nt=0 0\r\nm=audio 62986 RTP/AVP 0 4 18\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=rtpmap:18 G729/8000\r\na=inactive\r\n";
const ACCEPT_INFO: &str = "v=0\r\no=bob 2890844730 2890844731 IN IP4 host.example.com\r\ns=\r\nc=IN IP4 host.example.com\r\nt=0 0\r\nm=audio 54344 RTP/AVP 0 4\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=inactive\r\n";
/// Example from <https://datatracker.ietf.org/doc/rfc9143/>
/// with `s= ` replaced with `s=-`.
///
/// `s=` cannot be empty according to RFC 3264,
/// so it is more clear as `s=-`.
const PLACE_INFO_VIDEO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP6 2001:db8::3\r\ns=-\r\nc=IN IP6 2001:db8::3\r\nt=0 0\r\na=group:BUNDLE foo bar\r\n\r\nm=audio 10000 RTP/AVP 0 8 97\r\nb=AS:200\r\na=mid:foo\r\na=rtcp-mux\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:97 iLBC/8000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n\r\nm=video 10002 RTP/AVP 31 32\r\nb=AS:1000\r\na=mid:bar\r\na=rtcp-mux\r\na=rtpmap:31 H261/90000\r\na=rtpmap:32 MPV/90000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n";
async fn setup_call() -> Result<CallSetup> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
@@ -46,7 +52,7 @@ async fn setup_call() -> Result<CallSetup> {
bob2.create_chat(&alice).await;
let test_msg_id = alice
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true)
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string())
.await?;
let sent1 = alice.pop_sent_msg().await;
assert_eq!(sent1.sender_msg_id, test_msg_id);
@@ -62,8 +68,7 @@ async fn setup_call() -> Result<CallSetup> {
assert!(!info.is_incoming());
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_eq!(info.has_video_initially(), true);
assert_text(t, m.id, "Outgoing video call").await?;
assert_text(t, m.id, "Outgoing call").await?;
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
}
@@ -84,8 +89,7 @@ async fn setup_call() -> Result<CallSetup> {
assert!(info.is_incoming());
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_eq!(info.has_video_initially(), true);
assert_text(t, m.id, "Incoming video call").await?;
assert_text(t, m.id, "Incoming call").await?;
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
}
@@ -116,28 +120,9 @@ async fn accept_call() -> Result<CallSetup> {
// Bob accepts the incoming call
bob.accept_incoming_call(bob_call.id, ACCEPT_INFO.to_string())
.await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Incoming video call").await?;
assert_text(&bob, bob_call.id, "Incoming call").await?;
bob.evtracker
.get_matching(|evt| {
matches!(
evt,
EventType::IncomingCallAccepted {
from_this_device: true,
..
}
)
})
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.await;
let sent2 = bob.pop_sent_msg().await;
let info = bob
@@ -149,17 +134,9 @@ async fn accept_call() -> Result<CallSetup> {
assert_eq!(call_state(&bob, bob_call.id).await?, CallState::Active);
bob2.recv_msg_trash(&sent2).await;
assert_text(&bob, bob_call.id, "Incoming video call").await?;
assert_text(&bob, bob_call.id, "Incoming call").await?;
bob2.evtracker
.get_matching(|evt| {
matches!(
evt,
EventType::IncomingCallAccepted {
from_this_device: false,
..
}
)
})
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.await;
let info = bob2
.load_call_by_id(bob2_call.id)
@@ -170,7 +147,7 @@ async fn accept_call() -> Result<CallSetup> {
// Alice receives the acceptance message
alice.recv_msg_trash(&sent2).await;
assert_text(&alice, alice_call.id, "Outgoing video call").await?;
assert_text(&alice, alice_call.id, "Outgoing call").await?;
let ev = alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
@@ -192,7 +169,7 @@ async fn accept_call() -> Result<CallSetup> {
assert_eq!(call_state(&alice, alice_call.id).await?, CallState::Active);
alice2.recv_msg_trash(&sent2).await;
assert_text(&alice2, alice2_call.id, "Outgoing video call").await?;
assert_text(&alice2, alice2_call.id, "Outgoing call").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
@@ -228,21 +205,10 @@ async fn test_accept_call_callee_ends() -> Result<()> {
bob2_call,
..
} = accept_call().await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob has accepted the call and also ends it
bob.end_call(bob_call.id).await?;
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -253,7 +219,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
));
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Incoming video call\n<1 minute").await?;
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -264,7 +230,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
// Alice receives the ending message
alice.recv_msg_trash(&sent3).await;
assert_text(&alice, alice_call.id, "Outgoing video call\n<1 minute").await?;
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -275,7 +241,7 @@ async fn test_accept_call_callee_ends() -> Result<()> {
));
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Outgoing video call\n<1 minute").await?;
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -305,7 +271,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
// Bob has accepted the call but Alice ends it
alice.end_call(alice_call.id).await?;
assert_text(&alice, alice_call.id, "Outgoing video call\n<1 minute").await?;
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -317,7 +283,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
));
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Outgoing video call\n<1 minute").await?;
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -329,7 +295,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
// Bob receives the ending message
bob.recv_msg_trash(&sent3).await;
assert_text(&bob, bob_call.id, "Incoming video call\n<1 minute").await?;
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -339,7 +305,7 @@ async fn test_accept_call_caller_ends() -> Result<()> {
));
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Incoming video call\n<1 minute").await?;
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
@@ -367,18 +333,8 @@ async fn test_callee_rejects_call() -> Result<()> {
} = setup_call().await?;
// Bob has accepted Alice before, but does not want to talk with Alice
bob_call.chat_id.accept(&bob).await?;
bob.end_call(bob_call.id).await?;
assert_eq!(bob_call.id.get_state(&bob).await?, MessageState::InSeen);
// Bob sends an MDN to Alice.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, bob_call.from_id)
)
.await?,
1
);
assert_text(&bob, bob_call.id, "Declined call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
@@ -419,35 +375,6 @@ async fn test_callee_rejects_call() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_callee_sees_contact_request_call() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().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;
let bob_call = bob.recv_msg(&sent1).await;
// Bob can't end_call() because the contact request isn't accepted, but he can mark the call as
// seen.
markseen_msgs(bob, vec![bob_call.id]).await?;
assert_eq!(bob_call.id.get_state(bob).await?, MessageState::InSeen);
// Bob sends an MDN only to self so that an unaccepted contact can't know anything.
assert_eq!(
bob.sql
.count(
"SELECT COUNT(*) FROM smtp_mdns WHERE msg_id=? AND from_id=?",
(bob_call.id, ContactId::SELF)
)
.await?,
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_caller_cancels_call() -> Result<()> {
// Alice calls Bob
@@ -498,7 +425,7 @@ async fn test_caller_cancels_call() -> Result<()> {
// Test that message summary says it is a missed call.
let bob_call_msg = Message::load_from_db(&bob, bob_call.id).await?;
let summary = bob_call_msg.get_summary(&bob, None).await?;
assert_eq!(summary.text, "🎥 Missed call");
assert_eq!(summary.text, "📞 Missed call");
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Missed call").await?;
@@ -598,6 +525,13 @@ async fn test_update_call_text() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sdp_has_video() {
assert!(sdp_has_video("foobar").is_err());
assert_eq!(sdp_has_video(PLACE_INFO).unwrap(), false);
assert_eq!(sdp_has_video(PLACE_INFO_VIDEO).unwrap(), true);
}
/// Tests that calls are forwarded as text messages.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_forward_call() -> Result<()> {
@@ -608,7 +542,7 @@ async fn test_forward_call() -> Result<()> {
let alice_bob_chat = alice.create_chat(bob).await;
let alice_msg_id = alice
.place_outgoing_call(alice_bob_chat.id, PLACE_INFO.to_string(), true)
.place_outgoing_call(alice_bob_chat.id, PLACE_INFO.to_string())
.await
.context("Failed to place a call")?;
let alice_call = Message::load_from_db(alice, alice_msg_id).await?;

View File

@@ -1,7 +1,7 @@
//! # Chat module.
use std::cmp;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::io::Cursor;
use std::marker::Sync;
@@ -42,7 +42,6 @@ use crate::message::{self, Message, MessageState, MsgId, Viewtype};
use crate::mimefactory::{MimeFactory, RenderedEmail};
use crate::mimeparser::SystemMessage;
use crate::param::{Param, Params};
use crate::pgp::addresses_from_public_key;
use crate::receive_imf::ReceivedMsg;
use crate::smtp::{self, send_msg_to_smtp};
use crate::stock_str;
@@ -258,11 +257,7 @@ impl ChatId {
ChatIdBlocked::get_for_contact(context, contact_id, create_blocked)
.await
.map(|chat| chat.id)?;
if create_blocked != Blocked::Yes {
info!(context, "Scale up origin of {contact_id} to CreateChat.");
ContactId::scaleup_origin(context, &[contact_id], Origin::CreateChat)
.await?;
}
ContactId::scaleup_origin(context, &[contact_id], Origin::CreateChat).await?;
chat_id
} else {
warn!(
@@ -476,18 +471,13 @@ impl ChatId {
/// Adds message "Messages are end-to-end encrypted".
pub(crate) async fn add_e2ee_notice(self, context: &Context, timestamp: i64) -> Result<()> {
let text = stock_str::messages_e2ee_info_msg(context);
// Sort this notice to the very beginning of the chat.
// We don't want any message to appear before this notice
// which is normally added when encrypted chat is created.
let sort_timestamp = 0;
let text = stock_str::messages_e2e_encrypted(context).await;
add_info_msg_with_cmd(
context,
self,
&text,
SystemMessage::ChatE2ee,
Some(sort_timestamp),
Some(timestamp),
timestamp,
None,
None,
@@ -628,6 +618,7 @@ impl ChatId {
);
let chat = Chat::load_from_db(context, self).await?;
let delete_msgs_target = context.get_delete_msgs_target().await?;
let sync_id = match sync {
Nosync => None,
Sync => chat.get_sync_id(context).await?,
@@ -637,12 +628,12 @@ impl ChatId {
.sql
.transaction(|transaction| {
transaction.execute(
"UPDATE imap SET target='' WHERE rfc724_mid IN (SELECT rfc724_mid FROM msgs WHERE chat_id=? AND rfc724_mid!='')",
(self,),
"UPDATE imap SET target=? WHERE rfc724_mid IN (SELECT rfc724_mid FROM msgs WHERE chat_id=? AND rfc724_mid!='')",
(&delete_msgs_target, self,),
)?;
transaction.execute(
"UPDATE imap SET target='' WHERE rfc724_mid IN (SELECT pre_rfc724_mid FROM msgs WHERE chat_id=? AND pre_rfc724_mid!='')",
(self,),
"UPDATE imap SET target=? WHERE rfc724_mid IN (SELECT pre_rfc724_mid FROM msgs WHERE chat_id=? AND pre_rfc724_mid!='')",
(&delete_msgs_target, self,),
)?;
transaction.execute(
"DELETE FROM msgs_mdns WHERE msg_id IN (SELECT id FROM msgs WHERE chat_id=?)",
@@ -674,7 +665,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
}
if chat.is_self_talk() {
let mut msg = Message::new_text(stock_str::self_deleted_msg_body(context));
let mut msg = Message::new_text(stock_str::self_deleted_msg_body(context).await);
add_device_msg(context, None, Some(&mut msg)).await?;
}
chatlist_events::emit_chatlist_changed(context);
@@ -930,17 +921,6 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
.unwrap_or(0))
}
/// Returns timestamp of us joining the chat if we are the member of the chat.
pub(crate) async fn join_timestamp(self, context: &Context) -> Result<Option<i64>> {
context
.sql
.query_get_value(
"SELECT add_timestamp FROM chats_contacts WHERE chat_id=? AND contact_id=?",
(self, ContactId::SELF),
)
.await
}
/// Returns timestamp of the latest message in the chat,
/// including hidden messages or a draft if there is one.
pub(crate) async fn get_timestamp(self, context: &Context) -> Result<Option<i64>> {
@@ -962,7 +942,6 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
/// Jaccard similarity coefficient is used to estimate similarity of chat member sets.
///
/// Chat is considered active if something was posted there within the last 42 days.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_similar_chat_ids(self, context: &Context) -> Result<Vec<(ChatId, f64)>> {
// Count number of common members in this and other chats.
let intersection = context
@@ -1167,14 +1146,13 @@ 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? {
return Ok(stock_str::encr_none(context));
return Ok(stock_str::encr_none(context).await);
}
let mut ret = stock_str::messages_are_e2ee(context) + "\n";
let mut ret = stock_str::messages_e2e_encrypted(context).await + "\n";
for &contact_id in get_chat_contacts(context, self)
.await?
@@ -1191,13 +1169,8 @@ 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")?;
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(",");
ret += &format!("\n{addr}({relays})\n{fingerprint}\n");
} else {
ret += &format!("\n{addr}\n{fingerprint}\n");
}
if contact.public_key(context).await?.is_some() {
ret += &format!("\n{addr}\n{fingerprint}\n");
} else {
ret += &format!("\n{addr}\n(key missing)\n{fingerprint}\n");
}
@@ -1228,11 +1201,15 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
/// corresponding event in case of a system message (usually the current system time).
/// `always_sort_to_bottom` makes this adjust the returned timestamp up so that the message goes
/// to the chat bottom.
/// `received` -- whether the message is received. Otherwise being sent.
/// `incoming` -- whether the message is incoming.
pub(crate) async fn calc_sort_timestamp(
self,
context: &Context,
message_timestamp: i64,
always_sort_to_bottom: bool,
received: bool,
incoming: bool,
) -> Result<i64> {
let mut sort_timestamp = cmp::min(message_timestamp, smeared_time(context));
@@ -1252,6 +1229,38 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
(self, MessageState::OutDraft),
)
.await?
} else if received {
// Received messages shouldn't mingle with just sent ones and appear somewhere in the
// middle of the chat, so we go after the newest non fresh message.
//
// But if a received outgoing message is older than some seen message, better sort the
// received message purely by timestamp. We could place it just before that seen
// message, but anyway the user may not notice it.
//
// NB: Received outgoing messages may break sorting of fresh incoming ones, but this
// shouldn't happen frequently. Seen incoming messages don't really break sorting of
// fresh ones, they rather mean that older incoming messages are actually seen as well.
context
.sql
.query_row_optional(
"SELECT MAX(timestamp), MAX(IIF(state=?,timestamp_sent,0))
FROM msgs
WHERE chat_id=? AND hidden=0 AND state>?
HAVING COUNT(*) > 0",
(MessageState::InSeen, self, MessageState::InFresh),
|row| {
let ts: i64 = row.get(0)?;
let ts_sent_seen: i64 = row.get(1)?;
Ok((ts, ts_sent_seen))
},
)
.await?
.and_then(|(ts, ts_sent_seen)| {
match incoming || ts_sent_seen <= message_timestamp {
true => Some(ts),
false => None,
}
})
} else {
None
};
@@ -1262,16 +1271,7 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
sort_timestamp = last_msg_time;
}
if let Some(join_timestamp) = self.join_timestamp(context).await? {
// If we are the member of the chat, don't add messages
// before the timestamp of us joining it.
// This is needed to avoid sorting "Member added"
// or automatically sent bot welcome messages
// above SecureJoin system messages.
Ok(std::cmp::max(sort_timestamp, join_timestamp))
} else {
Ok(sort_timestamp)
}
Ok(sort_timestamp)
}
}
@@ -1381,7 +1381,7 @@ impl Chat {
.context(format!("Failed loading chat {chat_id} from database"))?;
if chat.id.is_archived_link() {
chat.name = stock_str::archived_chats(context);
chat.name = stock_str::archived_chats(context).await;
} else {
if chat.typ == Chattype::Single && chat.name.is_empty() {
// chat.name is set to contact.display_name on changes,
@@ -1405,9 +1405,9 @@ impl Chat {
chat.name = chat_name;
}
if chat.param.exists(Param::Selftalk) {
chat.name = stock_str::saved_messages(context);
chat.name = stock_str::saved_messages(context).await;
} else if chat.param.exists(Param::Devicetalk) {
chat.name = stock_str::device_messages(context);
chat.name = stock_str::device_messages(context).await;
}
}
@@ -1731,7 +1731,6 @@ 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,
@@ -1765,12 +1764,21 @@ impl Chat {
} else if matches!(self.typ, Chattype::Group | Chattype::OutBroadcast)
&& self.param.get_int(Param::Unpromoted).unwrap_or_default() == 1
{
msg.param.set_int(Param::AttachChatAvatarAndDescription, 1);
msg.param.set_int(Param::AttachGroupImage, 1);
self.param
.remove(Param::Unpromoted)
.set_i64(Param::GroupNameTimestamp, msg.timestamp_sort)
.set_i64(Param::GroupDescriptionTimestamp, msg.timestamp_sort);
.set_i64(Param::GroupNameTimestamp, msg.timestamp_sort);
self.update_param(context).await?;
// TODO: Remove this compat code needed because Core <= v1.143:
// - doesn't accept synchronization of QR code tokens for unpromoted groups, so we also
// send them when the group is promoted.
// - doesn't sync QR code tokens for unpromoted groups and the group might be created
// before an upgrade.
context
.sync_qr_code_tokens(Some(self.grpid.as_str()))
.await
.log_err(context)
.ok();
}
let is_bot = context.get_config_bool(Config::Bot).await?;
@@ -1878,7 +1886,11 @@ impl Chat {
let (msg_text, was_truncated) = truncate_msg_text(context, msg.text.clone()).await?;
let new_mime_headers = if msg.has_html() {
msg.param.get(Param::SendHtml).map(|s| s.to_string())
if msg.param.exists(Param::Forwarded) {
msg.get_id().get_html(context).await?
} else {
msg.param.get(Param::SendHtml).map(|s| s.to_string())
}
} else {
None
};
@@ -2295,10 +2307,15 @@ pub(crate) async fn update_special_chat_names(context: &Context) -> Result<()> {
update_special_chat_name(
context,
ContactId::DEVICE,
stock_str::device_messages(context),
stock_str::device_messages(context).await,
)
.await?;
update_special_chat_name(
context,
ContactId::SELF,
stock_str::saved_messages(context).await,
)
.await?;
update_special_chat_name(context, ContactId::SELF, stock_str::saved_messages(context)).await?;
Ok(())
}
@@ -2795,18 +2812,9 @@ async fn render_mime_message_and_pre_message(
///
/// The caller has to interrupt SMTP loop or otherwise process new rows.
pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -> Result<Vec<i64>> {
let cmd = msg.param.get_cmd();
if cmd == SystemMessage::GroupNameChanged || cmd == SystemMessage::GroupDescriptionChanged {
if msg.param.get_cmd() == SystemMessage::GroupNameChanged {
msg.chat_id
.update_timestamp(
context,
if cmd == SystemMessage::GroupNameChanged {
Param::GroupNameTimestamp
} else {
Param::GroupDescriptionTimestamp
},
msg.timestamp_sort,
)
.update_timestamp(context, Param::GroupNameTimestamp, msg.timestamp_sort)
.await?;
}
@@ -2828,12 +2836,19 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
let lowercase_from = from.to_lowercase();
recipients.retain(|x| x.to_lowercase() != lowercase_from);
// Default Webxdc integrations are hidden messages and must not be sent out:
if (msg.param.get_int(Param::WebxdcIntegration).is_some() && msg.hidden)
// This may happen eg. for groups with only SELF and bcc_self disabled:
|| (!context.get_config_bool(Config::BccSelf).await? && recipients.is_empty())
if context.get_config_bool(Config::BccSelf).await?
|| msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage
{
smtp::add_self_recipients(context, &mut recipients, needs_encryption).await?;
}
// Default Webxdc integrations are hidden messages and must not be sent out
if msg.param.get_int(Param::WebxdcIntegration).is_some() && msg.hidden {
recipients.clear();
}
if recipients.is_empty() {
// may happen eg. for groups with only SELF and bcc_self disabled
info!(
context,
"Message {} has no recipient, skipping smtp-send.", msg.id
@@ -2872,10 +2887,6 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
);
}
if context.get_config_bool(Config::BccSelf).await? {
smtp::add_self_recipients(context, &mut recipients, rendered_msg.is_encrypted).await?;
}
if needs_encryption && !rendered_msg.is_encrypted {
/* unrecoverable */
message::set_msg_failed(
@@ -2979,7 +2990,6 @@ 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!(
@@ -3052,7 +3062,7 @@ async fn donation_request_maybe(context: &Context) -> Result<()> {
let ts = if ts == 0 || msg_cnt.await? < 100 {
now.saturating_add(secs_between_checks)
} else {
let mut msg = Message::new_text(stock_str::donation_request(context));
let mut msg = Message::new_text(stock_str::donation_request(context).await);
add_device_msg(context, None, Some(&mut msg)).await?;
i64::MAX
};
@@ -3085,7 +3095,6 @@ pub async fn get_chat_msgs(context: &Context, chat_id: ChatId) -> Result<Vec<Cha
}
/// Returns messages belonging to the chat according to the given options.
#[expect(clippy::arithmetic_side_effects)]
pub async fn get_chat_msgs_ex(
context: &Context,
chat_id: ChatId,
@@ -3387,38 +3396,6 @@ pub(crate) async fn mark_old_messages_as_noticed(
Ok(())
}
/// Marks last incoming message in a chat as fresh.
pub async fn markfresh_chat(context: &Context, chat_id: ChatId) -> Result<()> {
let affected_rows = context
.sql
.execute(
"UPDATE msgs
SET state=?1
WHERE id=(SELECT id
FROM msgs
WHERE state IN (?1, ?2, ?3) AND hidden=0 AND chat_id=?4
ORDER BY timestamp DESC, id DESC
LIMIT 1)
AND state!=?1",
(
MessageState::InFresh,
MessageState::InNoticed,
MessageState::InSeen,
chat_id,
),
)
.await?;
if affected_rows == 0 {
return Ok(());
}
context.emit_msgs_changed_without_msg_id(chat_id);
chatlist_events::emit_chatlist_item_changed(context, chat_id);
Ok(())
}
/// Returns all database message IDs of the given types.
///
/// If `chat_id` is None, return messages from any chat.
@@ -3606,10 +3583,10 @@ pub(crate) async fn create_group_ex(
{
let text = if !grpid.is_empty() {
// Add "Others will only see this group after you sent a first message." message.
stock_str::new_group_send_first_message(context)
stock_str::new_group_send_first_message(context).await
} else {
// Add "Messages in this chat use classic email and are not encrypted." message.
stock_str::chat_unencrypted_explanation(context)
stock_str::chat_unencrypted_explanation(context).await
};
add_info_msg(context, chat_id, &text).await?;
}
@@ -3907,14 +3884,18 @@ pub(crate) async fn add_contact_to_chat_ex(
);
return Ok(false);
}
let sync_qr_code_tokens;
if from_handshake && chat.param.get_int(Param::Unpromoted).unwrap_or_default() == 1 {
let smeared_time = smeared_time(context);
chat.param
.remove(Param::Unpromoted)
.set_i64(Param::GroupNameTimestamp, smeared_time)
.set_i64(Param::GroupDescriptionTimestamp, smeared_time);
.set_i64(Param::GroupNameTimestamp, smeared_time(context));
chat.update_param(context).await?;
sync_qr_code_tokens = true;
} else {
sync_qr_code_tokens = false;
}
if context.is_self_addr(contact.get_addr()).await? {
// ourself is added using ContactId::SELF, do not add this address explicitly.
// if SELF is not in the group, members cannot be added at all.
@@ -3963,6 +3944,20 @@ pub(crate) async fn add_contact_to_chat_ex(
send_msg(context, chat_id, &mut msg).await?;
sync = Nosync;
// TODO: Remove this compat code needed because Core <= v1.143:
// - doesn't accept synchronization of QR code tokens for unpromoted groups, so we also send
// them when the group is promoted.
// - doesn't sync QR code tokens for unpromoted groups and the group might be created before
// an upgrade.
if sync_qr_code_tokens
&& context
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
.await
.log_err(context)
.is_ok()
{
context.scheduler.interrupt_smtp().await;
}
}
context.emit_event(EventType::ChatModified(chat_id));
if sync.into() {
@@ -3976,7 +3971,6 @@ pub(crate) async fn add_contact_to_chat_ex(
/// This function does not check if the avatar is set.
/// If avatar is not set and this function returns `true`,
/// a `Chat-User-Avatar: 0` header should be sent to reset the avatar.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn shall_attach_selfavatar(context: &Context, chat_id: ChatId) -> Result<bool> {
let timestamp_some_days_ago = time() - DC_RESEND_USER_AVATAR_DAYS * 24 * 60 * 60;
let needs_attach = context
@@ -4181,7 +4175,7 @@ async fn send_member_removal_msg(
if contact_id == ContactId::SELF {
if chat.typ == Chattype::InBroadcast {
msg.text = stock_str::msg_you_left_broadcast(context);
msg.text = stock_str::msg_you_left_broadcast(context).await;
} else {
msg.text = stock_str::msg_group_left_local(context, ContactId::SELF).await;
}
@@ -4198,106 +4192,7 @@ async fn send_member_removal_msg(
send_msg(context, chat.id, &mut msg).await
}
/// Set group or broadcast channel description.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
///
/// See also [`get_chat_description`]
pub async fn set_chat_description(
context: &Context,
chat_id: ChatId,
new_description: &str,
) -> Result<()> {
set_chat_description_ex(context, Sync, chat_id, new_description).await
}
async fn set_chat_description_ex(
context: &Context,
mut sync: sync::Sync,
chat_id: ChatId,
new_description: &str,
) -> Result<()> {
let new_description = sanitize_bidi_characters(new_description.trim());
ensure!(!chat_id.is_special(), "Invalid chat ID");
let chat = Chat::load_from_db(context, chat_id).await?;
ensure!(
chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast,
"Can only set description for groups / broadcasts"
);
ensure!(
!chat.grpid.is_empty(),
"Cannot set description for ad hoc groups"
);
if !chat.is_self_in_chat(context).await? {
context.emit_event(EventType::ErrorSelfNotInGroup(
"Cannot set chat description; self not in group".into(),
));
bail!("Cannot set chat description; self not in group");
}
let old_description = get_chat_description(context, chat_id).await?;
if old_description == new_description {
return Ok(());
}
context
.sql
.execute(
"INSERT OR REPLACE INTO chats_descriptions(chat_id, description) VALUES(?, ?)",
(chat_id, &new_description),
)
.await?;
if chat.is_promoted() {
let mut msg = Message::new(Viewtype::Text);
msg.text = stock_str::msg_chat_description_changed(context, ContactId::SELF).await;
msg.param.set_cmd(SystemMessage::GroupDescriptionChanged);
msg.id = send_msg(context, chat_id, &mut msg).await?;
context.emit_msgs_changed(chat_id, msg.id);
sync = Nosync;
}
context.emit_event(EventType::ChatModified(chat_id));
if sync.into() {
chat.sync(context, SyncAction::SetDescription(new_description))
.await
.log_err(context)
.ok();
}
Ok(())
}
/// Load the chat description from the database.
///
/// UIs show this in the profile page of the chat,
/// it is settable by [`set_chat_description`]
pub async fn get_chat_description(context: &Context, chat_id: ChatId) -> Result<String> {
let description = context
.sql
.query_get_value(
"SELECT description FROM chats_descriptions WHERE chat_id=?",
(chat_id,),
)
.await?
.unwrap_or_default();
Ok(description)
}
/// Sets group, mailing list, or broadcast channel chat name.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
/// Sets group or mailing list chat name.
pub async fn set_chat_name(context: &Context, chat_id: ChatId, new_name: &str) -> Result<()> {
rename_ex(context, Sync, chat_id, new_name).await
}
@@ -4341,11 +4236,8 @@ async fn rename_ex(
&& sanitize_single_line(&chat.name) != new_name
{
msg.viewtype = Viewtype::Text;
msg.text = if chat.typ == Chattype::OutBroadcast {
stock_str::msg_broadcast_name_changed(context, &chat.name, &new_name)
} else {
stock_str::msg_grp_name(context, &chat.name, &new_name, ContactId::SELF).await
};
msg.text =
stock_str::msg_grp_name(context, &chat.name, &new_name, ContactId::SELF).await;
msg.param.set_cmd(SystemMessage::GroupNameChanged);
if !chat.name.is_empty() {
msg.param.set(Param::Arg, &chat.name);
@@ -4406,11 +4298,7 @@ pub async fn set_chat_profile_image(
if new_image.is_empty() {
chat.param.remove(Param::ProfileImage);
msg.param.remove(Param::Arg);
msg.text = if chat.typ == Chattype::OutBroadcast {
stock_str::msg_broadcast_img_changed(context)
} else {
stock_str::msg_grp_img_deleted(context, ContactId::SELF).await
};
msg.text = stock_str::msg_grp_img_deleted(context, ContactId::SELF).await;
} else {
let mut image_blob = BlobObject::create_and_deduplicate(
context,
@@ -4420,11 +4308,7 @@ pub async fn set_chat_profile_image(
image_blob.recode_to_avatar_size(context).await?;
chat.param.set(Param::ProfileImage, image_blob.as_name());
msg.param.set(Param::Arg, image_blob.as_name());
msg.text = if chat.typ == Chattype::OutBroadcast {
stock_str::msg_broadcast_img_changed(context)
} else {
stock_str::msg_grp_img_changed(context, ContactId::SELF).await
};
msg.text = stock_str::msg_grp_img_changed(context, ContactId::SELF).await;
}
chat.update_param(context).await?;
if chat.is_promoted() {
@@ -4442,7 +4326,6 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
}
/// Forwards multiple messages to a chat in another context.
#[expect(clippy::arithmetic_side_effects)]
pub async fn forward_msgs_2ctx(
ctx_src: &Context,
msg_ids: &[MsgId],
@@ -4484,12 +4367,8 @@ pub async fn forward_msgs_2ctx(
msg.param = Params::new();
if msg.get_viewtype() != Viewtype::Sticker {
let forwarded_msg_id = match ctx_src.blobdir == ctx_dst.blobdir {
true => src_msg_id,
false => MsgId::new_unset(),
};
msg.param
.set_int(Param::Forwarded, forwarded_msg_id.to_u32() as i32);
.set_int(Param::Forwarded, src_msg_id.to_u32() as i32);
}
if msg.get_viewtype() == Viewtype::Call {
@@ -4501,9 +4380,7 @@ pub async fn forward_msgs_2ctx(
// When forwarding between different accounts, blob files must be physically copied
// because each account has its own blob directory.
if ctx_src.blobdir == ctx_dst.blobdir {
msg.param.steal(param, Param::File);
} else if let Some(src_path) = param.get_file_path(ctx_src)? {
if let Some(src_path) = param.get_file_path(ctx_src)? {
let new_blob = BlobObject::create_and_deduplicate(ctx_dst, &src_path, &src_path)
.context("Failed to copy blob file to destination account")?;
msg.param.set(Param::File, new_blob.as_name());
@@ -4516,9 +4393,6 @@ pub async fn forward_msgs_2ctx(
msg.param.steal(param, Param::ProtectQuote);
msg.param.steal(param, Param::Quote);
msg.param.steal(param, Param::Summary1);
if msg.has_html() {
msg.set_html(src_msg_id.get_html(ctx_src).await?);
}
msg.in_reply_to = None;
// do not leak data as group names; a default subject is generated by mimefactory
@@ -4573,7 +4447,6 @@ 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,
@@ -4666,6 +4539,7 @@ pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
}
msg_state => bail!("Unexpected message state {msg_state}"),
}
msg.timestamp_sort = create_smeared_timestamp(context);
if create_send_msg_jobs(context, &mut msg).await?.is_empty() {
continue;
}
@@ -4751,7 +4625,6 @@ pub(crate) async fn get_chat_id_by_grpid(
///
/// Optional `label` can be provided to ensure that message is added only once.
/// If `important` is true, a notification will be sent.
#[expect(clippy::arithmetic_side_effects)]
pub async fn add_device_msg_with_importance(
context: &Context,
label: Option<&str>,
@@ -4927,8 +4800,15 @@ pub(crate) async fn add_info_msg_with_cmd(
ts
} else {
let sort_to_bottom = true;
let (received, incoming) = (false, false);
chat_id
.calc_sort_timestamp(context, smeared_time(context), sort_to_bottom)
.calc_sort_timestamp(
context,
smeared_time(context),
sort_to_bottom,
received,
incoming,
)
.await?
};
@@ -5054,18 +4934,18 @@ async fn set_contacts_by_fingerprints(
matches!(chat.typ, Chattype::Group | Chattype::OutBroadcast),
"{id} is not a group or broadcast",
);
let mut contacts = BTreeSet::new();
let mut contacts = HashSet::new();
for (fingerprint, addr) in fingerprint_addrs {
let contact = Contact::add_or_lookup_ex(context, "", addr, fingerprint, Origin::Hidden)
.await?
.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(());
}
let broadcast_contacts_added = context
context
.sql
.transaction(move |transaction| {
// For broadcast channels, we only add members,
@@ -5082,31 +4962,12 @@ async fn set_contacts_by_fingerprints(
let mut statement = transaction.prepare(
"INSERT OR IGNORE INTO chats_contacts (chat_id, contact_id) VALUES (?, ?)",
)?;
let mut broadcast_contacts_added = Vec::new();
for contact_id in &contacts {
if statement.execute((id, contact_id))? > 0 && chat.typ == Chattype::OutBroadcast {
broadcast_contacts_added.push(*contact_id);
}
statement.execute((id, contact_id))?;
}
Ok(broadcast_contacts_added)
Ok(())
})
.await?;
let timestamp = smeared_time(context);
for added_id in broadcast_contacts_added {
let msg = stock_str::msg_add_member_local(context, added_id, ContactId::UNDEFINED).await;
add_info_msg_with_cmd(
context,
id,
&msg,
SystemMessage::MemberAddedToGroup,
Some(timestamp),
timestamp,
None,
Some(ContactId::SELF),
Some(added_id),
)
.await?;
}
context.emit_event(EventType::ChatModified(id));
Ok(())
}
@@ -5150,7 +5011,6 @@ pub(crate) enum SyncAction {
///
/// The list is a list of pairs of fingerprint and address.
SetPgpContacts(Vec<(String, String)>),
SetDescription(String),
Delete,
}
@@ -5249,9 +5109,6 @@ impl Context {
Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request."))
}
SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await,
SyncAction::SetDescription(to) => {
set_chat_description_ex(self, Nosync, chat_id, to).await
}
SyncAction::SetContacts(addrs) => set_contacts_by_addrs(self, chat_id, addrs).await,
SyncAction::SetPgpContacts(fingerprint_addrs) => {
set_contacts_by_fingerprints(self, chat_id, fingerprint_addrs).await

View File

@@ -746,7 +746,7 @@ async fn test_leave_group() -> Result<()> {
assert_eq!(get_chat_contacts(&alice, alice_chat_id).await?.len(), 1);
assert_eq!(rcvd_leave_msg.state, MessageState::InNoticed);
assert_eq!(rcvd_leave_msg.state, MessageState::InSeen);
alice.emit_event(EventType::Test);
alice
@@ -806,7 +806,7 @@ async fn test_self_talk() -> Result<()> {
assert!(chat.visibility == ChatVisibility::Normal);
assert!(!chat.is_device_talk());
assert!(chat.can_send(&t).await?);
assert_eq!(chat.name, stock_str::saved_messages(&t));
assert_eq!(chat.name, stock_str::saved_messages(&t).await);
assert!(chat.get_profile_image(&t).await?.is_some());
let msg_id = send_text_msg(&t, chat.id, "foo self".to_string()).await?;
@@ -866,6 +866,7 @@ async fn test_add_device_msg_unlabelled() {
assert_eq!(msg1.from_id, ContactId::DEVICE);
assert_eq!(msg1.to_id, ContactId::SELF);
assert!(!msg1.is_info());
assert!(!msg1.is_setupmessage());
let msg2 = message::Message::load_from_db(&t, msg2_id.unwrap()).await;
assert!(msg2.is_ok());
@@ -898,6 +899,7 @@ async fn test_add_device_msg_labelled() -> Result<()> {
assert_eq!(msg1.from_id, ContactId::DEVICE);
assert_eq!(msg1.to_id, ContactId::SELF);
assert!(!msg1.is_info());
assert!(!msg1.is_setupmessage());
// check device chat
let chat_id = msg1.chat_id;
@@ -911,7 +913,7 @@ async fn test_add_device_msg_labelled() -> Result<()> {
assert!(!chat.can_send(&t).await?);
assert!(chat.why_cant_send(&t).await? == Some(CantSendReason::DeviceChat));
assert_eq!(chat.name, stock_str::device_messages(&t));
assert_eq!(chat.name, stock_str::device_messages(&t).await);
let device_msg_icon = chat.get_profile_image(&t).await?.unwrap();
assert_eq!(
device_msg_icon.metadata()?.len(),
@@ -1329,54 +1331,6 @@ async fn test_marknoticed_all_chats() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markfresh_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// alice sends a message to Bob
let alice_chat = alice.create_chat(bob).await;
let sent_msg1 = alice.send_text(alice_chat.id, "hi bob!").await;
// bob received the message, fresh count is 1
let bob_msg1 = bob.recv_msg(&sent_msg1).await;
let bob_chat_id = bob_msg1.chat_id;
bob_chat_id.accept(bob).await?;
assert_eq!(bob_msg1.state, MessageState::InFresh);
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 1);
assert_eq!(bob.get_fresh_msgs().await?.len(), 1);
// alice sends another message to bob, fresh count is 2
let sent_msg2 = alice.send_text(alice_chat.id, "howdy?").await;
let bob_msg2 = bob.recv_msg(&sent_msg2).await;
let bob_msg1 = Message::load_from_db(bob, bob_msg1.id).await?;
assert_eq!(bob_msg1.state, MessageState::InFresh);
assert_eq!(bob_msg2.state, MessageState::InFresh);
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 2);
assert_eq!(bob.get_fresh_msgs().await?.len(), 2);
// bob marks the chat as noticed, messages are no longer fresh, fresh count is 0
marknoticed_chat(bob, bob_chat_id).await?;
let bob_msg1 = Message::load_from_db(bob, bob_msg1.id).await?;
let bob_msg2 = Message::load_from_db(bob, bob_msg2.id).await?;
assert_ne!(bob_msg1.state, MessageState::InFresh);
assert_ne!(bob_msg2.state, MessageState::InFresh);
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 0);
assert_eq!(bob.get_fresh_msgs().await?.len(), 0);
// bob marks the chat as fresh again, fresh count is 1 again
markfresh_chat(bob, bob_chat_id).await?;
let bob_msg1 = Message::load_from_db(bob, bob_msg1.id).await?;
let bob_msg2 = Message::load_from_db(bob, bob_msg2.id).await?;
assert_ne!(bob_msg1.state, MessageState::InFresh);
assert_eq!(bob_msg2.state, MessageState::InFresh);
assert_eq!(bob_chat_id.get_fresh_msg_cnt(bob).await?, 1);
assert_eq!(bob.get_fresh_msgs().await?.len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_archive_fresh_msgs() -> Result<()> {
let t = TestContext::new_alice().await;
@@ -2687,7 +2641,7 @@ async fn test_resend_own_message() -> Result<()> {
);
let msg_from = Contact::get_by_id(&fiona, msg.get_from_id()).await?;
assert_eq!(msg_from.get_addr(), "alice@example.org");
assert!(sent1_ts_sent == msg.timestamp_sent);
assert!(sent1_ts_sent < msg.timestamp_sent);
Ok(())
}
@@ -2777,24 +2731,27 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
join_securejoin(charlie, &qr).await.unwrap();
let request = charlie.pop_sent_msg().await;
assert_eq!(request.recipients, "alice@example.org");
assert_eq!(request.recipients, "alice@example.org charlie@example.net");
alice.recv_msg_trash(&request).await;
}
tcm.section("Alice sends vc-pubkey");
tcm.section("Alice sends auth-required");
{
let vc_pubkey = alice.pop_sent_msg().await;
assert_eq!(vc_pubkey.recipients, "charlie@example.net");
let parsed = charlie.parse_msg(&vc_pubkey).await;
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_none());
assert_eq!(parsed.decoded_data_contains("charlie@example.net"), false);
let auth_required = alice.pop_sent_msg().await;
assert_eq!(
auth_required.recipients,
"charlie@example.net alice@example.org"
);
let parsed = charlie.parse_msg(&auth_required).await;
assert!(parsed.get_header(HeaderDef::AutocryptGossip).is_some());
assert!(parsed.decoded_data_contains("charlie@example.net"));
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&vc_pubkey).await;
assert!(parsed_by_bob.decryption_error.is_some());
let parsed_by_bob = bob.parse_msg(&auth_required).await;
assert!(parsed_by_bob.decrypting_failed);
charlie.recv_msg_trash(&vc_pubkey).await;
charlie.recv_msg_trash(&auth_required).await;
}
tcm.section("Charlie sends request-with-auth");
@@ -2821,7 +2778,7 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&member_added).await;
assert!(parsed_by_bob.decryption_error.is_some());
assert!(parsed_by_bob.decrypting_failed);
let rcvd = charlie.recv_msg(&member_added).await;
assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberAddedToGroup);
@@ -2836,7 +2793,7 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&hi_msg).await;
assert!(parsed_by_bob.decryption_error.is_none());
assert_eq!(parsed_by_bob.decrypting_failed, false);
}
tcm.section("Alice removes Charlie. Bob must not see it.");
@@ -2853,7 +2810,7 @@ async fn test_broadcast_members_cant_see_each_other() -> Result<()> {
assert_eq!(parsed.decoded_data_contains("bob@example.net"), false);
let parsed_by_bob = bob.parse_msg(&member_removed).await;
assert!(parsed_by_bob.decryption_error.is_some());
assert!(parsed_by_bob.decrypting_failed);
let rcvd = charlie.recv_msg(&member_removed).await;
assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberRemovedFromGroup);
@@ -3035,49 +2992,27 @@ async fn test_broadcast_recipients_sync1() -> Result<()> {
alice1.recv_msg_trash(&request).await;
alice2.recv_msg_trash(&request).await;
let vc_pubkey = alice1.pop_sent_msg().await;
charlie.recv_msg_trash(&vc_pubkey).await;
let auth_required = alice1.pop_sent_msg().await;
charlie.recv_msg_trash(&auth_required).await;
alice2.recv_msg_trash(&auth_required).await;
let request_with_auth = charlie.pop_sent_msg().await;
alice1.recv_msg_trash(&request_with_auth).await;
alice2.recv_msg_trash(&request_with_auth).await;
let member_added = alice1.pop_sent_msg().await;
let a2_charlie_added = alice2.recv_msg(&member_added).await;
let a2_member_added = alice2.recv_msg(&member_added).await;
let _c_member_added = charlie.recv_msg(&member_added).await;
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
assert_eq!(a2_chatlist.get_msg_id(0)?.unwrap(), a2_charlie_added.id);
// Alice1 will now sync the full member list to Alice2:
sync(alice1, alice2).await;
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
assert_eq!(a2_chatlist.get_msg_id(0)?.unwrap(), a2_member_added.id);
let a2_bob_contact = alice2.add_or_lookup_contact_id(bob).await;
let a2_charlie_contact = alice2.add_or_lookup_contact_id(charlie).await;
let a2_chatlist = Chatlist::try_load(alice2, 0, Some("Channel"), None).await?;
let msg_id = a2_chatlist.get_msg_id(0)?.unwrap();
let a2_bob_added = Message::load_from_db(alice2, msg_id).await?;
assert_ne!(a2_bob_added.id, a2_charlie_added.id);
assert_eq!(
a2_bob_added.text,
stock_str::msg_add_member_local(alice2, a2_bob_contact, ContactId::UNDEFINED).await
);
assert_eq!(a2_bob_added.from_id, ContactId::SELF);
assert_eq!(
a2_bob_added.param.get_cmd(),
SystemMessage::MemberAddedToGroup
);
assert_eq!(
ContactId::new(
a2_bob_added
.param
.get_int(Param::ContactAddedRemoved)
.unwrap()
.try_into()
.unwrap()
),
a2_bob_contact
);
let a2_chat_members = get_chat_contacts(alice2, a2_charlie_added.chat_id).await?;
let a2_chat_members = get_chat_contacts(alice2, a2_member_added.chat_id).await?;
assert!(a2_chat_members.contains(&a2_bob_contact));
assert!(a2_chat_members.contains(&a2_charlie_contact));
assert_eq!(a2_chat_members.len(), 2);
@@ -3183,7 +3118,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupNameChanged);
assert_eq!(
rcvd.text,
r#"Channel name changed from "My Channel" to "New Channel name"."#
r#"Group name changed from "My Channel" to "New Channel name" by Alice."#
);
let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?;
assert_eq!(bob_chat.name, "New Channel name");
@@ -3200,7 +3135,7 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
let rcvd = bob.recv_msg(&sent).await;
assert!(rcvd.get_override_sender_name().is_none());
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupImageChanged);
assert_eq!(rcvd.text, "Channel image changed.");
assert_eq!(rcvd.text, "Group image changed by Alice.");
assert_eq!(rcvd.chat_id, bob_chat.id);
let bob_chat = Chat::load_from_db(bob, bob_chat.id).await?;
@@ -3221,206 +3156,6 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_description_basic() {
test_chat_description("", false, Chattype::Group)
.await
.unwrap();
// Don't test with broadcast channels,
// because broadcast channels can only be joined via a QR code
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_description_unpromoted_description() {
test_chat_description(
"Unpromoted description in the beginning",
false,
Chattype::Group,
)
.await
.unwrap();
// Don't test with broadcast channels,
// because broadcast channels can only be joined via a QR code
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_description_qr() {
test_chat_description("", true, Chattype::Group)
.await
.unwrap();
test_chat_description("", true, Chattype::OutBroadcast)
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_description_unpromoted_description_qr() {
test_chat_description(
"Unpromoted description in the beginning",
true,
Chattype::Group,
)
.await
.unwrap();
test_chat_description(
"Unpromoted description in the beginning",
true,
Chattype::OutBroadcast,
)
.await
.unwrap();
}
async fn test_chat_description(
initial_description: &str,
join_via_qr: bool,
chattype: Chattype,
) -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await;
let bob = &tcm.bob().await;
alice.set_config_bool(Config::SyncMsgs, true).await?;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
tcm.section("Create a group chat, and add Bob");
let alice_chat_id = if chattype == Chattype::Group {
create_group(alice, "My Group").await?
} else {
create_broadcast(alice, "My Channel".to_string()).await?
};
sync(alice, alice2).await;
if !initial_description.is_empty() {
set_chat_description(alice, alice_chat_id, initial_description).await?;
if chattype == Chattype::OutBroadcast {
// Broadcast channels are always promoted, so, a message is sent:
let sent = alice.pop_sent_msg().await;
assert_eq!(
sent.load_from_db().await.text,
"You changed the chat description."
);
let rcvd = alice2.recv_msg(&sent).await;
assert_eq!(rcvd.text, "You changed the chat description.");
} else {
sync(alice, alice2).await;
}
}
let alice2_chat_id = get_chat_id_by_grpid(
alice2,
&Chat::load_from_db(alice, alice_chat_id).await?.grpid,
)
.await?
.unwrap()
.0;
assert_eq!(
get_chat_description(alice2, alice2_chat_id).await?,
initial_description
);
let bob_chat_id = if join_via_qr {
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
tcm.exec_securejoin_qr(bob, alice, &qr).await
} else {
let alice_bob_id = alice.add_or_lookup_contact_id(bob).await;
add_contact_to_chat(alice, alice_chat_id, alice_bob_id).await?;
let sent = alice.send_text(alice_chat_id, "promoting the group").await;
bob.recv_msg(&sent).await.chat_id
};
assert_eq!(
get_chat_description(bob, bob_chat_id).await?,
initial_description
);
for description in [
&"This<>is 'a' \"cool\" chat:/\\|?*".repeat(50),
"multiple\nline\n\nbreaks\n\n\r\n.",
"",
"ä ẟ 😂",
] {
tcm.section(&format!(
"Alice sets the chat description to '{description}'"
));
set_chat_description(alice, alice_chat_id, description).await?;
let sent = alice.pop_sent_msg().await;
assert_eq!(
sent.load_from_db().await.text,
"You changed the chat description."
);
tcm.section("Bob receives the description change");
let parsed = MimeMessage::from_bytes(bob, sent.payload().as_bytes()).await?;
assert_eq!(
parsed.parts[0].msg,
"[Chat description changed. To see this and other new features, please update the app]"
);
let rcvd = bob.recv_msg(&sent).await;
assert_eq!(rcvd.get_info_type(), SystemMessage::GroupDescriptionChanged);
assert_eq!(rcvd.text, "Chat description changed by alice@example.org.");
assert_eq!(get_chat_description(bob, rcvd.chat_id).await?, description);
tcm.section("Check Alice's second device");
alice2.recv_msg(&sent).await;
let alice2_chat_id = get_chat_id_by_grpid(
alice2,
&Chat::load_from_db(alice, alice_chat_id).await?.grpid,
)
.await?
.unwrap()
.0;
assert_eq!(
get_chat_description(alice2, alice2_chat_id).await?,
description
);
}
tcm.section("Alice calls set_chat_description() without actually changing the description");
set_chat_description(alice, alice_chat_id, "ä ẟ 😂").await?;
assert!(
alice
.pop_sent_msg_opt(Duration::from_secs(0))
.await
.is_none()
);
Ok(())
}
/// Tests explicitly setting an empty chat description
/// doesn't trigger sending out a message
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setting_empty_chat_description() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
tcm.section("Create a group chat, and add Bob in order to promote it");
let alice_chat_id = create_group(alice, "My Group").await?;
add_contact_to_chat(
alice,
alice_chat_id,
alice.add_or_lookup_contact_id(bob).await,
)
.await?;
let _hi = alice.send_text(alice_chat_id, "hi").await;
set_chat_description(alice, alice_chat_id, "").await?;
assert!(
alice
.pop_sent_msg_opt(Duration::from_secs(0))
.await
.is_none()
);
Ok(())
}
/// Tests that directly after broadcast-securejoin,
/// the brodacast is shown correctly on both devices.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -3451,17 +3186,14 @@ async fn test_broadcast_joining_golden() -> Result<()> {
.await;
let alice_bob_contact = alice.add_or_lookup_contact_no_key(bob).await;
let private_chat = ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact.id)
.await?
.unwrap();
// The 1:1 chat with Bob should not be visible to the user:
assert!(
ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact.id)
.await?
.is_none()
);
let private_chat_id =
ChatId::create_for_contact_with_blocked(alice, alice_bob_contact.id, Blocked::Not).await?;
assert_eq!(private_chat.blocked, Blocked::Yes);
alice
.golden_test_chat(
private_chat_id,
private_chat.id,
"test_broadcast_joining_golden_private_chat",
)
.await;
@@ -3738,13 +3470,16 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
join_securejoin(bob0, &qr).await.unwrap();
let request = bob0.pop_sent_msg().await;
assert_eq!(request.recipients, "alice@example.org");
assert_eq!(request.recipients, "alice@example.org bob@example.net");
alice.recv_msg_trash(&request).await;
let vc_pubkey = alice.pop_sent_msg().await;
assert_eq!(vc_pubkey.recipients, "bob@example.net");
let auth_required = alice.pop_sent_msg().await;
assert_eq!(
auth_required.recipients,
"bob@example.net alice@example.org"
);
bob0.recv_msg_trash(&vc_pubkey).await;
bob0.recv_msg_trash(&auth_required).await;
let request_with_auth = bob0.pop_sent_msg().await;
assert_eq!(
request_with_auth.recipients,
@@ -3760,7 +3495,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberAddedToGroup);
tcm.section("Bob's second device also receives these messages");
bob1.recv_msg_trash(&vc_pubkey).await;
bob1.recv_msg_trash(&auth_required).await;
bob1.recv_msg_trash(&request_with_auth).await;
bob1.recv_msg(&member_added).await;
@@ -3790,14 +3525,14 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
let leave_msg = bob0.pop_sent_msg().await;
let parsed = MimeMessage::from_bytes(bob1, leave_msg.payload().as_bytes()).await?;
assert_eq!(parsed.parts[0].msg, "bob@example.net left the group.");
assert_eq!(parsed.parts[0].msg, "I left the group.");
let rcvd = bob1.recv_msg(&leave_msg).await;
assert_eq!(rcvd.chat_id, bob1_hello.chat_id);
assert!(rcvd.is_info());
assert_eq!(rcvd.get_info_type(), SystemMessage::MemberRemovedFromGroup);
assert_eq!(rcvd.text, stock_str::msg_you_left_broadcast(bob1));
assert_eq!(rcvd.text, stock_str::msg_you_left_broadcast(bob1).await);
Ok(())
}
@@ -3857,7 +3592,7 @@ async fn test_only_broadcast_owner_can_send_1() -> Result<()> {
"Bob receives an answer, but shows it in 1:1 chat because of a fingerprint mismatch",
);
let rcvd = bob.recv_msg(&member_added).await;
assert_eq!(rcvd.text, "Member bob@example.net was added.");
assert_eq!(rcvd.text, "I added member bob@example.net.");
let bob_alice_chat_id = bob.get_chat(alice).await.id;
assert_eq!(rcvd.chat_id, bob_alice_chat_id);
@@ -3907,7 +3642,6 @@ async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
tcm.section("Now, Alice's fingerprint changes");
alice.sql.execute("DELETE FROM keypairs", ()).await?;
*alice.self_public_key.lock().await = None;
alice
.sql
.execute("DELETE FROM config WHERE keyname='key_id'", ())
@@ -3918,20 +3652,14 @@ async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
.self_fingerprint
.take();
tcm.section("Alice sends a message, which is trashed");
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
bob.recv_msg_trash(&sent).await;
let EventType::Warning(warning) = bob
.evtracker
.get_matching(|ev| matches!(ev, EventType::Warning(_)))
.await
else {
unreachable!()
};
assert!(
warning.contains("This sender is not allowed to encrypt with this secret key"),
"Wrong warning: {warning}"
tcm.section(
"Alice sends a message, which is not put into the broadcast chat but into a 1:1 chat",
);
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
let rcvd = bob.recv_msg(&sent).await;
assert_eq!(rcvd.text, "Hi");
let bob_alice_chat_id = bob.get_chat(alice).await.id;
assert_eq!(rcvd.chat_id, bob_alice_chat_id);
Ok(())
}
@@ -3966,7 +3694,7 @@ async fn test_sync_broadcast_avatar_and_name() -> Result<()> {
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged);
assert_eq!(
rcvd.text,
r#"Channel name changed from "foo" to "New name"."#
r#"You changed group name from "foo" to "New name"."#
);
let a2_broadcast_chat = Chat::load_from_db(alice2, a2_broadcast_id).await?;
@@ -3980,7 +3708,7 @@ async fn test_sync_broadcast_avatar_and_name() -> Result<()> {
let rcvd = alice1.recv_msg(&sent).await;
assert_eq!(rcvd.chat_id, a1_broadcast_id);
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupImageChanged);
assert_eq!(rcvd.text, "Channel image changed.");
assert_eq!(rcvd.text, "You changed the group image.");
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
let avatar = a1_broadcast_chat.get_profile_image(alice1).await?.unwrap();
@@ -4000,7 +3728,6 @@ async fn test_encrypt_decrypt_broadcast() -> Result<()> {
let grpid = "grpid";
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
let bob_alice_contact_id = bob.add_or_lookup_contact_id(alice).await;
tcm.section("Create a broadcast channel with Bob, and send a message");
let alice_chat_id = create_out_broadcast_ex(
@@ -4024,7 +3751,6 @@ async fn test_encrypt_decrypt_broadcast() -> Result<()> {
)
.await?;
save_broadcast_secret(bob, bob_chat_id, secret).await?;
add_to_chat_contacts_table(bob, time(), bob_chat_id, &[bob_alice_contact_id]).await?;
let sent = alice
.send_text(alice_chat_id, "Symmetrically encrypted message")
@@ -4098,7 +3824,7 @@ async fn test_chat_get_encryption_info() -> Result<()> {
chat_id.get_encryption_info(alice).await?,
"Messages are end-to-end encrypted.\n\
\n\
bob@example.net(bob@example.net)\n\
bob@example.net\n\
CCCB 5AA9 F6E1 141C 9431\n\
65F1 DB18 B18C BCF7 0487"
);
@@ -4108,11 +3834,11 @@ async fn test_chat_get_encryption_info() -> Result<()> {
chat_id.get_encryption_info(alice).await?,
"Messages are end-to-end encrypted.\n\
\n\
fiona@example.net(fiona@example.net)\n\
fiona@example.net\n\
C8BA 50BF 4AC1 2FAF 38D7\n\
F657 DDFC 8E9F 3C79 9195\n\
\n\
bob@example.net(bob@example.net)\n\
bob@example.net\n\
CCCB 5AA9 F6E1 141C 9431\n\
65F1 DB18 B18C BCF7 0487"
);
@@ -4784,10 +4510,6 @@ async fn test_sync_broadcast_and_send_message() -> Result<()> {
vec![a2b_contact_id]
);
// alice2's smeared clock may be behind alice1's one, so we need to work around "hi" appearing
// before "You joined the channel." for bob. alice1 makes 3 more calls of
// create_smeared_timestamp() than alice2 does as of 2026-03-10.
SystemTime::shift(Duration::from_secs(3));
tcm.section("Alice's second device sends a message to the channel");
let sent_msg = alice2.send_text(a2_broadcast_id, "hi").await;
let msg = bob.recv_msg(&sent_msg).await;
@@ -4852,7 +4574,7 @@ async fn test_sync_name() -> Result<()> {
assert_eq!(rcvd.to_id, ContactId::SELF);
assert_eq!(
rcvd.text,
"Channel name changed from \"Channel\" to \"Broadcast channel 42\"."
"You changed group name from \"Channel\" to \"Broadcast channel 42\"."
);
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged);
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)
@@ -4922,22 +4644,6 @@ async fn test_sync_create_group() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_broadcast_contacts_are_hidden() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat_id = create_broadcast(alice, "Channel".to_string()).await?;
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
tcm.exec_securejoin_qr(bob, alice, &qr).await;
send_text_msg(alice, alice_chat_id, "hello".to_string()).await?;
bob.recv_msg(&alice.pop_sent_msg().await).await;
assert_eq!(Contact::get_all(alice, 0, None).await?.len(), 0);
assert_eq!(Contact::get_all(bob, 0, None).await?.len(), 0);
Ok(())
}
/// Tests sending JPEG image with .png extension.
///
/// This is a regression test, previously sending failed
@@ -6112,54 +5818,3 @@ async fn test_leftgrps() -> Result<()> {
Ok(())
}
/// Tests that if the message arrives late,
/// it can still be sorted above the last seen message.
///
/// Versions 2.47 and below always sorted incoming messages
/// after the last seen message, but with
/// the introduction of multi-relay it is possible
/// that some messages arrive only to some relays
/// and are fetched after the already arrived seen message.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_late_message_above_seen() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
let alice_chat_id = alice
.create_group_with_members("Group", &[bob, charlie])
.await;
let alice_sent = alice.send_text(alice_chat_id, "Hello everyone!").await;
let bob_chat_id = bob.recv_msg(&alice_sent).await.chat_id;
bob_chat_id.accept(bob).await?;
let charlie_chat_id = charlie.recv_msg(&alice_sent).await.chat_id;
charlie_chat_id.accept(charlie).await?;
// Bob sends a message, but the message is delayed.
let bob_sent = bob.send_text(bob_chat_id, "Hello from Bob!").await;
SystemTime::shift(Duration::from_secs(1000));
let charlie_sent = charlie
.send_text(charlie_chat_id, "Hello from Charlie!")
.await;
// Alice immediately receives a message from Charlie and reads it.
let alice_received_from_charlie = alice.recv_msg(&charlie_sent).await;
assert_eq!(
alice_received_from_charlie.get_text(),
"Hello from Charlie!"
);
message::markseen_msgs(alice, vec![alice_received_from_charlie.id]).await?;
// Bob message arrives later, it should be above the message from Charlie.
let alice_received_from_bob = alice.recv_msg(&bob_sent).await;
assert_eq!(alice_received_from_bob.get_text(), "Hello from Bob!");
// The last message in the chat is still from Charlie.
let last_msg = alice.get_last_msg_in(alice_chat_id).await;
assert_eq!(last_msg.get_text(), "Hello from Charlie!");
Ok(())
}

View File

@@ -142,7 +142,7 @@ impl Chatlist {
AND c.blocked!=1
AND c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=?2 AND add_timestamp >= remove_timestamp)
GROUP BY c.id
ORDER BY c.archived=?3 DESC, IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC;",
ORDER BY c.archived=?3 DESC, IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft, query_contact_id, ChatVisibility::Pinned),
process_row,
).await?
@@ -168,7 +168,7 @@ impl Chatlist {
AND c.blocked!=1
AND c.archived=1
GROUP BY c.id
ORDER BY IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC;",
ORDER BY IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft,),
process_row,
)
@@ -204,7 +204,7 @@ impl Chatlist {
AND IFNULL(c.name_normalized,c.name) LIKE ?3
AND (NOT ?4 OR EXISTS (SELECT 1 FROM msgs m WHERE m.chat_id = c.id AND m.state == ?5 AND hidden=0))
GROUP BY c.id
ORDER BY IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC;",
ORDER BY IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft, skip_id, str_like_cmd, only_unread, MessageState::InFresh),
process_row,
)
@@ -253,7 +253,7 @@ impl Chatlist {
AND NOT c.archived=?
AND (c.type!=? OR c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=? AND add_timestamp >= remove_timestamp))
GROUP BY c.id
ORDER BY c.id=? DESC, c.archived=? DESC, IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC;",
ORDER BY c.id=? DESC, c.archived=? DESC, IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
(
MessageState::OutDraft, skip_id, ChatVisibility::Archived,
Chattype::Group, ContactId::SELF,
@@ -279,7 +279,7 @@ impl Chatlist {
AND (c.blocked=0 OR c.blocked=2)
AND NOT c.archived=?
GROUP BY c.id
ORDER BY c.id=0 DESC, c.archived=? DESC, IFNULL(NULLIF(m.timestamp,0),c.created_timestamp) DESC, m.id DESC;",
ORDER BY c.id=0 DESC, c.archived=? DESC, IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft, skip_id, ChatVisibility::Archived, ChatVisibility::Pinned),
process_row,
).await?
@@ -417,7 +417,7 @@ impl Chatlist {
Summary::new_with_reaction_details(context, &lastmsg, chat, lastcontact.as_ref()).await
} else {
Ok(Summary {
text: stock_str::no_messages(context),
text: stock_str::no_messages(context).await,
..Default::default()
})
}
@@ -648,6 +648,7 @@ mod tests {
assert_eq!(chats.len(), 0);
t.set_stock_translation(StockMessage::SavedMessages, "test-1234-save".to_string())
.await
.unwrap();
let chats = Chatlist::try_load(&t, 0, Some("t-1234-s"), None)
.await
@@ -655,6 +656,7 @@ mod tests {
assert_eq!(chats.len(), 1);
t.set_stock_translation(StockMessage::DeviceMessages, "test-5678-babbel".to_string())
.await
.unwrap();
let chats = Chatlist::try_load(&t, 0, Some("t-5678-b"), None)
.await

View File

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

View File

@@ -19,7 +19,7 @@ use crate::log::LogExt;
use crate::mimefactory::RECOMMENDED_FILE_SIZE;
use crate::provider::Provider;
use crate::sync::{self, Sync::*, SyncData};
use crate::tools::{get_abs_path, time};
use crate::tools::get_abs_path;
use crate::transport::{ConfiguredLoginParam, add_pseudo_transport, send_sync_transports};
use crate::{constants, stats};
@@ -194,6 +194,10 @@ pub enum Config {
#[strum(props(default = "0"))]
DeleteDeviceAfter,
/// Move messages to the Trash folder instead of marking them "\Deleted". Overrides
/// `ProviderOptions::delete_to_trash`.
DeleteToTrash,
/// The primary email address.
ConfiguredAddr,
@@ -271,6 +275,9 @@ pub enum Config {
/// Configured folder for chat messages.
ConfiguredMvboxFolder,
/// Configured "Trash" folder.
ConfiguredTrashFolder,
/// Unix timestamp of the last successful configuration.
ConfiguredTimestamp,
@@ -328,6 +335,10 @@ pub enum Config {
/// Timestamp of the last `CantDecryptOutgoingMsgs` notification.
LastCantDecryptOutgoingMsgs,
/// To how many seconds to debounce scan_all_folders. Used mainly in tests, to disable debouncing completely.
#[strum(props(default = "60"))]
ScanAllFoldersDebounceSecs,
/// Whether to avoid using IMAP IDLE even if the server supports it.
///
/// This is a developer option for testing "fake idle".
@@ -591,7 +602,8 @@ impl Context {
.get_config(key)
.await?
.and_then(|s| s.parse::<i32>().ok())
.is_some_and(|x| x != 0))
.map(|x| x != 0)
.unwrap_or_default())
}
/// Returns true if movebox ("DeltaChat" folder) should be watched.
@@ -684,6 +696,7 @@ impl Context {
| Config::MdnsEnabled
| Config::MvboxMove
| Config::OnlyFetchMvbox
| Config::DeleteToTrash
| Config::Configured
| Config::Bot
| Config::NotifyAboutWrongPw
@@ -707,7 +720,12 @@ impl Context {
Self::check_config(key, value)?;
let n_transports = self.count_transports().await?;
if n_transports > 1 && matches!(key, Config::MvboxMove | Config::OnlyFetchMvbox) {
if n_transports > 1
&& matches!(
key,
Config::MvboxMove | Config::OnlyFetchMvbox | Config::ShowEmails
)
{
bail!("Cannot reconfigure {key} when multiple transports are configured");
}
@@ -828,22 +846,6 @@ impl Context {
(addr,),
)?;
// Update the timestamp for the primary transport
// so it becomes the first in `get_all_self_addrs()` list
// and the list of relays distributed in the public key.
// This ensures that messages will be sent
// to the primary relay by the contacts
// and will be fetched in background_fetch()
// which only fetches from the primary transport.
transaction
.execute(
"UPDATE transports SET add_timestamp=?, is_published=1 WHERE addr=?",
(time(), addr),
)
.context(
"Failed to update add_timestamp for the new primary transport",
)?;
// Clean up SMTP and IMAP APPEND queue.
//
// The messages in the queue have a different
@@ -960,33 +962,12 @@ impl Context {
Ok(())
}
/// Returns all self addresses, newest first.
/// Returns the primary self address followed by all secondary ones.
pub(crate) async fn get_all_self_addrs(&self) -> Result<Vec<String>> {
self.sql
.query_map_vec(
"SELECT addr FROM transports ORDER BY add_timestamp DESC, id DESC",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)
.await
}
let primary_addrs = self.get_config(Config::ConfiguredAddr).await?.into_iter();
let secondary_addrs = self.get_secondary_self_addrs().await?.into_iter();
/// Returns all published self addresses, newest first.
/// See `[Context::set_transport_unpublished]`
pub(crate) async fn get_published_self_addrs(&self) -> Result<Vec<String>> {
self.sql
.query_map_vec(
"SELECT addr FROM transports WHERE is_published=1 ORDER BY add_timestamp DESC, id DESC",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)
.await
Ok(primary_addrs.chain(secondary_addrs).collect())
}
/// Returns all secondary self addresses.
@@ -997,24 +978,6 @@ impl Context {
}).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>> {
self.sql
.query_map_vec(
"SELECT addr FROM transports
WHERE is_published
AND addr NOT IN (SELECT value FROM config WHERE keyname='configured_addr')
ORDER BY add_timestamp DESC, id DESC",
(),
|row| {
let addr: String = row.get(0)?;
Ok(addr)
},
)
.await
}
/// Returns the primary self address.
/// Returns an error if no self addr is configured.
pub async fn get_primary_self_addr(&self) -> Result<String> {

View File

@@ -246,7 +246,8 @@ async fn test_sync() -> Result<()> {
alice1
.get_config(Config::Selfavatar)
.await?
.is_some_and(|path| path.ends_with(".png"))
.filter(|path| path.ends_with(".png"))
.is_some()
);
alice0.set_config(Config::Selfavatar, None).await?;
sync(&alice0, &alice1).await;
@@ -304,14 +305,16 @@ async fn test_no_sync_on_self_sent_msg() -> Result<()> {
alice1
.get_config(Config::Selfavatar)
.await?
.is_some_and(|path| path.ends_with(".jpg"))
.filter(|path| path.ends_with(".jpg"))
.is_some()
);
sync(alice1, alice0).await;
assert!(
alice0
.get_config(Config::Selfavatar)
.await?
.is_some_and(|path| path.ends_with(".jpg"))
.filter(|path| path.ends_with(".jpg"))
.is_some()
);
Ok(())

View File

@@ -28,8 +28,8 @@ use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
use crate::context::Context;
use crate::imap::Imap;
use crate::log::warn;
use crate::login_param::EnteredCertificateChecks;
pub use crate::login_param::EnteredLoginParam;
use crate::login_param::{EnteredCertificateChecks, TransportListEntry};
use crate::message::Message;
use crate::net::proxy::ProxyConfig;
use crate::oauth2::get_oauth2_addr;
@@ -110,7 +110,6 @@ impl Context {
/// from a server encoded in a QR code.
/// - [Self::list_transports()] to get a list of all configured transports.
/// - [Self::delete_transport()] to remove a transport.
/// - [Self::set_transport_unpublished()] to set whether contacts see this transport.
pub async fn add_or_update_transport(&self, param: &mut EnteredLoginParam) -> Result<()> {
self.stop_io().await;
let result = self.add_transport_inner(param).await;
@@ -146,7 +145,7 @@ impl Context {
if let Err(err) = res.as_ref() {
// We are using Anyhow's .context() and to show the
// inner error, too, we need the {:#}:
let error_msg = stock_str::configuration_failed(self, &format!("{err:#}"));
let error_msg = stock_str::configuration_failed(self, &format!("{err:#}")).await;
progress!(self, 0, Some(error_msg.clone()));
bail!(error_msg);
} else {
@@ -189,22 +188,14 @@ impl Context {
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to delete a transport.
pub async fn list_transports(&self) -> Result<Vec<TransportListEntry>> {
pub async fn list_transports(&self) -> Result<Vec<EnteredLoginParam>> {
let transports = self
.sql
.query_map_vec(
"SELECT entered_param, is_published FROM transports",
(),
|row| {
let param: String = row.get(0)?;
let param: EnteredLoginParam = serde_json::from_str(&param)?;
let is_published: bool = row.get(1)?;
Ok(TransportListEntry {
param,
is_unpublished: !is_published,
})
},
)
.query_map_vec("SELECT entered_param FROM transports", (), |row| {
let entered_param: String = row.get(0)?;
let transport: EnteredLoginParam = serde_json::from_str(&entered_param)?;
Ok(transport)
})
.await?;
Ok(transports)
@@ -243,6 +234,11 @@ impl Context {
Ok((id, add_timestamp))
},
)?;
transaction.execute("DELETE FROM imap WHERE transport_id=?", (transport_id,))?;
transaction.execute(
"DELETE FROM imap_sync WHERE transport_id=?",
(transport_id,),
)?;
// Removal timestamp should not be lower than addition timestamp
// to be accepted by other devices when synced.
@@ -265,44 +261,6 @@ impl Context {
Ok(())
}
/// Change whether the transport is unpublished.
///
/// Unpublished transports are not advertised to contacts,
/// and self-sent messages are not sent there,
/// so that we don't cause extra messages to the corresponding inbox,
/// but can still receive messages from contacts who don't know our new transport addresses yet.
///
/// The default is false, but when the user updates from a version that didn't have this flag,
/// existing secondary transports are set to unpublished,
/// so that an existing transport address doesn't suddenly get spammed with a lot of messages.
pub async fn set_transport_unpublished(&self, addr: &str, unpublished: bool) -> Result<()> {
self.sql
.transaction(|trans| {
let primary_addr: String = trans
.query_row(
"SELECT value FROM config WHERE keyname='configured_addr'",
(),
|row| row.get(0),
)
.context("Select primary address")?;
if primary_addr == addr && unpublished {
bail!("Can't set primary relay as unpublished");
}
// We need to update the timestamp so that the key's timestamp changes
// and is recognized as newer by our peers
trans
.execute(
"UPDATE transports SET is_published=?, add_timestamp=? WHERE addr=? AND is_published!=?1",
(!unpublished, time(), addr),
)
.context("Update transports")?;
Ok(())
})
.await?;
send_sync_transports(self).await?;
Ok(())
}
async fn inner_configure(&self, param: &EnteredLoginParam) -> Result<()> {
info!(self, "Configure ...");
@@ -328,6 +286,11 @@ impl Context {
"To use additional relays, disable the legacy option \"Settings / Advanced / Move automatically to DeltaChat Folder\"."
);
}
if self.get_config(Config::ShowEmails).await?.as_deref() != Some("2") {
bail!(
"To use additional relays, set the legacy option \"Settings / Advanced / Show Classic Emails\" to \"All\"."
);
}
if self
.sql
@@ -591,6 +554,9 @@ async fn get_configured_param(
async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'static Provider>> {
progress!(ctx, 1);
let ctx2 = ctx.clone();
let update_device_chats_handle = task::spawn(async move { ctx2.update_device_chats().await });
let configured_param = get_configured_param(ctx, param).await?;
let proxy_config = ProxyConfig::load(ctx).await?;
let strict_tls = configured_param.strict_tls(proxy_config.is_some());
@@ -629,11 +595,11 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
let (_s, r) = async_channel::bounded(1);
let mut imap = Imap::new(ctx, transport_id, configured_param.clone(), r).await?;
let configuring = true;
let imap_session = match imap.connect(ctx, configuring).await {
Ok(imap_session) => imap_session,
Err(err) => {
bail!("{}", nicer_configuration_error(ctx, format!("{err:#}")));
}
if let Err(err) = imap.connect(ctx, configuring).await {
bail!(
"{}",
nicer_configuration_error(ctx, format!("{err:#}")).await
);
};
progress!(ctx, 850);
@@ -648,17 +614,7 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
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?;
} else if !is_configured {
// Reset the setting that may have been set
// during failed configuration.
ctx.sql.set_raw_config("is_chatmail", Some("0")).await?;
}
}
drop(imap_session);
drop(imap);
progress!(ctx, 910);
@@ -678,9 +634,7 @@ async fn configure(ctx: &Context, param: &EnteredLoginParam) -> Result<Option<&'
ctx.scheduler.interrupt_inbox().await;
progress!(ctx, 940);
ctx.update_device_chats()
.await
.context("Failed to update device chats")?;
update_device_chats_handle.await??;
ctx.sql.set_raw_config_bool("configured", true).await?;
ctx.emit_event(EventType::AccountsItemChanged);
@@ -773,7 +727,7 @@ async fn get_autoconfig(
None
}
fn nicer_configuration_error(context: &Context, e: String) -> String {
async fn nicer_configuration_error(context: &Context, e: String) -> String {
if e.to_lowercase().contains("could not resolve")
|| e.to_lowercase().contains("connection attempts")
|| e.to_lowercase()
@@ -782,7 +736,7 @@ fn nicer_configuration_error(context: &Context, e: String) -> String {
|| e.to_lowercase()
.contains("failed to lookup address information")
{
return stock_str::error_no_network(context);
return stock_str::error_no_network(context).await;
}
e

View File

@@ -234,6 +234,19 @@ pub(crate) const TIMESTAMP_SENT_TOLERANCE: i64 = 60;
// Newer Delta Chats will remove the prefix as needed.
pub(crate) const EDITED_PREFIX: &str = "✏️";
// Strings needed to render the Autocrypt Setup Message.
// Left untranslated as not being supported/recommended workflow and as translations would require deep knowledge.
pub(crate) const ASM_SUBJECT: &str = "Autocrypt Setup Message";
pub(crate) const ASM_BODY: &str = "This is the Autocrypt Setup Message \
used to transfer your end-to-end setup between clients.
To decrypt and use your setup, \
open the message in an Autocrypt-compliant client \
and enter the setup code presented on the generating device.
If you see this message in a chatmail client (Delta Chat, Arcane Chat, Delta Touch ...), \
use \"Settings / Add Second Device\" instead.";
/// Period between `sql::housekeeping()` runs.
pub(crate) const HOUSEKEEPING_PERIOD: i64 = 24 * 60 * 60;

View File

@@ -35,7 +35,6 @@ use crate::log::{LogExt, warn};
use crate::message::MessageState;
use crate::mimeparser::AvatarAction;
use crate::param::{Param, Params};
use crate::pgp::{addresses_from_public_key, merge_openpgp_certificates};
use crate::sync::{self, Sync::*};
use crate::tools::{SystemTime, duration_to_str, get_abs_path, normalize_text, time, to_lowercase};
use crate::{chat, chatlist_events, ensure_and_debug_assert_ne, stock_str};
@@ -315,67 +314,6 @@ pub async fn make_vcard(context: &Context, contacts: &[ContactId]) -> Result<Str
.to_string())
}
/// Imports public key into the public key store.
///
/// They key may come from Autocrypt header,
/// Autocrypt-Gossip header or a vCard.
///
/// If the key with the same fingerprint already exists,
/// it is updated by merging the new key.
pub(crate) async fn import_public_key(
context: &Context,
public_key: &SignedPublicKey,
) -> Result<()> {
public_key
.verify_bindings()
.context("Attempt to import broken public key")?;
let fingerprint = public_key.dc_fingerprint().hex();
let merged_public_key;
let merged_public_key_ref = if let Some(public_key_bytes) = context
.sql
.query_row_optional(
"SELECT public_key
FROM public_keys
WHERE fingerprint=?",
(&fingerprint,),
|row| {
let bytes: Vec<u8> = row.get(0)?;
Ok(bytes)
},
)
.await?
{
let old_public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
merged_public_key = merge_openpgp_certificates(public_key.clone(), old_public_key)
.context("Failed to merge public keys")?;
&merged_public_key
} else {
public_key
};
let inserted = context
.sql
.execute(
"INSERT INTO public_keys (fingerprint, public_key)
VALUES (?, ?)
ON CONFLICT (fingerprint)
DO UPDATE SET public_key=excluded.public_key
WHERE public_key!=excluded.public_key",
(&fingerprint, merged_public_key_ref.to_bytes()),
)
.await?;
if inserted > 0 {
info!(
context,
"Saved key with fingerprint {fingerprint} from the Autocrypt header"
);
}
Ok(())
}
/// Imports contacts from the given vCard.
///
/// Returns the ids of successfully processed contacts in the order they appear in `vcard`,
@@ -414,14 +352,23 @@ async fn import_vcard_contact(context: &Context, contact: &VcardContact) -> Resu
.ok()
});
let fingerprint = if let Some(public_key) = key {
import_public_key(context, &public_key)
.await
.context("Failed to import public key from vCard")?;
public_key.dc_fingerprint().hex()
let fingerprint;
if let Some(public_key) = key {
fingerprint = public_key.dc_fingerprint().hex();
context
.sql
.execute(
"INSERT INTO public_keys (fingerprint, public_key)
VALUES (?, ?)
ON CONFLICT (fingerprint)
DO NOTHING",
(&fingerprint, public_key.to_bytes()),
)
.await?;
} else {
String::new()
};
fingerprint = String::new();
}
let (id, modified) =
match Contact::add_or_lookup_ex(context, &contact.authname, &addr, &fingerprint, origin)
@@ -688,7 +635,7 @@ impl Contact {
.await?
{
if contact_id == ContactId::SELF {
contact.name = stock_str::self_msg(context);
contact.name = stock_str::self_msg(context).await;
contact.authname = context
.get_config(Config::Displayname)
.await?
@@ -705,9 +652,9 @@ impl Contact {
.await?
.unwrap_or_default();
} else if contact_id == ContactId::DEVICE {
contact.name = stock_str::device_messages(context);
contact.name = stock_str::device_messages(context).await;
contact.addr = ContactId::DEVICE_ADDR.to_string();
contact.status = stock_str::device_messages_hint(context);
contact.status = stock_str::device_messages_hint(context).await;
}
Ok(Some(contact))
} else {
@@ -726,7 +673,6 @@ impl Contact {
}
/// Returns `true` if this contact was seen recently.
#[expect(clippy::arithmetic_side_effects)]
pub fn was_seen_recently(&self) -> bool {
time() - self.last_seen <= SEEN_RECENTLY_SECONDS
}
@@ -1125,7 +1071,6 @@ VALUES (?, ?, ?, ?, ?, ?)
/// The `addr_book` is a multiline string in the format `Name one\nAddress one\nName two\nAddress two`.
///
/// Returns the number of modified contacts.
#[expect(clippy::arithmetic_side_effects)]
pub async fn add_address_book(context: &Context, addr_book: &str) -> Result<usize> {
let mut modify_cnt = 0;
@@ -1240,7 +1185,7 @@ ORDER BY c.origin>=? DESC, c.last_seen DESC, c.id DESC
if self_addr.contains(query)
|| self_name.contains(query)
|| self_name2.contains(query)
|| self_name2.await.contains(query)
{
add_self = true;
}
@@ -1392,17 +1337,17 @@ WHERE addr=?
.unwrap_or_default();
let Some(fingerprint_other) = contact.fingerprint() else {
return Ok(stock_str::encr_none(context));
return Ok(stock_str::encr_none(context).await);
};
let fingerprint_other = fingerprint_other.to_string();
let stock_message = if contact.public_key(context).await?.is_some() {
stock_str::messages_are_e2ee(context)
stock_str::messages_e2e_encrypted(context).await
} else {
stock_str::encr_none(context)
stock_str::encr_none(context).await
};
let finger_prints = stock_str::finger_prints(context);
let finger_prints = stock_str::finger_prints(context).await;
let mut ret = format!("{stock_message}\n{finger_prints}:");
let fingerprint_self = load_self_public_key(context)
@@ -1412,7 +1357,7 @@ WHERE addr=?
if addr < contact.addr {
cat_fingerprint(
&mut ret,
&stock_str::self_msg(context),
&stock_str::self_msg(context).await,
&addr,
&fingerprint_self,
);
@@ -1431,22 +1376,12 @@ WHERE addr=?
);
cat_fingerprint(
&mut ret,
&stock_str::self_msg(context),
&stock_str::self_msg(context).await,
&addr,
&fingerprint_self,
);
}
if let Some(public_key) = contact.public_key(context).await?
&& let Some(relay_addrs) = addresses_from_public_key(&public_key)
{
ret += "\n\nRelays:";
for relay in &relay_addrs {
ret += "\n";
ret += relay;
}
}
Ok(ret)
}
@@ -1974,7 +1909,6 @@ pub(crate) async fn set_status(
}
/// Updates last seen timestamp of the contact if it is earlier than the given `timestamp`.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn update_last_seen(
context: &Context,
contact_id: ContactId,
@@ -2066,7 +2000,6 @@ 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}");
}
@@ -2108,7 +2041,6 @@ impl RecentlySeenLoop {
}
}
#[expect(clippy::arithmetic_side_effects)]
async fn run(context: Context, interrupt: Receiver<RecentlySeenInterrupt>) {
type MyHeapElem = (Reverse<i64>, ContactId);

View File

@@ -282,7 +282,7 @@ async fn test_add_or_lookup() {
// check SELF
let contact = Contact::get_by_id(&t, ContactId::SELF).await.unwrap();
assert_eq!(contact.get_name(), stock_str::self_msg(&t));
assert_eq!(contact.get_name(), stock_str::self_msg(&t).await);
assert_eq!(contact.get_addr(), "alice@example.org");
assert!(!contact.is_blocked());
}
@@ -841,10 +841,7 @@ Me (alice@example.org):
bob@example.net (bob@example.net):
CCCB 5AA9 F6E1 141C 9431
65F1 DB18 B18C BCF7 0487
Relays:
bob@example.net"
65F1 DB18 B18C BCF7 0487"
);
let contact = Contact::get_by_id(alice, contact_bob_id).await?;
assert!(contact.e2ee_avail(alice).await?);
@@ -1148,11 +1145,8 @@ async fn test_make_n_import_vcard() -> Result<()> {
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
bob.set_config(Config::Displayname, Some("Bob")).await?;
bob.set_config(
Config::Selfstatus,
Some("It's me,\nbob; and here's a backslash: \\"),
)
.await?;
bob.set_config(Config::Selfstatus, Some("It's me, bob"))
.await?;
let avatar_path = bob.dir.path().join("avatar.png");
let avatar_bytes = include_bytes!("../../test-data/image/avatar64x64.png");
let avatar_base64 = base64::engine::general_purpose::STANDARD.encode(avatar_bytes);

View File

@@ -8,9 +8,8 @@ use std::sync::atomic::AtomicBool;
use std::sync::{Arc, OnceLock, Weak};
use std::time::Duration;
use anyhow::{Result, bail, ensure};
use anyhow::{Context as _, Result, bail, ensure};
use async_channel::{self as channel, Receiver, Sender};
use pgp::composed::SignedPublicKey;
use ratelimit::Ratelimit;
use tokio::sync::{Mutex, Notify, RwLock};
@@ -234,21 +233,12 @@ pub struct InnerContext {
/// This is a global mutex-like state for operations which should be modal in the
/// clients.
running_state: RwLock<RunningState>,
/// Mutex to avoid generating the key for the user more than once.
pub(crate) generating_key_mutex: Mutex<()>,
/// Mutex to enforce only a single running oauth2 is running.
pub(crate) oauth2_mutex: Mutex<()>,
/// Mutex to prevent a race condition when a "your pw is wrong" warning is sent, resulting in multiple messages being sent.
pub(crate) wrong_pw_warning_mutex: Mutex<()>,
/// Mutex to prevent running housekeeping from multiple threads at once.
pub(crate) housekeeping_mutex: Mutex<()>,
/// Mutex to prevent multiple IMAP loops from fetching the messages at once.
///
/// Without this mutex IMAP loops may waste traffic downloading the same message
/// from multiple IMAP servers and create multiple copies of the same message
/// in the database if the check for duplicates and creating a message
/// happens in separate database transactions.
pub(crate) fetch_msgs_mutex: Mutex<()>,
pub(crate) translated_stockstrings: StockStrings,
pub(crate) events: Events,
@@ -316,13 +306,6 @@ pub struct InnerContext {
/// the standard library's OnceLock is enough, and it's a lot smaller in memory.
pub(crate) self_fingerprint: OnceLock<String>,
/// OpenPGP certificate aka Transferrable Public Key.
///
/// It is generated on first use from the secret key stored in the database.
///
/// Mutex is also held while generating the key to avoid generating the key twice.
pub(crate) self_public_key: Mutex<Option<SignedPublicKey>>,
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
/// see [`Context::get_connectivity()`].
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
@@ -359,7 +342,6 @@ enum RunningState {
/// actual keys and their values which will be present are not
/// guaranteed. Calling [Context::get_info] also includes information
/// about the context on top of the information here.
#[expect(clippy::arithmetic_side_effects)]
pub fn get_info() -> BTreeMap<&'static str, String> {
let mut res = BTreeMap::new();
@@ -492,10 +474,9 @@ impl Context {
running_state: RwLock::new(Default::default()),
sql: Sql::new(dbfile),
smeared_timestamp: SmearedTimestamp::new(),
generating_key_mutex: Mutex::new(()),
oauth2_mutex: Mutex::new(()),
wrong_pw_warning_mutex: Mutex::new(()),
housekeeping_mutex: Mutex::new(()),
fetch_msgs_mutex: Mutex::new(()),
translated_stockstrings: stockstrings,
events,
scheduler: SchedulerState::new(),
@@ -513,7 +494,6 @@ impl Context {
tls_session_store: TlsSessionStore::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(),
};
@@ -900,6 +880,10 @@ impl Context {
.get_config(Config::ConfiguredMvboxFolder)
.await?
.unwrap_or_else(|| "<unset>".to_string());
let configured_trash_folder = self
.get_config(Config::ConfiguredTrashFolder)
.await?
.unwrap_or_else(|| "<unset>".to_string());
let mut res = get_info();
@@ -984,6 +968,7 @@ impl Context {
);
res.insert("configured_inbox_folder", configured_inbox_folder);
res.insert("configured_mvbox_folder", configured_mvbox_folder);
res.insert("configured_trash_folder", configured_trash_folder);
res.insert("mdns_enabled", mdns_enabled.to_string());
res.insert("bcc_self", bcc_self.to_string());
res.insert("sync_msgs", sync_msgs.to_string());
@@ -1007,6 +992,12 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"delete_to_trash",
self.get_config(Config::DeleteToTrash)
.await?
.unwrap_or_else(|| "<unset>".to_string()),
);
res.insert(
"last_housekeeping",
self.get_config_int(Config::LastHousekeeping)
@@ -1019,6 +1010,12 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"scan_all_folders_debounce_secs",
self.get_config_int(Config::ScanAllFoldersDebounceSecs)
.await?
.to_string(),
);
res.insert(
"quota_exceeding",
self.get_config_int(Config::QuotaExceeding)
@@ -1289,6 +1286,33 @@ ORDER BY m.timestamp DESC,m.id DESC",
Ok(mvbox.as_deref() == Some(folder_name))
}
/// Returns true if given folder name is the name of the trash folder.
pub async fn is_trash(&self, folder_name: &str) -> Result<bool> {
let trash = self.get_config(Config::ConfiguredTrashFolder).await?;
Ok(trash.as_deref() == Some(folder_name))
}
pub(crate) async fn should_delete_to_trash(&self) -> Result<bool> {
if let Some(v) = self.get_config_bool_opt(Config::DeleteToTrash).await? {
return Ok(v);
}
if let Some(provider) = self.get_configured_provider().await? {
return Ok(provider.opt.delete_to_trash);
}
Ok(false)
}
/// Returns `target` for deleted messages as per `imap` table. Empty string means "delete w/o
/// moving to trash".
pub(crate) async fn get_delete_msgs_target(&self) -> Result<String> {
if !self.should_delete_to_trash().await? {
return Ok("".into());
}
self.get_config(Config::ConfiguredTrashFolder)
.await?
.context("No configured trash folder")
}
pub(crate) fn derive_blobdir(dbfile: &Path) -> PathBuf {
let mut blob_fname = OsString::new();
blob_fname.push(dbfile.file_name().unwrap_or_default());

View File

@@ -1,256 +1,34 @@
//! Helper functions for decryption.
//! The actual decryption is done in the [`crate::pgp`] module.
//! End-to-end decryption support.
use std::collections::HashSet;
use std::io::Cursor;
use anyhow::{Context as _, Result, bail};
use anyhow::Result;
use mailparse::ParsedMail;
use pgp::composed::Esk;
use pgp::composed::Message;
use pgp::composed::PlainSessionKey;
use pgp::composed::SignedSecretKey;
use pgp::composed::decrypt_session_key_with_password;
use pgp::packet::SymKeyEncryptedSessionKey;
use pgp::types::Password;
use pgp::types::StringToKey;
use crate::chat::ChatId;
use crate::constants::Chattype;
use crate::contact::ContactId;
use crate::context::Context;
use crate::key::self_fingerprint;
use crate::key::{Fingerprint, SignedPublicKey, load_self_secret_keyring};
use crate::token::Namespace;
use crate::key::{Fingerprint, SignedPublicKey, SignedSecretKey};
use crate::pgp;
/// Tries to decrypt the message,
/// returning a tuple of `(decrypted message, fingerprint)`.
/// Tries to decrypt a message, but only if it is structured as an Autocrypt message.
///
/// If the message wasn't encrypted, returns `Ok(None)`.
///
/// If the message was asymmetrically encrypted, returns `Ok((decrypted message, None))`.
///
/// If the message was symmetrically encrypted, returns `Ok((decrypted message, Some(fingerprint)))`,
/// where `fingerprint` denotes which contact is allowed to send encrypted with this symmetric secret.
/// If the message is not signed by `fingerprint`, it must be dropped.
///
/// Otherwise, Eve could send a message to Alice
/// encrypted with the symmetric secret of someone else's broadcast channel.
/// If Alice sends an answer (or read receipt),
/// then Eve would know that Alice is in the broadcast channel.
pub(crate) async fn decrypt(
context: &Context,
mail: &mailparse::ParsedMail<'_>,
) -> Result<Option<(Message<'static>, Option<String>)>> {
// `pgp::composed::Message` is huge (>4kb), so, make sure that it is in a Box when held over an await point
let Some(msg) = get_encrypted_pgp_message_boxed(mail)? else {
return Ok(None);
};
let expected_sender_fingerprint: Option<String>;
let plain = if let Message::Encrypted { esk, .. } = &*msg
// We only allow one ESK for symmetrically encrypted messages
// to avoid dealing with messages that are encrypted to multiple symmetric keys
// or a mix of symmetric and asymmetric keys:
&& let [Esk::SymKeyEncryptedSessionKey(esk)] = &esk[..]
{
check_symmetric_encryption(esk)?;
let (psk, fingerprint) = decrypt_session_key_symmetrically(context, esk)
.await
.context("decrypt_session_key_symmetrically")?;
expected_sender_fingerprint = fingerprint;
tokio::task::spawn_blocking(move || -> Result<Message<'_>> {
let plain = msg
.decrypt_with_session_key(psk)
.context("decrypt_with_session_key")?;
let plain: Message<'static> = plain.decompress()?;
Ok(plain)
})
.await??
} else {
// Message is asymmetrically encrypted
let secret_keys: Vec<SignedSecretKey> = load_self_secret_keyring(context).await?;
expected_sender_fingerprint = None;
tokio::task::spawn_blocking(move || -> Result<Message<'_>> {
let empty_pw = Password::empty();
let secret_keys: Vec<&SignedSecretKey> = secret_keys.iter().collect();
let plain = msg
.decrypt_with_keys(vec![&empty_pw], secret_keys)
.context("decrypt_with_keys")?;
let plain: Message<'static> = plain.decompress()?;
Ok(plain)
})
.await??
};
Ok(Some((plain, expected_sender_fingerprint)))
}
async fn decrypt_session_key_symmetrically(
context: &Context,
esk: &SymKeyEncryptedSessionKey,
) -> Result<(PlainSessionKey, Option<String>)> {
let self_fp = self_fingerprint(context).await?;
let query_only = true;
context
.sql
.call(query_only, |conn| {
// First, try decrypting using AUTH tokens from scanned QR codes, stored in the bobstate,
// because usually there will only be 1 or 2 of it, so, it should be fast
let res: Option<(PlainSessionKey, String)> = try_decrypt_with_bobstate(esk, conn)?;
if let Some((plain_session_key, fingerprint)) = res {
return Ok((plain_session_key, Some(fingerprint)));
}
// Then, try decrypting using broadcast secrets
let res: Option<(PlainSessionKey, Option<String>)> =
try_decrypt_with_broadcast_secret(esk, conn)?;
if let Some((plain_session_key, fingerprint)) = res {
return Ok((plain_session_key, fingerprint));
}
// Finally, try decrypting using own AUTH tokens
// There can be a lot of AUTH tokens,
// because a new one is generated every time a QR code is shown
let res: Option<PlainSessionKey> = try_decrypt_with_auth_token(esk, conn, self_fp)?;
if let Some(plain_session_key) = res {
return Ok((plain_session_key, None));
}
bail!("Could not find symmetric secret for session key")
})
.await
}
fn try_decrypt_with_bobstate(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
) -> Result<Option<(PlainSessionKey, String)>> {
let mut stmt = conn.prepare("SELECT invite FROM bobstate")?;
let mut rows = stmt.query(())?;
while let Some(row) = rows.next()? {
let invite: crate::securejoin::QrInvite = row.get(0)?;
let authcode = invite.authcode().to_string();
let alice_fp = invite.fingerprint().hex();
let shared_secret = format!("securejoin/{alice_fp}/{authcode}");
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(shared_secret)) {
let fingerprint = invite.fingerprint().hex();
return Ok(Some((psk, fingerprint)));
}
}
Ok(None)
}
fn try_decrypt_with_broadcast_secret(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
) -> Result<Option<(PlainSessionKey, Option<String>)>> {
let Some((psk, chat_id)) = try_decrypt_with_broadcast_secret_inner(esk, conn)? else {
return Ok(None);
};
let chat_type: Chattype =
conn.query_one("SELECT type FROM chats WHERE id=?", (chat_id,), |row| {
row.get(0)
})?;
let fp: Option<String> = if chat_type == Chattype::OutBroadcast {
// An attacker who knows the secret will also know who owns it,
// and it's easiest code-wise to just return None here.
// But we could alternatively return the self fingerprint here
None
} else if chat_type == Chattype::InBroadcast {
let contact_id: ContactId = conn
.query_one(
"SELECT contact_id FROM chats_contacts WHERE chat_id=? AND contact_id>9",
(chat_id,),
|row| row.get(0),
)
.context("Find InBroadcast owner")?;
let fp = conn
.query_one(
"SELECT fingerprint FROM contacts WHERE id=?",
(contact_id,),
|row| row.get(0),
)
.context("Find owner fingerprint")?;
Some(fp)
} else {
bail!("Chat {chat_id} is not a broadcast but {chat_type}")
};
Ok(Some((psk, fp)))
}
fn try_decrypt_with_broadcast_secret_inner(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
) -> Result<Option<(PlainSessionKey, ChatId)>> {
let mut stmt = conn.prepare("SELECT secret, chat_id FROM broadcast_secrets")?;
let mut rows = stmt.query(())?;
while let Some(row) = rows.next()? {
let secret: String = row.get(0)?;
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(secret)) {
let chat_id: ChatId = row.get(1)?;
return Ok(Some((psk, chat_id)));
}
}
Ok(None)
}
fn try_decrypt_with_auth_token(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
self_fingerprint: &str,
) -> Result<Option<PlainSessionKey>> {
// ORDER BY id DESC to query the most-recently saved tokens are returned first.
// This improves performance when Bob scans a QR code that was just created.
let mut stmt = conn.prepare("SELECT token FROM tokens WHERE namespc=? ORDER BY id DESC")?;
let mut rows = stmt.query((Namespace::Auth,))?;
while let Some(row) = rows.next()? {
let token: String = row.get(0)?;
let shared_secret = format!("securejoin/{self_fingerprint}/{token}");
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(shared_secret)) {
return Ok(Some(psk));
}
}
Ok(None)
}
/// Returns Ok(()) if we want to try symmetrically decrypting the message,
/// and Err with a reason if symmetric decryption should not be tried.
///
/// A DoS attacker could send a message with a lot of encrypted session keys,
/// all of which use a very hard-to-compute string2key algorithm.
/// We would then try to decrypt all of the encrypted session keys
/// with all of the known shared secrets.
/// In order to prevent this, we do not try to symmetrically decrypt messages
/// that use a string2key algorithm other than 'Salted'.
pub(crate) fn check_symmetric_encryption(esk: &SymKeyEncryptedSessionKey) -> Result<()> {
match esk.s2k() {
Some(StringToKey::Salted { .. }) => Ok(()),
_ => bail!("unsupported string2key algorithm"),
}
}
/// Turns a [`ParsedMail`] into [`pgp::composed::Message`].
/// [`pgp::composed::Message`] is huge (over 4kb),
/// so, it is put on the heap using [`Box`].
pub fn get_encrypted_pgp_message_boxed<'a>(
/// If successful and the message was encrypted,
/// returns the decrypted and decompressed message.
pub fn try_decrypt<'a>(
mail: &'a ParsedMail<'a>,
) -> Result<Option<Box<Message<'static>>>> {
private_keyring: &'a [SignedSecretKey],
shared_secrets: &[String],
) -> Result<Option<::pgp::composed::Message<'static>>> {
let Some(encrypted_data_part) = get_encrypted_mime(mail) else {
return Ok(None);
};
let data = encrypted_data_part.get_body_raw()?;
let cursor = Cursor::new(data);
let (msg, _headers) = Message::from_armor(cursor)?;
Ok(Some(Box::new(msg)))
let msg = pgp::decrypt(data, private_keyring, shared_secrets)?;
Ok(Some(msg))
}
/// Returns a reference to the encrypted payload of a message.
pub fn get_encrypted_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
pub(crate) fn get_encrypted_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
get_autocrypt_mime(mail)
.or_else(|| get_mixed_up_mime(mail))
.or_else(|| get_attachment_mime(mail))
@@ -353,10 +131,8 @@ pub(crate) fn validate_detached_signature<'a, 'b>(
// First part is the content, second part is the signature.
let content = first_part.raw_bytes;
let ret_valid_signatures = match second_part.get_body_raw() {
Ok(signature) => {
crate::pgp::pk_validate(content, &signature, public_keyring_for_validate)
.unwrap_or_default()
}
Ok(signature) => pgp::pk_validate(content, &signature, public_keyring_for_validate)
.unwrap_or_default(),
Err(_) => Default::default(),
};
Some((first_part, ret_valid_signatures))

View File

@@ -235,7 +235,6 @@ fn str_cb(event_str: &str, dehtml: &mut Dehtml) {
}
}
#[expect(clippy::arithmetic_side_effects)]
fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
let tag = String::from_utf8_lossy(event.name().as_ref())
.trim()
@@ -266,18 +265,21 @@ fn dehtml_endtag_cb(event: &BytesEnd, dehtml: &mut Dehtml) {
}
}
}
"b" | "strong" if dehtml.get_add_text() != AddText::No => {
*dehtml.get_buf() += "*";
"b" | "strong" => {
if dehtml.get_add_text() != AddText::No {
*dehtml.get_buf() += "*";
}
}
"i" | "em" if dehtml.get_add_text() != AddText::No => {
*dehtml.get_buf() += "_";
"i" | "em" => {
if dehtml.get_add_text() != AddText::No {
*dehtml.get_buf() += "_";
}
}
"blockquote" => pop_tag(&mut dehtml.blockquotes_since_blockquote),
_ => {}
}
}
#[expect(clippy::arithmetic_side_effects)]
fn dehtml_starttag_cb<B: std::io::BufRead>(
event: &BytesStart,
dehtml: &mut Dehtml,
@@ -337,11 +339,15 @@ fn dehtml_starttag_cb<B: std::io::BufRead>(
}
}
}
"b" | "strong" if dehtml.get_add_text() != AddText::No => {
*dehtml.get_buf() += "*";
"b" | "strong" => {
if dehtml.get_add_text() != AddText::No {
*dehtml.get_buf() += "*";
}
}
"i" | "em" if dehtml.get_add_text() != AddText::No => {
*dehtml.get_buf() += "_";
"i" | "em" => {
if dehtml.get_add_text() != AddText::No {
*dehtml.get_buf() += "_";
}
}
"blockquote" => dehtml.blockquotes_since_blockquote += 1,
_ => {}
@@ -350,7 +356,6 @@ fn dehtml_starttag_cb<B: std::io::BufRead>(
/// In order to know when a specific tag is closed, we need to count the opening and closing tags.
/// The `counts`s are stored in the `Dehtml` struct.
#[expect(clippy::arithmetic_side_effects)]
fn pop_tag(count: &mut u32) {
if *count > 0 {
*count -= 1;
@@ -359,7 +364,6 @@ fn pop_tag(count: &mut u32) {
/// In order to know when a specific tag is closed, we need to count the opening and closing tags.
/// The `counts`s are stored in the `Dehtml` struct.
#[expect(clippy::arithmetic_side_effects)]
fn maybe_push_tag(
event: &BytesStart,
reader: &Reader<impl BufRead>,

View File

@@ -99,8 +99,7 @@ impl MsgId {
Ok(())
}
/// Updates the message download state. Returns `Ok` if the message doesn't exist anymore or has
/// the download state up to date.
/// Updates the message download state. Returns `Ok` if the message doesn't exist anymore.
pub(crate) async fn update_download_state(
self,
context: &Context,
@@ -109,7 +108,7 @@ impl MsgId {
if context
.sql
.execute(
"UPDATE msgs SET download_state=? WHERE id=? AND download_state<>?1",
"UPDATE msgs SET download_state=? WHERE id=?;",
(download_state, self),
)
.await?
@@ -136,47 +135,42 @@ impl Message {
}
}
/// Actually downloads a message partially downloaded before if the message is available on the
/// session transport, in which case returns `Some`. If the message is available on another
/// transport, returns `None`.
/// Actually download a message partially downloaded before.
///
/// Most messages are downloaded automatically on fetch instead.
pub(crate) async fn download_msg(
context: &Context,
rfc724_mid: String,
session: &mut Session,
) -> Result<Option<()>> {
) -> Result<()> {
let transport_id = session.transport_id();
let row = context
.sql
.query_row_optional(
"SELECT uid, folder, transport_id FROM imap
WHERE rfc724_mid=? AND target!=''
ORDER BY transport_id=? DESC LIMIT 1",
"SELECT uid, folder FROM imap
WHERE rfc724_mid=?
AND transport_id=?
AND target!=''",
(&rfc724_mid, transport_id),
|row| {
let server_uid: u32 = row.get(0)?;
let server_folder: String = row.get(1)?;
let msg_transport_id: u32 = row.get(2)?;
Ok((server_uid, server_folder, msg_transport_id))
Ok((server_uid, server_folder))
},
)
.await?;
let Some((server_uid, server_folder, msg_transport_id)) = row else {
let Some((server_uid, server_folder)) = row else {
// No IMAP record found, we don't know the UID and folder.
delete_from_available_post_msgs(context, &rfc724_mid).await?;
return Err(anyhow!(
"IMAP location for {rfc724_mid:?} post-message is unknown"
));
};
if msg_transport_id != transport_id {
return Ok(None);
}
session
.fetch_single_msg(context, &server_folder, server_uid, rfc724_mid)
.await?;
Ok(Some(()))
Ok(())
}
impl Session {
@@ -278,7 +272,7 @@ pub(crate) async fn download_msgs(context: &Context, session: &mut Session) -> R
for rfc724_mid in &rfc724_mids {
let res = download_msg(context, rfc724_mid.clone(), session).await;
if let Ok(Some(())) = res {
if res.is_ok() {
delete_from_downloads(context, rfc724_mid).await?;
delete_from_available_post_msgs(context, rfc724_mid).await?;
}
@@ -327,25 +321,22 @@ pub(crate) async fn download_known_post_messages_without_pre_message(
})
.await?;
for rfc724_mid in &rfc724_mids {
if msg_is_downloaded_for(context, rfc724_mid).await? {
delete_from_available_post_msgs(context, rfc724_mid).await?;
continue;
}
// Download the Post-Message unconditionally,
// because the Pre-Message got lost.
// The message may be in the wrong order,
// but at least we have it at all.
let res = download_msg(context, rfc724_mid.clone(), session).await;
if let Ok(Some(())) = res {
delete_from_available_post_msgs(context, rfc724_mid).await?;
}
if let Err(err) = res {
warn!(
context,
"download_known_post_messages_without_pre_message: Failed to download message rfc724_mid={rfc724_mid}: {:#}.",
err
);
if !msg_is_downloaded_for(context, rfc724_mid).await? {
// Download the Post-Message unconditionally,
// because the Pre-Message got lost.
// The message may be in the wrong order,
// but at least we have it at all.
let res = download_msg(context, rfc724_mid.clone(), session).await;
if res.is_ok() {
delete_from_available_post_msgs(context, rfc724_mid).await?;
}
if let Err(err) = res {
warn!(
context,
"download_known_post_messages_without_pre_message: Failed to download message rfc724_mid={rfc724_mid}: {:#}.",
err
);
}
}
}
Ok(())

View File

@@ -41,14 +41,6 @@ impl PostMsgMetadata {
.get(Param::Filename)
.unwrap_or_default()
.to_owned();
let filename = match message.viewtype {
Viewtype::Webxdc => message
.get_webxdc_info(context)
.await
.map(|info| info.name)
.unwrap_or_else(|_| filename),
_ => filename,
};
let wh = {
match (
message.param.get_int(Param::Width),

View File

@@ -40,6 +40,7 @@ impl EncryptHelper {
keyring: Vec<SignedPublicKey>,
mail_to_encrypt: MimePart<'static>,
compress: bool,
anonymous_recipients: bool,
seipd_version: SeipdVersion,
) -> Result<String> {
let sign_key = load_self_secret_key(context).await?;
@@ -48,8 +49,15 @@ impl EncryptHelper {
let cursor = Cursor::new(&mut raw_message);
mail_to_encrypt.clone().write_part(cursor).ok();
let ctext =
pgp::pk_encrypt(raw_message, keyring, sign_key, compress, seipd_version).await?;
let ctext = pgp::pk_encrypt(
raw_message,
keyring,
sign_key,
compress,
anonymous_recipients,
seipd_version,
)
.await?;
Ok(ctext)
}
@@ -62,13 +70,8 @@ impl EncryptHelper {
shared_secret: &str,
mail_to_encrypt: MimePart<'static>,
compress: bool,
sign: bool,
) -> Result<String> {
let sign_key = if sign {
Some(load_self_secret_key(context).await?)
} else {
None
};
let sign_key = load_self_secret_key(context).await?;
let mut raw_message = Vec::new();
let cursor = Cursor::new(&mut raw_message);

View File

@@ -593,7 +593,6 @@ async fn next_expiration_timestamp(context: &Context) -> Option<i64> {
.min()
}
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiver<()>) {
loop {
let ephemeral_timestamp = next_expiration_timestamp(context).await;
@@ -651,7 +650,6 @@ pub(crate) async fn ephemeral_loop(context: &Context, interrupt_receiver: Receiv
}
/// Schedules expired IMAP messages for deletion.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn delete_expired_imap_messages(context: &Context) -> Result<()> {
let now = time();
@@ -667,19 +665,25 @@ pub(crate) async fn delete_expired_imap_messages(context: &Context) -> Result<()
now - max(delete_server_after, MIN_DELETE_SERVER_AFTER),
),
};
let target = context.get_delete_msgs_target().await?;
context
.sql
.execute(
"UPDATE imap
SET target=''
SET target=?
WHERE rfc724_mid IN (
SELECT rfc724_mid FROM msgs
WHERE ((download_state = 0 AND timestamp < ?) OR
(download_state != 0 AND timestamp < ?) OR
(ephemeral_timestamp != 0 AND ephemeral_timestamp <= ?))
)",
(threshold_timestamp, threshold_timestamp_extended, now),
(
&target,
threshold_timestamp,
threshold_timestamp_extended,
now,
),
)
.await?;

View File

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

View File

@@ -57,7 +57,7 @@ pub enum EventType {
/// should not be disturbed by a dialog or so. Instead, use a bubble or so.
///
/// However, for ongoing processes (eg. configure())
/// or for functions that are expected to fail
/// or for functions that are expected to fail (eg. dc_continue_key_transfer())
/// it might be better to delay showing these events until the function has really
/// failed (returned false). It should be sufficient to report only the *last* error
/// in a message box then.
@@ -397,8 +397,6 @@ pub enum EventType {
msg_id: MsgId,
/// ID of the chat which the message belongs to.
chat_id: ChatId,
/// The call was accepted from this device (process).
from_this_device: bool,
},
/// Outgoing call accepted.

View File

@@ -60,9 +60,6 @@ pub enum HeaderDef {
ChatGroupName,
ChatGroupNameChanged,
ChatGroupNameTimestamp,
ChatGroupDescription,
ChatGroupDescriptionChanged,
ChatGroupDescriptionTimestamp,
ChatVerified,
ChatGroupAvatar,
ChatUserAvatar,
@@ -94,7 +91,6 @@ pub enum HeaderDef {
ChatDispositionNotificationTo,
ChatWebrtcRoom,
ChatWebrtcAccepted,
ChatWebrtcHasVideoInitially,
/// This message deletes the messages listed in the value by rfc724_mid.
ChatDelete,
@@ -125,6 +121,7 @@ pub enum HeaderDef {
/// [Autocrypt](https://autocrypt.org/) header.
Autocrypt,
AutocryptGossip,
AutocryptSetupMessage,
SecureJoin,
/// Deprecated header containing Group-ID in `vg-request-with-auth` message.
@@ -207,10 +204,10 @@ mod tests {
/// Test that headers are parsed case-insensitively
fn test_get_header_value_case() {
let (headers, _) =
mailparse::parse_headers(b"fRoM: Bob\naUtoCryPt-GoSsIp: fooBaR").unwrap();
mailparse::parse_headers(b"fRoM: Bob\naUtoCryPt-SeTup-MessAge: v99").unwrap();
assert_eq!(
headers.get_header_value(HeaderDef::AutocryptGossip),
Some("fooBaR".to_string())
headers.get_header_value(HeaderDef::AutocryptSetupMessage),
Some("v99".to_string())
);
assert_eq!(
headers.get_header_value(HeaderDef::From_),

View File

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

View File

@@ -6,7 +6,7 @@
use std::{
cmp::max,
cmp::min,
collections::{BTreeMap, HashMap},
collections::{BTreeMap, BTreeSet, HashMap},
iter::Peekable,
mem::take,
sync::atomic::Ordering,
@@ -18,21 +18,23 @@ use async_channel::{self, Receiver, Sender};
use async_imap::types::{Fetch, Flag, Name, NameAttribute, UnsolicitedResponse};
use futures::{FutureExt as _, TryStreamExt};
use futures_lite::FutureExt;
use num_traits::FromPrimitive;
use ratelimit::Ratelimit;
use url::Url;
use crate::calls::{
UnresolvedIceServer, create_fallback_ice_servers, create_ice_servers_from_metadata,
};
use crate::chat::{self, ChatIdBlocked, add_device_msg};
use crate::chat::{self, ChatId, ChatIdBlocked, add_device_msg};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::{self, Blocked, DC_VERSION_STR};
use crate::constants::{self, Blocked, DC_VERSION_STR, ShowEmails};
use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
use crate::headerdef::{HeaderDef, HeaderDefMap};
use crate::log::{LogExt, warn};
use crate::message::{self, Message, MessengerMessage};
use crate::message::{self, Message, MessageState, MessengerMessage, MsgId};
use crate::mimeparser;
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionStream;
@@ -51,6 +53,7 @@ use crate::transport::{
pub(crate) mod capabilities;
mod client;
mod idle;
pub mod scan_folders;
pub mod select_folder;
pub(crate) mod session;
@@ -180,7 +183,7 @@ impl FolderMeaning {
FolderMeaning::Spam => None,
FolderMeaning::Inbox => Some(Config::ConfiguredInboxFolder),
FolderMeaning::Mvbox => Some(Config::ConfiguredMvboxFolder),
FolderMeaning::Trash => None,
FolderMeaning::Trash => Some(Config::ConfiguredTrashFolder),
FolderMeaning::Virtual => None,
}
}
@@ -206,7 +209,6 @@ impl<T: Iterator<Item = (i64, u32, String)>> Iterator for UidGrouper<T> {
// Tuple of folder, row IDs, and UID range as a string.
type Item = (String, Vec<i64>, String);
#[expect(clippy::arithmetic_side_effects)]
fn next(&mut self) -> Option<Self::Item> {
let (_, _, folder) = self.inner.peek().cloned()?;
@@ -295,11 +297,6 @@ impl Imap {
Ok(imap)
}
/// Returns transport ID of the IMAP client.
pub fn transport_id(&self) -> u32 {
self.transport_id
}
/// Connects to IMAP server and returns a new IMAP session.
///
/// Calling this function is not enough to perform IMAP operations. Use [`Imap::prepare`]
@@ -360,10 +357,10 @@ impl Imap {
context,
self.proxy_config.clone(),
self.strict_tls,
&connection_candidate,
connection_candidate,
)
.await
.with_context(|| format!("IMAP failed to connect to {connection_candidate}"))
.context("IMAP failed to connect")
{
Ok(client) => client,
Err(err) => {
@@ -441,7 +438,7 @@ impl Imap {
Err(err) => {
let imap_user = lp.user.to_owned();
let message = stock_str::cannot_login(context, &imap_user);
let message = stock_str::cannot_login(context, &imap_user).await;
warn!(context, "IMAP failed to login: {err:#}.");
first_error.get_or_insert(format_err!("{message} ({err:#})"));
@@ -547,7 +544,6 @@ impl Imap {
/// Fetches new messages.
///
/// Returns true if at least one message was fetched.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn fetch_new_messages(
&mut self,
context: &Context,
@@ -555,13 +551,8 @@ impl Imap {
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:?}."
);
info!(context, "Not fetching from {folder:?}.");
session.new_mail = false;
return Ok(false);
}
@@ -570,22 +561,16 @@ impl Imap {
.select_with_uidvalidity(context, folder)
.await
.with_context(|| format!("Failed to select folder {folder:?}"))?;
if !session.new_mail {
info!(
context,
"Transport {transport_id}: No new emails in folder {folder:?}."
);
return Ok(false);
}
// Make sure not to return before setting new_mail to false
// Otherwise, we will skip IDLE and go into an infinite loop
session.new_mail = false;
if !folder_exists {
return Ok(false);
}
if !session.new_mail {
info!(context, "No new emails in folder {folder:?}.");
return Ok(false);
}
session.new_mail = false;
let mut read_cnt = 0;
loop {
let (n, fetch_more) = self
@@ -599,7 +584,6 @@ impl Imap {
}
/// Returns number of messages processed and whether the function should be called again.
#[expect(clippy::arithmetic_side_effects)]
async fn fetch_new_msg_batch(
&mut self,
context: &Context,
@@ -621,13 +605,13 @@ impl Imap {
.await
.context("prefetch")?;
let read_cnt = msgs.len();
let _fetch_msgs_lock_guard = context.fetch_msgs_mutex.lock().await;
let mut uids_fetch: Vec<u32> = Vec::new();
let mut available_post_msgs: Vec<String> = Vec::new();
let mut download_later: Vec<String> = Vec::new();
let mut uid_message_ids = BTreeMap::new();
let mut largest_uid_skipped = None;
let delete_target = context.get_delete_msgs_target().await?;
let download_limit: Option<u32> = context
.get_config_parsed(Config::DownloadLimit)
@@ -677,7 +661,7 @@ impl Imap {
let _target;
let target = if delete {
""
&delete_target
} else {
_target = target_folder(context, folder, folder_meaning, &headers).await?;
&_target
@@ -1004,6 +988,17 @@ impl Session {
return Ok(());
}
Err(err) => {
if context.should_delete_to_trash().await? {
error!(
context,
"Cannot move messages {} to {}, no fallback to COPY/DELETE because \
delete_to_trash is set. Error: {:#}",
set,
target,
err,
);
return Err(err.into());
}
warn!(
context,
"Cannot move messages, fallback to COPY/DELETE {} to {}: {}",
@@ -1017,11 +1012,19 @@ impl Session {
// Server does not support MOVE or MOVE failed.
// Copy messages to the destination folder if needed and mark records for deletion.
info!(
context,
"Server does not support MOVE, fallback to COPY/DELETE {} to {}", set, target
);
self.uid_copy(&set, &target).await?;
let copy = !context.is_trash(target).await?;
if copy {
info!(
context,
"Server does not support MOVE, fallback to COPY/DELETE {} to {}", set, target
);
self.uid_copy(&set, &target).await?;
} else {
error!(
context,
"Server does not support MOVE, fallback to DELETE {} to {}", set, target,
);
}
context
.sql
.transaction(|transaction| {
@@ -1033,9 +1036,11 @@ impl Session {
})
.await
.context("Cannot plan deletion of messages")?;
context.emit_event(EventType::ImapMessageMoved(format!(
"IMAP messages {set} copied to {target}"
)));
if copy {
context.emit_event(EventType::ImapMessageMoved(format!(
"IMAP messages {set} copied to {target}"
)));
}
Ok(())
}
@@ -1102,22 +1107,15 @@ impl Session {
return Ok(());
}
context
.sql
.execute(
"DELETE FROM imap_markseen WHERE id NOT IN (SELECT imap.id FROM imap)",
(),
)
.await?;
let transport_id = self.transport_id();
let mut rows = context
let rows = context
.sql
.query_map_vec(
"SELECT imap.id, uid, folder FROM imap, imap_markseen
WHERE imap.id = imap_markseen.id
AND imap.transport_id=?
AND target = folder",
AND target = folder
ORDER BY folder, uid",
(transport_id,),
|row| {
let rowid: i64 = row.get(0)?;
@@ -1128,16 +1126,6 @@ impl Session {
)
.await?;
// Number of SQL results is expected to be low as
// we usually don't have many messages to mark on IMAP at once.
// We are sorting outside of SQL to avoid SQLite constructing a query plan
// that scans the whole `imap` table. Scanning `imap_markseen` is fine
// as it should not have many items.
// If you change the SQL query, test it with `EXPLAIN QUERY PLAN`.
rows.sort_unstable_by(|(_rowid1, uid1, folder1), (_rowid2, uid2, folder2)| {
(folder1, uid1).cmp(&(folder2, uid2))
});
for (folder, rowid_set, uid_set) in UidGrouper::from(rows) {
let folder_exists = match self.select_with_uidvalidity(context, &folder).await {
Err(err) => {
@@ -1179,6 +1167,113 @@ impl Session {
Ok(())
}
/// Synchronizes `\Seen` flags using `CONDSTORE` extension.
pub(crate) async fn sync_seen_flags(&mut self, context: &Context, folder: &str) -> Result<()> {
if !self.can_condstore() {
info!(
context,
"Server does not support CONDSTORE, skipping flag synchronization."
);
return Ok(());
}
if context.get_config_bool(Config::TeamProfile).await? {
return Ok(());
}
let folder_exists = self
.select_with_uidvalidity(context, folder)
.await
.context("Failed to select folder")?;
if !folder_exists {
return Ok(());
}
let mailbox = self
.selected_mailbox
.as_ref()
.with_context(|| format!("No mailbox selected, folder: {folder}"))?;
// Check if the mailbox supports MODSEQ.
// We are not interested in actual value of HIGHESTMODSEQ.
if mailbox.highest_modseq.is_none() {
info!(
context,
"Mailbox {} does not support mod-sequences, skipping flag synchronization.", folder
);
return Ok(());
}
let transport_id = self.transport_id();
let mut updated_chat_ids = BTreeSet::new();
let uid_validity = get_uidvalidity(context, transport_id, folder)
.await
.with_context(|| format!("failed to get UID validity for folder {folder}"))?;
let mut highest_modseq = get_modseq(context, transport_id, folder)
.await
.with_context(|| format!("failed to get MODSEQ for folder {folder}"))?;
let mut list = self
.uid_fetch("1:*", format!("(FLAGS) (CHANGEDSINCE {highest_modseq})"))
.await
.context("failed to fetch flags")?;
let mut got_unsolicited_fetch = false;
while let Some(fetch) = list
.try_next()
.await
.context("failed to get FETCH result")?
{
let uid = if let Some(uid) = fetch.uid {
uid
} else {
info!(context, "FETCH result contains no UID, skipping");
got_unsolicited_fetch = true;
continue;
};
let is_seen = fetch.flags().any(|flag| flag == Flag::Seen);
if is_seen
&& let Some(chat_id) = mark_seen_by_uid(context, transport_id, folder, uid_validity, uid)
.await
.with_context(|| {
format!("Transport {transport_id}: Failed to update seen status for msg {folder}/{uid}")
})?
{
updated_chat_ids.insert(chat_id);
}
if let Some(modseq) = fetch.modseq {
if modseq > highest_modseq {
highest_modseq = modseq;
}
} else {
warn!(context, "FETCH result contains no MODSEQ");
}
}
drop(list);
if got_unsolicited_fetch {
// We got unsolicited FETCH, which means some flags
// have been modified while our request was in progress.
// We may or may not have these new flags as a part of the response,
// so better skip next IDLE and do another round of flag synchronization.
self.new_mail = true;
}
set_modseq(context, transport_id, folder, highest_modseq)
.await
.with_context(|| format!("failed to set MODSEQ for folder {folder}"))?;
if !updated_chat_ids.is_empty() {
context.on_archived_chats_maybe_noticed();
}
for updated_chat_id in updated_chat_ids {
context.emit_event(EventType::MsgsNoticed(updated_chat_id));
chatlist_events::emit_chatlist_item_changed(context, updated_chat_id);
}
Ok(())
}
/// Fetches a list of messages by server UID.
///
/// Sends pairs of UID and info about each downloaded message to the provided channel.
@@ -1193,7 +1288,6 @@ impl Session {
///
/// If the message is incorrect or there is a failure to write a message to the database,
/// it is skipped and the error is logged.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn fetch_many_msgs(
&mut self,
context: &Context,
@@ -1358,7 +1452,6 @@ impl Session {
/// We get [`/shared/comment`](https://www.rfc-editor.org/rfc/rfc5464#section-6.2.1)
/// and [`/shared/admin`](https://www.rfc-editor.org/rfc/rfc5464#section-6.2.2)
/// metadata.
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn update_metadata(&mut self, context: &Context) -> Result<()> {
let mut lock = context.metadata.write().await;
@@ -1487,18 +1580,11 @@ impl Session {
return Ok(());
}
let transport_id = self.transport_id();
let Some(device_token) = context.push_subscriber.device_token().await else {
return Ok(());
};
if self.can_metadata() && self.can_push() {
info!(
context,
"Transport {transport_id}: Subscribing for push notifications."
);
let old_encrypted_device_token =
context.get_config(Config::EncryptedDeviceToken).await?;
@@ -1906,6 +1992,15 @@ async fn needs_move_to_mvbox(
return Ok(false);
}
if headers
.get_header_value(HeaderDef::AutocryptSetupMessage)
.is_some()
{
// do not move setup messages;
// there may be a non-delta device that wants to handle it
return Ok(false);
}
if has_chat_version {
Ok(true)
} else if let Some(parent) = get_prefetch_parent_message(context, headers).await? {
@@ -2054,11 +2149,16 @@ pub(crate) async fn prefetch_should_download(
false
};
// Autocrypt Setup Message should be shown even if it is from non-chat client.
let is_autocrypt_setup_message = headers
.get_header_value(HeaderDef::AutocryptSetupMessage)
.is_some();
let from = match mimeparser::get_from(headers) {
Some(f) => f,
None => return Ok(false),
};
let (_from_id, blocked_contact, _origin) =
let (_from_id, blocked_contact, origin) =
match from_field_to_contact_id(context, &from, None, true, true).await? {
Some(res) => res,
None => return Ok(false),
@@ -2071,10 +2171,97 @@ pub(crate) async fn prefetch_should_download(
return Ok(false);
}
let should_download = (!blocked_contact) || maybe_ndn;
let is_chat_message = headers.get_header_value(HeaderDef::ChatVersion).is_some();
let accepted_contact = origin.is_known();
let is_reply_to_chat_message = get_prefetch_parent_message(context, headers)
.await?
.map(|parent| match parent.is_dc_message {
MessengerMessage::No => false,
MessengerMessage::Yes | MessengerMessage::Reply => true,
})
.unwrap_or_default();
let show_emails =
ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?).unwrap_or_default();
let show = is_autocrypt_setup_message
|| match show_emails {
ShowEmails::Off => is_chat_message || is_reply_to_chat_message,
ShowEmails::AcceptedContacts => {
is_chat_message || is_reply_to_chat_message || accepted_contact
}
ShowEmails::All => true,
};
let should_download = (show && !blocked_contact) || maybe_ndn;
Ok(should_download)
}
/// Marks messages in `msgs` table as seen, searching for them by UID.
///
/// Returns updated chat ID if any message was marked as seen.
async fn mark_seen_by_uid(
context: &Context,
transport_id: u32,
folder: &str,
uid_validity: u32,
uid: u32,
) -> Result<Option<ChatId>> {
if let Some((msg_id, chat_id)) = context
.sql
.query_row_optional(
"SELECT id, chat_id FROM msgs
WHERE id > 9 AND rfc724_mid IN (
SELECT rfc724_mid FROM imap
WHERE transport_id=?
AND folder=?
AND uidvalidity=?
AND uid=?
LIMIT 1
)",
(transport_id, &folder, uid_validity, uid),
|row| {
let msg_id: MsgId = row.get(0)?;
let chat_id: ChatId = row.get(1)?;
Ok((msg_id, chat_id))
},
)
.await
.with_context(|| format!("failed to get msg and chat ID for IMAP message {folder}/{uid}"))?
{
let updated = context
.sql
.execute(
"UPDATE msgs SET state=?1
WHERE (state=?2 OR state=?3)
AND id=?4",
(
MessageState::InSeen,
MessageState::InFresh,
MessageState::InNoticed,
msg_id,
),
)
.await
.with_context(|| format!("failed to update msg {msg_id} state"))?
> 0;
if updated {
msg_id
.start_ephemeral_timer(context)
.await
.with_context(|| format!("failed to start ephemeral timer for message {msg_id}"))?;
Ok(Some(chat_id))
} else {
// Message state has not changed.
Ok(None)
}
} else {
// There is no message is `msgs` table matching the given UID.
Ok(None)
}
}
/// Schedule marking the message as Seen on IMAP by adding all known IMAP messages corresponding to
/// the given Message-ID to `imap_markseen` table.
pub(crate) async fn markseen_on_imap_table(context: &Context, message_id: &str) -> Result<()> {
@@ -2172,6 +2359,17 @@ pub(crate) async fn set_modseq(
Ok(())
}
async fn get_modseq(context: &Context, transport_id: u32, folder: &str) -> Result<u64> {
Ok(context
.sql
.query_get_value(
"SELECT modseq FROM imap_sync WHERE transport_id=? AND folder=?",
(transport_id, folder),
)
.await?
.unwrap_or(0))
}
/// Whether to ignore fetching messages from a folder.
///
/// This caters for the [`Config::OnlyFetchMvbox`] setting which means mails from folders
@@ -2190,7 +2388,6 @@ async fn should_ignore_folder(
/// Builds a list of sequence/uid sets. The returned sets have each no more than around 1000
/// characters because according to <https://tools.ietf.org/html/rfc2683#section-3.2.1.5>
/// command lines should not be much more than 1000 chars (servers should allow at least 8000 chars)
#[expect(clippy::arithmetic_side_effects)]
fn build_sequence_sets(uids: &[u32]) -> Result<Vec<(Vec<u32>, String)>> {
// first, try to find consecutive ranges:
let mut ranges: Vec<UidRange> = vec![];
@@ -2246,23 +2443,5 @@ 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

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

View File

@@ -27,8 +27,6 @@ impl Session {
idle_interrupt_receiver: Receiver<()>,
folder: &str,
) -> Result<Self> {
let transport_id = self.transport_id();
self.select_with_uidvalidity(context, folder).await?;
if self.drain_unsolicited_responses(context)? {
@@ -38,16 +36,13 @@ impl Session {
if self.new_mail {
info!(
context,
"Transport {transport_id}: Skipping IDLE in {folder:?} because there may be new mail."
"Skipping IDLE in {folder:?} because there may be new mail."
);
return Ok(self);
}
if let Ok(()) = idle_interrupt_receiver.try_recv() {
info!(
context,
"Transport {transport_id}: Skip IDLE in {folder:?} because we got interrupt."
);
info!(context, "Skip IDLE in {folder:?} because we got interrupt.");
return Ok(self);
}
@@ -66,7 +61,7 @@ impl Session {
info!(
context,
"Transport {transport_id}: IDLE entering wait-on-remote state in folder {folder:?}."
"IDLE entering wait-on-remote state in folder {folder:?}."
);
// Spawn a task to relay interrupts from `idle_interrupt_receiver`

View File

@@ -1,5 +1,4 @@
use super::*;
use crate::chat::ChatId;
use crate::contact::Contact;
use crate::test_utils::TestContext;
@@ -106,9 +105,10 @@ async fn check_target_folder_combination(
expected_destination: &str,
accepted_chat: bool,
outgoing: bool,
setupmessage: bool,
) -> Result<()> {
println!(
"Testing: For folder {folder}, mvbox_move {mvbox_move}, 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}, setupmessage {setupmessage}"
);
let t = TestContext::new_alice().await;
@@ -125,7 +125,9 @@ async fn check_target_folder_combination(
}
let temp;
let bytes = {
let bytes = if setupmessage {
include_bytes!("../../test-data/message/AutocryptSetupMessage.eml")
} else {
temp = format!(
"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
{}\
@@ -162,7 +164,7 @@ async fn check_target_folder_combination(
assert_eq!(
expected,
actual.as_deref(),
"For folder {folder}, mvbox_move {mvbox_move}, 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}, setupmessage {setupmessage}: expected {expected:?}, got {actual:?}"
);
Ok(())
}
@@ -202,6 +204,7 @@ async fn test_target_folder_incoming_accepted() -> Result<()> {
expected_destination,
true,
false,
false,
)
.await?;
}
@@ -218,6 +221,7 @@ async fn test_target_folder_incoming_request() -> Result<()> {
expected_destination,
false,
false,
false,
)
.await?;
}
@@ -235,6 +239,25 @@ async fn test_target_folder_outgoing() -> Result<()> {
expected_destination,
true,
true,
false,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_setupmsg() -> Result<()> {
// Test setupmessages
for (folder, mvbox_move, chat_msg, _expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
if folder == &"Spam" { "INBOX" } else { folder }, // Never move setup messages, except if they are in "Spam"
false,
true,
true,
)
.await?;
}

119
src/imap/scan_folders.rs Normal file
View File

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

View File

@@ -71,18 +71,13 @@ impl ImapSession {
self.select(folder).await
};
let transport_id = self.transport_id();
// <https://tools.ietf.org/html/rfc3501#section-6.3.1>
// says that if the server reports select failure we are in
// authenticated (not-select) state.
match res {
Ok(mailbox) => {
info!(
context,
"Transport {transport_id}: Selected folder {folder:?}."
);
info!(context, "Selected folder {folder:?}.");
self.selected_folder = Some(folder.to_string());
self.selected_mailbox = Some(mailbox);
Ok(NewlySelected::Yes)

View File

@@ -5,9 +5,11 @@ use anyhow::{Context as _, Result};
use async_imap::Session as ImapSession;
use async_imap::types::Mailbox;
use futures::TryStreamExt;
use tokio::sync::Mutex;
use crate::imap::capabilities::Capabilities;
use crate::net::session::SessionStream;
use crate::tools;
/// Prefetch:
/// - Message-ID to check if we already have the message.
@@ -21,8 +23,10 @@ const PREFETCH_FLAGS: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIE
DATE \
X-MICROSOFT-ORIGINAL-MESSAGE-ID \
FROM \
IN-REPLY-TO REFERENCES \
CHAT-VERSION \
CHAT-IS-POST-MESSAGE \
AUTO-SUBMITTED \
AUTOCRYPT-SETUP-MESSAGE\
)])";
@@ -42,6 +46,8 @@ pub(crate) struct Session {
pub selected_folder_needs_expunge: bool,
pub(crate) last_full_folder_scan: Mutex<Option<tools::Time>>,
/// True if currently selected folder has new messages.
///
/// Should be false if no folder is currently selected.
@@ -78,6 +84,7 @@ impl Session {
selected_folder: None,
selected_mailbox: None,
selected_folder_needs_expunge: false,
last_full_folder_scan: Mutex::new(None),
new_mail: false,
resync_request_sender,
}
@@ -125,7 +132,6 @@ impl Session {
/// Prefetch `n_uids` messages starting from `uid_next`. Returns a list of fetch results in the
/// order of ascending delivery time to the server (INTERNALDATE).
#[expect(clippy::arithmetic_side_effects)]
pub(crate) async fn prefetch(
&mut self,
uid_next: u32,

View File

@@ -19,8 +19,9 @@ use crate::config::Config;
use crate::context::Context;
use crate::e2ee;
use crate::events::EventType;
use crate::key::{self, DcKey, SignedSecretKey};
use crate::key::{self, DcKey, DcSecretKey, SignedPublicKey, SignedSecretKey};
use crate::log::{LogExt, warn};
use crate::pgp;
use crate::qr::DCBACKUP_VERSION;
use crate::sql;
use crate::tools::{
@@ -28,9 +29,11 @@ use crate::tools::{
write_file,
};
mod key_transfer;
mod transfer;
use ::pgp::types::KeyDetails;
pub use key_transfer::{continue_key_transfer, initiate_key_transfer};
pub use transfer::{BackupProvider, get_backup};
// Name of the database file in the backup.
@@ -100,8 +103,7 @@ pub async fn imex(
if let Err(err) = res.as_ref() {
// We are using Anyhow's .context() and to show the inner error, too, we need the {:#}:
error!(context, "{:#}", err);
warn!(context, "IMEX failed to complete: {:#}", err);
error!(context, "IMEX failed to complete: {:#}", err);
context.emit_event(EventType::ImexProgress(0));
} else {
info!(context, "IMEX successfully completed");
@@ -139,13 +141,19 @@ pub async fn has_backup(_context: &Context, dir_name: &Path) -> Result<String> {
}
async fn set_self_key(context: &Context, armored: &str) -> Result<()> {
let secret_key = SignedSecretKey::from_asc(armored)?;
key::store_self_keypair(context, &secret_key).await?;
let private_key = SignedSecretKey::from_asc(armored)?;
let public_key = private_key.split_public_key()?;
let keypair = pgp::KeyPair {
public: public_key,
secret: private_key,
};
key::store_self_keypair(context, &keypair).await?;
info!(
context,
"Stored self key: {:?}.",
secret_key.public_key().fingerprint()
"stored self key: {:?}",
keypair.secret.public_key().key_id()
);
Ok(())
}
@@ -201,6 +209,15 @@ async fn import_backup(
backup_to_import: &Path,
passphrase: String,
) -> Result<()> {
ensure!(
!context.is_configured().await?,
"Cannot import backups to accounts in use."
);
ensure!(
!context.scheduler.is_running().await,
"cannot import backup, IO is running"
);
let backup_file = File::open(backup_to_import).await?;
let file_size = backup_file.metadata().await?.len();
info!(
@@ -234,15 +251,6 @@ pub(crate) async fn import_backup_stream<R: tokio::io::AsyncRead + Unpin>(
file_size: u64,
passphrase: String,
) -> Result<()> {
ensure!(
!context.is_configured().await?,
"Cannot import backups to accounts in use"
);
ensure!(
!context.scheduler.is_running().await,
"Cannot import backup, IO is running"
);
import_backup_stream_inner(context, backup_file, file_size, passphrase)
.await
.0
@@ -285,7 +293,6 @@ impl<R> AsyncRead for ProgressReader<R>
where
R: AsyncRead,
{
#[expect(clippy::arithmetic_side_effects)]
fn poll_read(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
@@ -309,9 +316,6 @@ where
}
}
// This function returns a tuple (Result<()>,) rather than Result<()>
// so that we don't accidentally early-return with `?`
// and forget to cleanup.
async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
context: &Context,
backup_file: R,
@@ -358,6 +362,11 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
}
}
};
if res.is_err() {
for blob in blobs {
fs::remove_file(&blob).await.log_err(context).ok();
}
}
let unpacked_database = context.get_blobdir().join(DBFILE_BACKUP_NAME);
if res.is_ok() {
@@ -370,6 +379,17 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
if res.is_ok() {
res = check_backup_version(context).await;
}
if res.is_ok() {
// All recent backups have `bcc_self` set to "1" before export.
//
// Setting `bcc_self` to "1" on export was introduced on 2024-12-17
// in commit 21664125d798021be75f47d5b0d5006d338b4531
//
// We additionally try to set `bcc_self` to "1" after import here
// for compatibility with older backups,
// but eventually this code can be removed.
res = context.set_config(Config::BccSelf, Some("1")).await;
}
fs::remove_file(unpacked_database)
.await
.context("cannot remove unpacked database")
@@ -380,22 +400,6 @@ async fn import_backup_stream_inner<R: tokio::io::AsyncRead + Unpin>(
res = context.sql.run_migrations(context).await;
context.emit_event(EventType::AccountsItemChanged);
}
if res.is_err() {
context.sql.close().await;
fs::remove_file(context.sql.dbfile.as_path())
.await
.log_err(context)
.ok();
for blob in blobs {
fs::remove_file(&blob).await.log_err(context).ok();
}
context
.sql
.open(context, "".to_string())
.await
.log_err(context)
.ok();
}
if res.is_ok() {
delete_and_reset_all_device_msgs(context)
.await
@@ -445,7 +449,6 @@ fn get_next_backup_path(
/// Exports the database to a separate file with the given passphrase.
///
/// Set passphrase to empty string to export the database unencrypted.
#[expect(clippy::arithmetic_side_effects)]
async fn export_backup(context: &Context, dir: &Path, passphrase: String) -> Result<()> {
// get a fine backup file name (the name includes the date so that multiple backup instances are possible)
let now = time();
@@ -519,7 +522,6 @@ impl<W> AsyncWrite for ProgressWriter<W>
where
W: AsyncWrite,
{
#[expect(clippy::arithmetic_side_effects)]
fn poll_write(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
@@ -599,7 +601,6 @@ async fn import_secret_key(context: &Context, path: &Path) -> Result<()> {
/// containing secret keys are imported and the last successfully
/// imported which does not contain "legacy" in its filename
/// is set as the default.
#[expect(clippy::arithmetic_side_effects)]
async fn import_self_keys(context: &Context, path: &Path) -> Result<()> {
let attr = tokio::fs::metadata(path).await?;
@@ -653,43 +654,44 @@ async fn import_self_keys(context: &Context, path: &Path) -> Result<()> {
Ok(())
}
#[expect(clippy::arithmetic_side_effects)]
async fn export_self_keys(context: &Context, dir: &Path) -> Result<()> {
let mut export_errors = 0;
let keys = context
.sql
.query_map_vec(
"SELECT id, private_key, id=(SELECT value FROM config WHERE keyname='key_id') FROM keypairs;",
"SELECT id, public_key, private_key, id=(SELECT value FROM config WHERE keyname='key_id') FROM keypairs;",
(),
|row| {
let id = row.get(0)?;
let private_key_blob: Vec<u8> = row.get(1)?;
let public_key_blob: Vec<u8> = row.get(1)?;
let public_key = SignedPublicKey::from_slice(&public_key_blob);
let private_key_blob: Vec<u8> = row.get(2)?;
let private_key = SignedSecretKey::from_slice(&private_key_blob);
let is_default: i32 = row.get(2)?;
let is_default: i32 = row.get(3)?;
Ok((id, private_key, is_default))
Ok((id, public_key, private_key, is_default))
},
)
.await?;
let self_addr = context.get_primary_self_addr().await?;
for (id, private_key, is_default) in keys {
for (id, public_key, private_key, is_default) in keys {
let id = Some(id).filter(|_| is_default == 0);
let Ok(private_key) = private_key else {
export_errors += 1;
continue;
};
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &private_key).await {
error!(context, "Failed to export private key: {:#}.", err);
if let Ok(key) = public_key {
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &key).await {
error!(context, "Failed to export public key: {:#}.", err);
export_errors += 1;
}
} else {
export_errors += 1;
}
let public_key = private_key.to_public_key();
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &public_key).await {
error!(context, "Failed to export public key: {:#}.", err);
if let Ok(key) = private_key {
if let Err(err) = export_key_to_asc_file(context, dir, &self_addr, id, &key).await {
error!(context, "Failed to export private key: {:#}.", err);
export_errors += 1;
}
} else {
export_errors += 1;
}
}
@@ -719,7 +721,12 @@ where
format!("{kind}-key-{addr}-{id}-{fp}.asc")
};
let path = dir.join(&file_name);
info!(context, "Exporting key to {}.", path.display());
info!(
context,
"Exporting key {:?} to {}.",
key.key_id(),
path.display()
);
// Delete the file if it already exists.
delete_file(context, &path).await.ok();
@@ -793,7 +800,7 @@ async fn check_backup_version(context: &Context) -> Result<()> {
let version = (context.sql.get_raw_config_int("backup_version").await?).unwrap_or(2);
ensure!(
version <= DCBACKUP_VERSION,
"This profile is from a newer version of Delta Chat. Please update Delta Chat and try again (profile version is v{version}, the latest supported is v{DCBACKUP_VERSION})"
"Backup too new, please update Delta Chat"
);
Ok(())
}
@@ -806,12 +813,12 @@ mod tests {
use super::*;
use crate::config::Config;
use crate::test_utils::{TestContext, TestContextManager, alice_keypair};
use crate::test_utils::{TestContext, alice_keypair};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_export_public_key_to_asc_file() {
let context = TestContext::new().await;
let key = alice_keypair().to_public_key();
let key = alice_keypair().public;
let blobdir = Path::new("$BLOBDIR");
let filename = export_key_to_asc_file(&context.ctx, blobdir, "a@b", None, &key)
.await
@@ -828,7 +835,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_import_private_key_exported_to_asc_file() {
let context = TestContext::new().await;
let key = alice_keypair();
let key = alice_keypair().secret;
let blobdir = Path::new("$BLOBDIR");
let filename = export_key_to_asc_file(&context.ctx, blobdir, "a@b", None, &key)
.await
@@ -1023,81 +1030,6 @@ mod tests {
Ok(())
}
/// Tests that [`crate::qr::DCBACKUP_VERSION`] is checked correctly.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_import_backup_fails_because_of_dcbackup_version() -> Result<()> {
let mut tcm = TestContextManager::new();
let context1 = tcm.alice().await;
let context2 = tcm.unconfigured().await;
assert!(context1.is_configured().await?);
assert!(!context2.is_configured().await?);
let backup_dir = tempfile::tempdir().unwrap();
tcm.section("export from context1");
assert!(
imex(&context1, ImexMode::ExportBackup, backup_dir.path(), None)
.await
.is_ok()
);
let _event = context1
.evtracker
.get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
.await;
let backup = has_backup(&context2, backup_dir.path()).await?;
let modified_backup = backup_dir.path().join("modified_backup.tar");
tcm.section("Change backup_version to be higher than DCBACKUP_VERSION");
{
let unpack_dir = tempfile::tempdir().unwrap();
let mut ar = Archive::new(File::open(&backup).await?);
ar.unpack(&unpack_dir).await?;
let sql = sql::Sql::new(unpack_dir.path().join(DBFILE_BACKUP_NAME));
sql.open(&context2, "".to_string()).await?;
assert_eq!(
sql.get_raw_config_int("backup_version").await?.unwrap(),
DCBACKUP_VERSION
);
sql.set_raw_config_int("backup_version", DCBACKUP_VERSION + 1)
.await?;
sql.close().await;
let modified_backup_file = File::create(&modified_backup).await?;
let mut builder = tokio_tar::Builder::new(modified_backup_file);
builder.append_dir_all("", unpack_dir.path()).await?;
builder.finish().await?;
}
tcm.section("import to context2");
let err = imex(&context2, ImexMode::ImportBackup, &modified_backup, None)
.await
.unwrap_err();
assert!(err.to_string().starts_with("This profile is from a newer version of Delta Chat. Please update Delta Chat and try again"));
// Some UIs show the error from the event to the user.
// Therefore, it must also be a user-facing string, rather than some technical info:
let err_event = context2
.evtracker
.get_matching(|evt| matches!(evt, EventType::Error(_)))
.await;
let EventType::Error(err_msg) = err_event else {
unreachable!()
};
assert!(err_msg.starts_with("This profile is from a newer version of Delta Chat. Please update Delta Chat and try again"));
context2
.evtracker
.get_matching(|evt| matches!(evt, EventType::ImexProgress(0)))
.await;
assert!(!context2.is_configured().await?);
assert_eq!(context2.get_config(Config::ConfiguredAddr).await?, None);
Ok(())
}
/// This is a regression test for
/// https://github.com/deltachat/deltachat-android/issues/2263
/// where the config cache wasn't reset properly after a backup.
@@ -1137,16 +1069,4 @@ mod tests {
Ok(())
}
/// Tests importing a backup from Delta Chat 1.30.3 for Android (core v1.86.0).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_import_ancient_backup() -> Result<()> {
let mut tcm = TestContextManager::new();
let context = &tcm.unconfigured().await;
let backup_path = Path::new("test-data/core-1.86.0-backup.tar");
imex(context, ImexMode::ImportBackup, backup_path, None).await?;
Ok(())
}
}

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