Compare commits

..

1 Commits

Author SHA1 Message Date
link2xt
3010d28901 fix: prevent reuse of the stream after an error
When a stream timeouts, `tokio_io_timeout::TimeoutStream`
returns an error once, but then allows to keep using
the stream, e.g. calling `poll_read()` again.

This can be dangerous if the error is ignored.
For example in case of IMAP stream,
if IMAP command is sent,
but then reading the response
times out and the error is ignored,
it is possible to send another IMAP command.
In this case leftover response
from a previous command may be read
and interpreted as the response
to the new IMAP command.

ErrorCapturingStream wraps the stream
to prevent its reuse after an error.
2025-07-19 13:44:00 +00:00
150 changed files with 2683 additions and 6635 deletions

View File

@@ -20,7 +20,7 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.90.0
RUST_VERSION: 1.88.0
# Minimum Supported Rust Version
MSRV: 1.85.0
@@ -30,7 +30,7 @@ jobs:
name: Lint Rust
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
@@ -53,7 +53,7 @@ jobs:
name: cargo deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
@@ -67,12 +67,10 @@ jobs:
name: Check provider database
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Install rustfmt
run: rustup component add --toolchain stable-x86_64-unknown-linux-gnu rustfmt
- name: Check provider database
run: scripts/update-provider-database.sh
@@ -82,7 +80,7 @@ jobs:
env:
RUSTDOCFLAGS: -Dwarnings
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
@@ -117,7 +115,7 @@ jobs:
shell: bash
if: matrix.rust == 'latest'
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
@@ -139,12 +137,12 @@ jobs:
- name: Tests
env:
RUST_BACKTRACE: 1
run: cargo nextest run --workspace --locked
run: cargo nextest run --workspace
- name: Doc-Tests
env:
RUST_BACKTRACE: 1
run: cargo test --workspace --locked --doc
run: cargo test --workspace --doc
- name: Test cargo vendor
run: cargo vendor
@@ -156,7 +154,7 @@ jobs:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
@@ -181,7 +179,7 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
@@ -203,7 +201,7 @@ jobs:
name: Python lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
@@ -228,9 +226,9 @@ jobs:
include:
# Currently used Rust version.
- os: ubuntu-latest
python: 3.14
python: 3.13
- os: macos-latest
python: 3.14
python: 3.13
# PyPy tests
- os: ubuntu-latest
@@ -246,19 +244,19 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Download libdeltachat.a
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: ${{ matrix.os }}-libdeltachat.a
path: target/debug
- name: Install python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
@@ -281,11 +279,11 @@ jobs:
matrix:
include:
- os: ubuntu-latest
python: 3.14
python: 3.13
- os: macos-latest
python: 3.14
python: 3.13
- os: windows-latest
python: 3.14
python: 3.13
# PyPy tests
- os: ubuntu-latest
@@ -299,13 +297,13 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Install python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
@@ -313,7 +311,7 @@ jobs:
run: pip install tox
- name: Download deltachat-rpc-server
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: ${{ matrix.os }}-deltachat-rpc-server
path: target/debug

View File

@@ -30,11 +30,11 @@ jobs:
arch: [aarch64, armv7l, armv6l, i686, x86_64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: DeterminateSystems/nix-installer-action@main
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
@@ -54,11 +54,11 @@ jobs:
arch: [win32, win64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: DeterminateSystems/nix-installer-action@main
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
@@ -79,7 +79,7 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
@@ -105,11 +105,11 @@ jobs:
arch: [arm64-v8a, armeabi-v7a]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: DeterminateSystems/nix-installer-action@main
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
@@ -132,74 +132,74 @@ jobs:
contents: write
runs-on: "ubuntu-latest"
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: DeterminateSystems/nix-installer-action@main
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-aarch64-linux
path: deltachat-rpc-server-aarch64-linux.d
- name: Download Linux armv7l binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-armv7l-linux
path: deltachat-rpc-server-armv7l-linux.d
- name: Download Linux armv6l binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-armv6l-linux
path: deltachat-rpc-server-armv6l-linux.d
- name: Download Linux i686 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-i686-linux
path: deltachat-rpc-server-i686-linux.d
- name: Download Linux x86_64 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-x86_64-linux
path: deltachat-rpc-server-x86_64-linux.d
- name: Download Win32 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-win32
path: deltachat-rpc-server-win32.d
- name: Download Win64 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-win64
path: deltachat-rpc-server-win64.d
- name: Download macOS binary for x86_64
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-x86_64-macos
path: deltachat-rpc-server-x86_64-macos.d
- name: Download macOS binary for aarch64
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-aarch64-macos
path: deltachat-rpc-server-aarch64-macos.d
- name: Download Android binary for arm64-v8a
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-arm64-v8a-android
path: deltachat-rpc-server-arm64-v8a-android.d
- name: Download Android binary for armeabi-v7a
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-armeabi-v7a-android
path: deltachat-rpc-server-armeabi-v7a-android.d
@@ -224,7 +224,7 @@ jobs:
# Python 3.11 is needed for tomllib used in scripts/wheel-rpc-server.py
- name: Install python 3.12
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: 3.12
@@ -285,76 +285,76 @@ jobs:
# Needed to publish the binaries to the release.
contents: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-aarch64-linux
path: deltachat-rpc-server-aarch64-linux.d
- name: Download Linux armv7l binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-armv7l-linux
path: deltachat-rpc-server-armv7l-linux.d
- name: Download Linux armv6l binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-armv6l-linux
path: deltachat-rpc-server-armv6l-linux.d
- name: Download Linux i686 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-i686-linux
path: deltachat-rpc-server-i686-linux.d
- name: Download Linux x86_64 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-x86_64-linux
path: deltachat-rpc-server-x86_64-linux.d
- name: Download Win32 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-win32
path: deltachat-rpc-server-win32.d
- name: Download Win64 binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-win64
path: deltachat-rpc-server-win64.d
- name: Download macOS binary for x86_64
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-x86_64-macos
path: deltachat-rpc-server-x86_64-macos.d
- name: Download macOS binary for aarch64
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-aarch64-macos
path: deltachat-rpc-server-aarch64-macos.d
- name: Download Android binary for arm64-v8a
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-arm64-v8a-android
path: deltachat-rpc-server-arm64-v8a-android.d
- name: Download Android binary for armeabi-v7a
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: deltachat-rpc-server-armeabi-v7a-android
path: deltachat-rpc-server-armeabi-v7a-android.d
@@ -401,7 +401,7 @@ jobs:
deltachat-rpc-server/npm-package/*.tgz
# Configure Node.js for publishing.
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"

View File

@@ -14,12 +14,12 @@ jobs:
id-token: write
contents: read
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"

View File

@@ -16,12 +16,12 @@ jobs:
build_and_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Use Node.js 18.x
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 18.x
- name: Add Rust cache

View File

@@ -5,12 +5,10 @@ on:
paths:
- flake.nix
- flake.lock
- .github/workflows/nix.yml
push:
paths:
- flake.nix
- flake.lock
- .github/workflows/nix.yml
branches:
- main
@@ -21,12 +19,15 @@ jobs:
name: check flake formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- run: nix fmt flake.nix -- --check
- uses: DeterminateSystems/nix-installer-action@main
- run: nix fmt
# Check that formatting does not change anything.
- run: git diff --exit-code
build:
name: nix build
@@ -80,11 +81,11 @@ jobs:
#- deltachat-rpc-server-x86_64-android
#- deltachat-rpc-server-x86-android
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: DeterminateSystems/nix-installer-action@main
- run: nix build .#${{ matrix.installable }}
build-macos:
@@ -100,9 +101,9 @@ jobs:
# - deltachat-rpc-server-aarch64-darwin
# - deltachat-rpc-server-x86_64-darwin
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: DeterminateSystems/nix-installer-action@main
- run: nix build .#${{ matrix.installable }}

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
@@ -42,7 +42,7 @@ jobs:
steps:
- name: Download all the dists
uses: actions/download-artifact@v5
uses: actions/download-artifact@v4
with:
name: python-package-distributions
path: dist/

View File

@@ -14,11 +14,11 @@ jobs:
name: Build REPL example
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: DeterminateSystems/nix-installer-action@main
- name: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
@@ -31,12 +31,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: DeterminateSystems/nix-installer-action@main
- name: Build Python documentation
run: nix build .#python-docs
- name: Upload to py.delta.chat
@@ -50,12 +50,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31
- uses: DeterminateSystems/nix-installer-action@main
- name: Build C documentation
run: nix build .#docs
- name: Upload to c.delta.chat
@@ -72,13 +72,13 @@ jobs:
working-directory: ./deltachat-jsonrpc/typescript
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- name: Use Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: '18'
- name: npm install

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false

View File

@@ -14,7 +14,7 @@ jobs:
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
persist-credentials: false

View File

@@ -1,414 +1,5 @@
# Changelog
## [2.20.0] - 2025-10-13
This release fixes a bug that resulted in ephemeral loop getting stuck in infinite loop
when trying to delete a message with unknown viewtype.
### Fixes
- Accept unknown viewtype in ephemeral loop.
- Accept unknown viewtype in delete-old-messages loop.
## [2.19.0] - 2025-10-12
### Features / Changes
- Slightly increase saturation of colors.
### Fixes
- Do not fail to receive call accepted/ended messages referring to non-call Message-ID.
- Do not fail to fully download previously trashed messages.
- Emit AccountsItemChanged when own key is generated/imported, use gray self-color until that ([#7296](https://github.com/chatmail/core/pull/7296)).
- Do not try to process calls from partial messages.
### CI
- Update to Python 3.14.
### Refactor
- Use variables directly in formatted strings ([#7284](https://github.com/chatmail/core/pull/7284)).
- Set_chat_profile_image(): Remove !chat.is_mailing_list() check.
### Miscellaneous Tasks
- cargo: Bump quick-xml from 0.37.5 to 0.38.3.
- Add nodejs to nix dev env ([#7283](https://github.com/chatmail/core/pull/7283))
## [2.18.0] - 2025-10-08
### API-Changes
- [**breaking**] Remove APIs for video chat invitations.
### CI
- nix: Run the workflow when workflow file changes.
- nix: Switch from DeterminateSystems/nix-installer-action to cachix/install-nix-action.
### Features / Changes
- No implicit member changes from old Delta Chat clients ([#7220](https://github.com/chatmail/core/pull/7220)).
### Fixes
- Do not fail to load messages with unknown viewtype.
- Only omit group changes messages if SELF is really added ([#7220](https://github.com/chatmail/core/pull/7220)).
### Refactor
- Assert that Iroh node addresses have home relay URL.
## [2.17.0] - 2025-10-04
### API-Changes
- [**breaking**] Remove deprecated verified_one_on_one_chats config.
### CI
- Require that Cargo.lock is up to date.
- Fix CI checking Nix formatting.
### Documentation
- Comment about outdated timespan.
- Clarify CALL events ([#7188](https://github.com/chatmail/core/pull/7188)).
- Add docs for JS `BaseDeltaChat`.
### Features / Changes
- Make `text/calendar` alternative available as an attachment.
- Better summary for calls.
- Add strings 'You left the channel.' and 'Scan to join Channel' ([#7266](https://github.com/chatmail/core/pull/7266)).
- Stock strings for calls.
- ffi: Add DC_STR_CANT_DECRYPT_OUTGOING_MSGS define.
### Fixes
- Prefer last part in `multipart/alternative`.
- Prefetch messages in limited batches ([#6915](https://github.com/chatmail/core/pull/6915)).
- Forward calls as text messages.
- Consistent spelling of "canceled" with a single "l".
- Lowercase "call" in "Missed call" and similar strings.
### Refactor
- Return the reason when failing to place calls.
### Tests
- Test reception of `multipart/alternative` with `text/calendar`.
## [2.16.0] - 2025-10-01
### API-Changes
- [**breaking**] Get rid of inviter progress other than 0 and 1000.
- Add has_video attribute to incoming call events.
- Add JSON-RPC API to get ICE servers.
- Add call_info() JSON-RPC API.
- Add chat ID to SecureJoinInviterProgress.
- deltachat-rpc-client: Add Chat.resend_messages().
- Add `chat_id` to all call events ([#7216](https://github.com/chatmail/core/pull/7216)).
### Build system
- Update rPGP from 0.16.0 to 0.17.0.
### CI
- Update Rust to 1.90.0.
- Install rustfmt before checking provider database.
### Documentation
- Add more `get_next_event` docs.
- SecurejoinInviterProgress never returns an error.
### Features / Changes
- Don't fetch messages from unknown folders ([#7190](https://github.com/chatmail/core/pull/7190)).
- Get ICE servers from IMAP METADATA.
- Don't ignore receive_imf_inner() errors, try adding partially downloaded message instead ([#7196](https://github.com/chatmail/core/pull/7196)).
- Set dimensions for outgoing Sticker messages.
### Fixes
- Create 1:1 chat only if auth token is for setup contact.
- Ignore vc-/vg- prefix for SecurejoinInviterProgress.
- Don't init Iroh on channel leave ([#7210](https://github.com/chatmail/core/pull/7210)).
- Take the last valid Autocrypt header ([#7167](https://github.com/chatmail/core/pull/7167)).
- Don't add "member removed" messages from nonmembers ([#7207](https://github.com/chatmail/core/pull/7207)).
- Do not consider the call stale if it is not sent out yet.
- Receive_imf: Report replaced message id in `MsgsChanged` if chat is the same.
- Allow Exif for stickers, don't recode them because of that ([#6447](https://github.com/chatmail/core/pull/6447)).
### Refactor
- Remove unused prop (TS, `BaseDeltaChat`).
- Remove unused FolderMeaning::Drafts.
### Tests
- Rename test_udpate_call_text into test_update_call_text.
- Update timestamp_sent in pop_sent_msg_opt().
- Do not match call ID from second alice with first alice event.
## [2.15.0] - 2025-09-15
### API-Changes
- Add JSON-RPC API for calls ([#7194](https://github.com/chatmail/core/pull/7194)).
### Build system
- Remove unused `quoted_printable` dependency.
## [2.14.0] - 2025-09-12
### API-Changes
- Put the chattype into the SecurejoinInviterProgress event ([#7181](https://github.com/chatmail/core/pull/7181)).
### Fixes
- param: Split params only on \n.
- B-encode SDP offer and answer sent in headers.
### Refactor
- Use recv_msg_trash() instead of recv_msg_opt().
- Prepare_msg_raw(): don't return MsgId.
### Tests
- Message is OutFailed if all keys are missing ([#6849](https://github.com/chatmail/core/pull/6849)).
- Test sending SDP offer and answer with newlines.
## [2.13.0] - 2025-09-09
### API-Changes
- [**breaking**] Remove `is_profile_verified` APIs.
- [**breaking**] Remove deprecated `is_protection_broken`.
- [**breaking**] Remove `e2ee_enabled` preference.
### Features / Changes
- Add call ringing API ([#6650](https://github.com/chatmail/core/pull/6650), [#7174](https://github.com/chatmail/core/pull/7174), [#7175](https://github.com/chatmail/core/pull/7175), [#7179](https://github.com/chatmail/core/pull/7179))
- Warn for outdated versions after 6 months instead of 1 year ([#7144](https://github.com/chatmail/core/pull/7144)).
- Do not set "unknown sender for this chat" error.
- Do not replace messages with an error on verification failure.
- Support receiving Autocrypt-Gossip with `_verified` attribute.
- Withdraw all QR codes when one is withdrawn.
### Fixes
- Don't reverify contacts by SELF on receipt of a message from another device.
- Don't verify contacts by others having an unknown verifier.
- Update verifier_id if it's "unknown" and new verifier has known verifier.
- Mark message as failed if it can't be sent ([#7143](https://github.com/chatmail/core/pull/7143)).
- Add "Messages are end-to-end encrypted." to non-protected groups.
### Documentation
- Fix for SecurejoinInviterProgress with progress == 600.
- STYLE.md: Prefer BTreeMap and BTreeSet over hash variants.
### Miscellaneous Tasks
- Update provider database.
- Update dependencies.
### Refactor
- Check that verifier is verified in turn.
- Remove unused `EncryptPreference::Reset`.
- Remove `Aheader::new`.
### Tests
- Add another TimeShiftFalsePositiveNote ([#7142](https://github.com/chatmail/core/pull/7142)).
- Add TestContext.create_chat_id.
## [2.12.0] - 2025-08-26
### API-Changes
- api!(python): remove remaining broken API for reactions
### Features / Changes
- Use Group ID for chat color generation instead of the name for encrypted groups.
- Use key fingerprints instead of addresses for key-contacts color generation.
- Replace HSLuv colors with OKLCh.
- `wal_checkpoint()`: Do `wal_checkpoint(PASSIVE)` and `wal_checkpoint(FULL)` before `wal_checkpoint(TRUNCATE)`.
- Assign messages to key-contacts based on Issuer Fingerprint.
- Create_group_ex(): Log and replace invalid chat name with "…".
### Fixes
- Do not create a group if the sender includes self in the `To` field.
- Do not reverify already verified contacts via gossip.
- `get_connectivity()`: Get rid of locking SchedulerState::inner ([#7124](https://github.com/chatmail/core/pull/7124)).
- Make reaction message hidden only if there are no other parts.
### Refactor
- Do not return `Result` from `valid_signature_fingerprints()`.
- Make `ConnectivityStore` use a non-async lock ([#7129](https://github.com/chatmail/core/pull/7129)).
### Documentation
- Remove broken link from documentation comments.
- Remove the comment about Color Vision Deficiency correction.
## [2.11.0] - 2025-08-13
### Features / Changes
- Contact::lookup_id_by_addr_ex: Prefer returning key-contact.
- Contact::lookup_id_by_addr_ex: Prefer returning accepted contacts.
- Better string when using disappearing messages of one year (365..367 days, so it can be tweaked later).
- Do not require resent messages to be from the same chat.
- `lookup_key_contact_by_address()`: Allow looking up ContactId::SELF without chat id.
- `get_securejoin_qr()`: Log error if group doesn't have grpid.
- `receive_imf::add_parts()`: Get rid of extra `Chat::load_from_db()` calls.
### Fixes
- Ignore case when trying to detect 'invalid unencrypted mail' and add an info-message.
- Run wal_checkpoint during housekeeping ([#6089](https://github.com/chatmail/core/pull/6089)).
- Allow receiving empty files.
- Set correct sent_timestamp for saved outgoing messages.
- Do not remove query parameters from URLs.
- Log and set imex progress error ([#7091](https://github.com/chatmail/core/pull/7091)).
- Do not add key-contacts to unencrypted groups.
- Do not reset `GuaranteeE2ee` in the database when resending messages.
- Assign messages to a group if there is a `Chat-Group-Name`.
- Take `Chat-Group-Name` into account when matching ad hoc groups.
- Don't break long group names with non-ASCII characters.
- Add messages that can't be verified as `DownloadState::Available` ([#7059](https://github.com/chatmail/core/pull/7059)).
### Tests
- Log the number of the test account if there are multiple alices ([#7087](https://github.com/chatmail/core/pull/7087)).
### CI
- Update Rust to 1.89.0.
### Refactor
- Rename icon-address-contact to icon-unencrypted.
- Skip loading the contact of 1:1 unencrypted chat to show the avatar.
- Chat::is_encrypted(): Make one query instead of two for 1:1 chats.
### Miscellaneous Tasks
- cargo: Bump toml from 0.8.23 to 0.9.4.
- cargo: Bump human-panic from 2.0.2 to 2.0.3.
- deny.toml: Add exception for duplicate toml_datetime 0.6.11 dependency.
- deps: Bump actions/checkout from 4 to 5.
- deps: Bump actions/download-artifact from 4 to 5.
## [2.10.0] - 2025-08-04
### Features / Changes
- Also lookup key contacts in lookup_id_by_addr() ([#7073](https://github.com/chatmail/core/pull/7073)).
### Miscellaneous Tasks
- cargo: Bump serde_json from 1.0.140 to 1.0.142.
- cargo: Bump bolero from 0.13.3 to 0.13.4.
- cargo: Bump async-channel from 2.3.1 to 2.5.0.
- cargo: Bump hyper-util from 0.1.14 to 0.1.16.
- cargo: Bump criterion from 0.6.0 to 0.7.0.
- cargo: Bump strum from 0.27.1 to 0.27.2.
- cargo: Bump strum_macros from 0.27.1 to 0.27.2.
- Upgrade async-imap to 0.11.1.
## [2.9.0] - 2025-07-31
### Features / Changes
- repl: Add import-vcard and make-vcard commands ([#7048](https://github.com/chatmail/core/pull/7048)).
### Fixes
- Display correct timer value for ephemeral timer changes.
- Get_chat_msgs_ex(): Report local midnight in ChatItem::DayMarker.
### Refactor
- Rename add_or_lookup_key_contacts_by_address_list() to add_or_lookup_key_contacts().
- Don't call add_or_lookup_key_contacts() in advance.
## [2.8.0] - 2025-07-28
### Features / Changes
- Remove ProtectionBroken, make such chats Unprotected ([#7041](https://github.com/chatmail/core/pull/7041)).
### Fixes
- Lookup self by address if there is no fingerprint or gossip.
## [2.7.0] - 2025-07-26
### Features / Changes
- Mimefactory: Order message recipients by time of addition ([#6872](https://github.com/chatmail/core/pull/6872)).
- Put the debug/release build version into the info ([#7034](https://github.com/chatmail/core/pull/7034)).
### Fixes
- Realtime late join ([#6869](https://github.com/chatmail/core/pull/6869)).
- Do not fail to upgrade if the verifier of a contact doesn't exist anymore ([#7044](https://github.com/chatmail/core/pull/7044)).
### Tests
- Add regression test for verification-gossiping crash ([#7033](https://github.com/chatmail/core/pull/7033)).
## [2.6.0] - 2025-07-23
### Fixes
- Fix crash when receiving a verification-gossiping message which a contact also sends to itself ([#7032](https://github.com/chatmail/core/pull/7032)).
## [2.5.0] - 2025-07-22
### Fixes
- Correctly migrate "verified by me".
- Mark all email chats as unprotected in the migration ([#7026](https://github.com/chatmail/core/pull/7026)).
- Do not ignore errors in add_flag_finalized_with_set.
### Documentation
- Deprecate protection-broken and related stuff ([#7018](https://github.com/chatmail/core/pull/7018)).
- Clarify the meaning of is_verified() vs verifier_id() ([#7027](https://github.com/chatmail/core/pull/7027)).
- STYLE.md: Prefer `try_next()` over `next()`.
## [2.4.0] - 2025-07-21
### Fixes
- Do not ignore errors when draining FETCH responses. This avoids IMAP loop getting stuck in an infinite loop retrying reading from the connection.
- Update `tokio-io-timeout` to 1.2.1. This release includes a fix to reset timeout after every error, so timeout error is returned at most once a minute if read is attempted after a timeout.
### Miscellaneous Tasks
- Update async-imap to 0.11.0.
### Refactor
- Use `try_next()` when processing FETCH responses.
## [2.3.0] - 2025-07-19
### Features / Changes
@@ -1866,7 +1457,7 @@ This reverts commit 6f22ce2722b51773d7fbb0d89e4764f963cafd91..
### Fixes
- Reset quota on configured address change ([#5908](https://github.com/chatmail/core/pull/5908)).
- Do not emit progress 1000 when configuration is canceled.
- Do not emit progress 1000 when configuration is cancelled.
- Assume file extensions are 32 chars max and don't contain whitespace ([#5338](https://github.com/chatmail/core/pull/5338)).
- Re-add tokens.foreign_id column ([#6038](https://github.com/chatmail/core/pull/6038)).
@@ -4314,7 +3905,7 @@ Bugfix release attempting to fix the [iOS build error](https://github.com/chatma
- Recreate `smtp` table with AUTOINCREMENT `id` ([#4390](https://github.com/chatmail/core/pull/4390)).
- Do not return an error from `send_msg_to_smtp` if retry limit is exceeded.
- Make the bots automatically accept group chat contact requests ([#4377](https://github.com/chatmail/core/pull/4377)).
- Delete `smtp` rows when message sending is canceled ([#4391](https://github.com/chatmail/core/pull/4391)).
- Delete `smtp` rows when message sending is cancelled ([#4391](https://github.com/chatmail/core/pull/4391)).
### Refactor
@@ -4325,7 +3916,7 @@ Bugfix release attempting to fix the [iOS build error](https://github.com/chatma
### Fixes
- Fetch at most 100 existing messages even if EXISTS was not received.
- Delete `smtp` rows when message sending is canceled.
- Delete `smtp` rows when message sending is cancelled.
### Changes
@@ -4412,14 +4003,14 @@ Bugfix release attempting to fix the [iOS build error](https://github.com/chatma
## [1.112.3] - 2023-03-30
### Fixes
- `transfer::get_backup` now frees ongoing process when canceled. #4249
- `transfer::get_backup` now frees ongoing process when cancelled. #4249
## [1.112.2] - 2023-03-30
### Changes
- Update iroh, remove `default-net` from `[patch.crates-io]` section.
- transfer backup: Connect to multiple provider addresses concurrently. This should speed up connection time significantly on the getter side. #4240
- Make sure BackupProvider is canceled on drop (or `dc_backup_provider_unref`). The BackupProvider will now always finish with an IMEX event of 1000 or 0, previously it would sometimes finished with 1000 (success) when it really was 0 (failure). #4242
- Make sure BackupProvider is cancelled on drop (or `dc_backup_provider_unref`). The BackupProvider will now always finish with an IMEX event of 1000 or 0, previously it would sometimes finished with 1000 (success) when it really was 0 (failure). #4242
### Fixes
- Do not return media from trashed messages in the "All media" view. #4247
@@ -6904,20 +6495,3 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.1.0]: https://github.com/chatmail/core/compare/v2.0.0..v2.1.0
[2.2.0]: https://github.com/chatmail/core/compare/v2.1.0..v2.2.0
[2.3.0]: https://github.com/chatmail/core/compare/v2.2.0..v2.3.0
[2.4.0]: https://github.com/chatmail/core/compare/v2.3.0..v2.4.0
[2.5.0]: https://github.com/chatmail/core/compare/v2.4.0..v2.5.0
[2.6.0]: https://github.com/chatmail/core/compare/v2.5.0..v2.6.0
[2.7.0]: https://github.com/chatmail/core/compare/v2.6.0..v2.7.0
[2.8.0]: https://github.com/chatmail/core/compare/v2.7.0..v2.8.0
[2.9.0]: https://github.com/chatmail/core/compare/v2.8.0..v2.9.0
[2.10.0]: https://github.com/chatmail/core/compare/v2.9.0..v2.10.0
[2.11.0]: https://github.com/chatmail/core/compare/v2.10.0..v2.11.0
[2.12.0]: https://github.com/chatmail/core/compare/v2.11.0..v2.12.0
[2.13.0]: https://github.com/chatmail/core/compare/v2.12.0..v2.13.0
[2.14.0]: https://github.com/chatmail/core/compare/v2.13.0..v2.14.0
[2.15.0]: https://github.com/chatmail/core/compare/v2.14.0..v2.15.0
[2.16.0]: https://github.com/chatmail/core/compare/v2.15.0..v2.16.0
[2.17.0]: https://github.com/chatmail/core/compare/v2.16.0..v2.17.0
[2.18.0]: https://github.com/chatmail/core/compare/v2.17.0..v2.18.0
[2.19.0]: https://github.com/chatmail/core/compare/v2.18.0..v2.19.0
[2.20.0]: https://github.com/chatmail/core/compare/v2.19.0..v2.20.0

View File

@@ -44,7 +44,7 @@ If you want to contribute a code, follow this guide.
The following prefix types are used:
- `feat`: Features, e.g. "feat: Pause IO for BackupProvider". If you are unsure what's the category of your commit, you can often just use `feat`.
- `fix`: Bug fixes, e.g. "fix: delete `smtp` rows when message sending is canceled"
- `fix`: Bug fixes, e.g. "fix: delete `smtp` rows when message sending is cancelled"
- `api`: API changes, e.g. "api(rust): add `get_msg_read_receipts(context, msg_id)`"
- `refactor`: Refactorings, e.g. "refactor: iterate over `msg_ids` without `.iter()`"
- `perf`: Performance improvements, e.g. "perf: improve SQLite performance with `PRAGMA synchronous=normal`"

567
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.20.0"
version = "2.3.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.85"
@@ -44,16 +44,14 @@ ratelimit = { path = "./deltachat-ratelimit" }
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-imap = { version = "0.10.4", default-features = false, features = ["runtime-tokio", "compress"] }
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.17", default-features = false, features = ["deflate", "tokio-fs"] }
base64 = { workspace = true }
blake3 = "1.8.2"
brotli = { version = "8", default-features=false, features = ["std"] }
bytes = "1"
chrono = { workspace = true, features = ["alloc", "clock", "std"] }
colorutils-rs = { version = "0.7.5", default-features = false }
data-encoding = "2.9.0"
escaper = "0.1"
fast-socks5 = "0.10"
@@ -65,13 +63,13 @@ hickory-resolver = "0.25.2"
http-body-util = "0.1.3"
humansize = "2"
hyper = "1"
hyper-util = "0.1.16"
hyper-util = "0.1.14"
image = { version = "0.25.6", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
iroh-gossip = { version = "0.35", default-features = false, features = ["net"] }
iroh = { version = "0.35", default-features = false }
kamadak-exif = "0.6.1"
libc = { workspace = true }
mail-builder = { version = "0.4.4", default-features = false }
mail-builder = { version = "0.4.3", default-features = false }
mailparse = { workspace = true }
mime = "0.3.17"
num_cpus = "1.17"
@@ -79,17 +77,18 @@ num-derive = "0.4"
num-traits = { workspace = true }
parking_lot = "0.12.4"
percent-encoding = "2.3"
pgp = { version = "0.17.0", default-features = false }
pgp = { version = "0.16.0", default-features = false }
pin-project = "1"
qrcodegen = "1.7.0"
quick-xml = { version = "0.38", features = ["escape-html"] }
quick-xml = "0.37"
quoted_printable = "0.5"
rand = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rust-hsluv = "0.1"
rustls-pki-types = "1.12.0"
rustls = { version = "0.23.22", default-features = false }
sanitize-filename = { workspace = true }
sdp = "0.8.0"
serde_json = { workspace = true }
serde_urlencoded = "0.7.1"
serde = { workspace = true, features = ["derive"] }
@@ -102,21 +101,22 @@ strum_macros = "0.27"
tagger = "4.3.4"
textwrap = "0.16.2"
thiserror = { workspace = true }
tokio-io-timeout = "1.2.1"
tokio-io-timeout = "1.2.0"
tokio-rustls = { version = "0.26.2", default-features = false }
tokio-stream = { version = "0.1.17", features = ["fs"] }
tokio-tar = { version = "0.3" } # TODO: integrate tokio into async-tar
tokio-util = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.9"
toml = "0.8"
tracing = "0.1.41"
url = "2"
uuid = { version = "1", features = ["serde", "v4"] }
webpki-roots = "0.26.8"
blake3 = "1.8.2"
[dev-dependencies]
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.
criterion = { version = "0.7.0", features = ["async_tokio"] }
criterion = { version = "0.6.0", features = ["async_tokio"] }
futures-lite = { workspace = true }
log = { workspace = true }
nu-ansi-term = { workspace = true }
@@ -175,18 +175,18 @@ harness = false
[workspace.dependencies]
anyhow = "1"
async-channel = "2.5.0"
async-channel = "2.3.1"
base64 = "0.22"
chrono = { version = "0.4.42", default-features = false }
chrono = { version = "0.4.41", 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.31"
futures-lite = "2.6.1"
futures-lite = "2.6.0"
libc = "0.2"
log = "0.4"
mailparse = "0.16.1"
nu-ansi-term = "0.50"
nu-ansi-term = "0.46"
num-traits = "0.2"
rand = "0.8"
regex = "1.10"
@@ -194,10 +194,10 @@ rusqlite = "0.36"
sanitize-filename = "0.5"
serde = "1.0"
serde_json = "1"
tempfile = "3.23.0"
tempfile = "3.20.0"
thiserror = "2"
tokio = "1"
tokio-util = "0.7.16"
tokio-util = "0.7.14"
tracing-subscriber = "0.3"
yerpc = "0.6.4"

View File

@@ -80,26 +80,18 @@ Connect to your mail server (if already configured):
> connect
```
Export your public key to a vCard file:
```
> make-vcard my.vcard 1
```
Create contacts by address or vCard file:
Create a contact:
```
> addcontact yourfriends@email.org
> import-vcard key-contact.vcard
```
List contacts:
```
> listcontacts
Contact#Contact#11: key-contact@email.org <key-contact@email.org>
Contact#Contact#Self: Me √ <your@email.org>
2 key contacts.
1 key contacts.
Contact#Contact#10: yourfriends@email.org <yourfriends@email.org>
1 address contacts.
```

View File

@@ -78,27 +78,6 @@ All errors should be handled in one of these ways:
- With `.log_err().ok()`.
- Bubbled up with `?`.
When working with [async streams](https://docs.rs/futures/0.3.31/futures/stream/index.html),
prefer [`try_next`](https://docs.rs/futures/0.3.31/futures/stream/trait.TryStreamExt.html#method.try_next) over `next()`, e.g. do
```
while let Some(event) = stream.try_next().await? {
todo!();
}
```
instead of
```
while let Some(event_res) = stream.next().await {
todo!();
}
```
as it allows bubbling up the error early with `?`
with no way to accidentally skip error processing
with early `continue` or `break`.
Some streams reading from a connection
return infinite number of `Some(Err(_))`
items when connection breaks and not processing
errors may result in infinite loop.
`backtrace` feature is enabled for `anyhow` crate
and `debug = 1` option is set in the test profile.
This allows to run `RUST_BACKTRACE=1 cargo test`
@@ -112,18 +91,6 @@ Follow
<https://doc.rust-lang.org/core/error/index.html#common-message-styles>
for `.expect` message style.
## BTreeMap vs HashMap
Prefer [BTreeMap](https://doc.rust-lang.org/std/collections/struct.BTreeMap.html)
over [HashMap](https://doc.rust-lang.org/std/collections/struct.HashMap.html)
and [BTreeSet](https://doc.rust-lang.org/std/collections/struct.BTreeSet.html)
over [HashSet](https://doc.rust-lang.org/std/collections/struct.HashSet.html)
as iterating over these structures returns items in deterministic order.
Non-deterministic code may result in difficult to reproduce bugs,
flaky tests, regression tests that miss bugs
or different behavior on different devices when processing the same messages.
## Logging
For logging, use `info!`, `warn!` and `error!` macros.

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -68,7 +68,7 @@ impl ContactAddress {
pub fn new(s: &str) -> Result<Self> {
let addr = addr_normalize(s);
if !may_be_valid_addr(&addr) {
bail!("invalid address {s:?}");
bail!("invalid address {:?}", s);
}
Ok(Self(addr.to_string()))
}
@@ -257,16 +257,16 @@ impl EmailAddress {
.chars()
.any(|c| c.is_whitespace() || c == '<' || c == '>')
{
bail!("Email {input:?} must not contain whitespaces, '>' or '<'");
bail!("Email {:?} must not contain whitespaces, '>' or '<'", input);
}
match &parts[..] {
[domain, local] => {
if local.is_empty() {
bail!("empty string is not valid for local part in {input:?}");
bail!("empty string is not valid for local part in {:?}", input);
}
if domain.is_empty() {
bail!("missing domain after '@' in {input:?}");
bail!("missing domain after '@' in {:?}", input);
}
if domain.ends_with('.') {
bail!("Domain {domain:?} should not contain the dot in the end");
@@ -276,7 +276,7 @@ impl EmailAddress {
domain: (*domain).to_string(),
})
}
_ => bail!("Email {input:?} must contain '@' character"),
_ => bail!("Email {:?} must contain '@' character", input),
}
}
}

View File

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

View File

@@ -415,6 +415,7 @@ char* dc_get_blobdir (const dc_context_t* context);
* As for `displayname` and `selfstatus`, also the avatar is sent to the recipients.
* To save traffic, however, the avatar is attached only as needed
* and also recoded to a reasonable size.
* - `e2ee_enabled` = 0=no end-to-end-encryption, 1=prefer end-to-end-encryption (default)
* - `mdns_enabled` = 0=do not send or request read receipts,
* 1=send and request read receipts
* default=send and request read receipts, only send but not request if `bot` is set
@@ -458,6 +459,12 @@ char* dc_get_blobdir (const dc_context_t* context);
* The library uses the `media_quality` setting to use different defaults
* for recoding images sent with type #DC_MSG_IMAGE.
* If needed, recoding other file types is up to the UI.
* - `webrtc_instance` = webrtc instance to use for videochats in the form
* `[basicwebrtc:|jitsi:]https://example.com/subdir#roomname=$ROOM`
* if the URL is prefixed by `basicwebrtc`, the server is assumed to be of the type
* https://github.com/cracker0dks/basicwebrtc which some UIs have native support for.
* The type `jitsi:` may be handled by external apps.
* If no type is prefixed, the videochat is handled completely in a browser.
* - `bot` = Set to "1" if this is a bot.
* Prevents adding the "Device messages" and "Saved messages" chats,
* adds Auto-Submitted header to outgoing messages,
@@ -496,6 +503,13 @@ char* dc_get_blobdir (const dc_context_t* context);
* - `gossip_period` = How often to gossip Autocrypt keys in chats with multiple recipients, in
* seconds. 2 days by default.
* This is not supposed to be changed by UIs and only used for testing.
* - `verified_one_on_one_chats` = Feature flag for verified 1:1 chats; the UI should set it
* to 1 if it supports verified 1:1 chats.
* Regardless of this setting, `dc_chat_is_protected()` returns true while the key is verified,
* and when the key changes, an info message is posted into the chat.
* 0=Nothing else happens when the key changes.
* 1=After the key changed, `dc_chat_can_send()` returns false and `dc_chat_is_protection_broken()` returns true
* until `dc_accept_chat()` is called.
* - `is_chatmail` = 1 if the the server is a chatmail server, 0 otherwise.
* - `is_muted` = Whether a context is muted by the user.
* Muted contexts should not sound, vibrate or show notifications.
@@ -569,10 +583,11 @@ int dc_set_stock_translation(dc_context_t* context, uint32_t stock_i
/**
* Set configuration values from a QR code.
* Before this function is called, dc_check_qr() should confirm the type of the
* QR code is DC_QR_ACCOUNT or DC_QR_LOGIN.
* QR code is DC_QR_ACCOUNT, DC_QR_LOGIN or DC_QR_WEBRTC_INSTANCE.
*
* Internally, the function will call dc_set_config() with the appropriate keys,
* e.g. `addr` and `mail_pw` for DC_QR_ACCOUNT and DC_QR_LOGIN.
* e.g. `addr` and `mail_pw` for DC_QR_ACCOUNT and DC_QR_LOGIN
* or `webrtc_instance` for DC_QR_WEBRTC_INSTANCE.
*
* @memberof dc_context_t
* @param context The context object.
@@ -1045,6 +1060,42 @@ void dc_send_edit_request (dc_context_t* context, uint32_t ms
void dc_send_delete_request (dc_context_t* context, const uint32_t* msg_ids, int msg_cnt);
/**
* Send invitation to a videochat.
*
* This function reads the `webrtc_instance` config value,
* may check that the server is working in some way
* and creates a unique room for this chat, if needed doing a TOKEN roundtrip for that.
*
* After that, the function sends out a message that contains information to join the room:
*
* - To allow non-delta-clients to join the chat,
* the message contains a text-area with some descriptive text
* and a URL that can be opened in a supported browser to join the videochat.
*
* - delta-clients can get all information needed from
* the message object, using e.g.
* dc_msg_get_videochat_url() and check dc_msg_get_viewtype() for #DC_MSG_VIDEOCHAT_INVITATION.
*
* dc_send_videochat_invitation() is blocking and may take a while,
* so the UIs will typically call the function from within a thread.
* Moreover, UIs will typically enter the room directly without an additional click on the message,
* for this purpose, the function returns the message id directly.
*
* As for other messages sent, this function
* sends the event #DC_EVENT_MSGS_CHANGED on success, the message has a delivery state, and so on.
* The recipient will get noticed by the call as usual by #DC_EVENT_INCOMING_MSG or #DC_EVENT_MSGS_CHANGED,
* However, UIs might some things differently, e.g. play a different sound.
*
* @memberof dc_context_t
* @param context The context object.
* @param chat_id The chat to start a videochat for.
* @return The ID of the message sent out
* or 0 for errors.
*/
uint32_t dc_send_videochat_invitation (dc_context_t* context, uint32_t chat_id);
/**
* A webxdc instance sends a status update to its other members.
*
@@ -1171,117 +1222,6 @@ void dc_set_webxdc_integration (dc_context_t* context, const char* f
uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t chat_id);
/**
* Start an outgoing call.
* This sends a message of type #DC_MSG_CALL with all relevant information to the callee,
* who will get informed by an #DC_EVENT_INCOMING_CALL event and rings.
*
* Possible actions during ringing:
*
* - caller cancels the call using dc_end_call():
* callee receives #DC_EVENT_CALL_ENDED and has a "Missed call"
*
* - callee accepts using dc_accept_incoming_call():
* caller receives #DC_EVENT_OUTGOING_CALL_ACCEPTED.
* callee's devices receive #DC_EVENT_INCOMING_CALL_ACCEPTED, call starts
*
* - callee declines using dc_end_call():
* caller receives #DC_EVENT_CALL_ENDED and has a "Declinced Call".
* callee's other devices receive #DC_EVENT_CALL_ENDED and have a "Canceled Call",
*
* - callee is already in a call:
* what to do depends on the capabilities of UI to handle calls.
* if UI cannot handle multiple calls, an easy approach would be to decline the new call automatically
* and make that visble to the user in the call, e.g. by a notification
*
* - timeout:
* after 1 minute without action,
* caller and callee receive #DC_EVENT_CALL_ENDED
* to prevent endless ringing of callee
* in case caller got offline without being able to send cancellation message.
* for caller, this is a "Canceled call";
* for callee, this is a "Missed call"
*
* Actions during the call:
*
* - caller ends the call using dc_end_call():
* callee receives #DC_EVENT_CALL_ENDED
*
* - callee ends the call using dc_end_call():
* caller receives #DC_EVENT_CALL_ENDED
*
* Contact request handling:
*
* - placing or accepting calls implies accepting contact requests
*
* - ending a call does not accept a contact request;
* instead, the call will timeout on all affected devices.
*
* Note, that the events are for updating the call screen,
* possible status messages are added and updated as usual, including the known events.
* In the UI, the sorted chatlist is used as an overview about calls as well as messages.
* To place a call with a contact that has no chat yet, use dc_create_chat_by_contact_id() first.
*
* UI will usually allow only one call at the same time,
* this has to be tracked by UI across profile, the core does not track this.
*
* @memberof dc_context_t
* @param context The context object.
* @param chat_id The chat to place a call for.
* This needs to be a one-to-one chat.
* @param place_call_info any data that other devices receive
* in #DC_EVENT_INCOMING_CALL.
* @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);
/**
* Accept incoming call.
*
* This implicitly accepts the contact request, if not yet done.
* All affected devices will receive
* either #DC_EVENT_OUTGOING_CALL_ACCEPTED or #DC_EVENT_INCOMING_CALL_ACCEPTED.
*
* If the call is already accepted or ended, nothing happens.
* If the chat is a contact request, it is accepted implicitly.
*
* @memberof dc_context_t
* @param context The context object.
* @param msg_id The ID of the call to accept.
* This is the ID reported by #DC_EVENT_INCOMING_CALL
* and equals to the ID of the corresponding info message.
* @param accept_call_info any data that other devices receive
* in #DC_EVENT_OUTGOING_CALL_ACCEPTED.
* @return 1=success, 0=error
*/
int dc_accept_incoming_call (dc_context_t* context, uint32_t msg_id, const char* accept_call_info);
/**
* End incoming or outgoing call.
*
* For unaccepted calls ended by the caller, this is a "cancellation".
* Unaccepted calls ended by the callee are a "decline".
* If the call was accepted, this is a "hangup".
*
* All participant devices get informed about the ended call via #DC_EVENT_CALL_ENDED unless they are contact requests.
* For contact requests, the call times out on all other affected devices.
*
* If the message ID is wrong or does not exist for whatever reasons, nothing happens.
* Therefore, and for resilience, UI should remove the call UI directly when calling
* this function and not only on the event.
*
* If the call is already ended, nothing happens.
*
* @memberof dc_context_t
* @param context The context object.
* @param msg_id the ID of the call.
* @return 1=success, 0=error
*/
int dc_end_call (dc_context_t* context, uint32_t msg_id);
/**
* Save a draft for a chat in the database.
*
@@ -1399,14 +1339,12 @@ dc_msg_t* dc_get_draft (dc_context_t* context, uint32_t ch
* Optionally, some special markers added to the ID array may help to
* implement virtual lists.
*
* To get the concrete time of the message, use dc_array_get_timestamp().
*
* @memberof dc_context_t
* @param context The context object as returned from dc_context_new().
* @param chat_id The chat ID of which the messages IDs should be queried.
* @param flags If set to DC_GCM_ADDDAYMARKER, the marker DC_MSG_ID_DAYMARKER will
* be added before each day (regarding the local timezone). Set this to 0 if you do not want this behaviour.
* The day marker timestamp is the midnight one for the corresponding (following) day in the local timezone.
* To get the concrete time of the marker, use dc_array_get_timestamp().
* If set to DC_GCM_INFO_ONLY, only system messages will be returned, can be combined with DC_GCM_ADDDAYMARKER.
* @param marker1before Deprecated, set this to 0.
* @return Array of message IDs, must be dc_array_unref()'d when no longer used.
@@ -2156,19 +2094,9 @@ int dc_may_be_valid_addr (const char* addr);
/**
* Looks up a known and unblocked contact with a given e-mail address.
* Check if an e-mail address belongs to a known and unblocked contact.
* To get a list of all known and unblocked contacts, use dc_get_contacts().
*
* **POTENTIAL SECURITY ISSUE**: If there are multiple contacts with this address
* (e.g. an address-contact and a key-contact),
* this looks up the most recently seen contact,
* i.e. which contact is returned depends on which contact last sent a message.
* If the user just clicked on a mailto: link, then this is the best thing you can do.
* But **DO NOT** internally represent contacts by their email address
* and do not use this function to look them up;
* otherwise this function will sometimes look up the wrong contact.
* Instead, you should internally represent contacts by their ids.
*
* To validate an e-mail address independently of the contact database
* use dc_may_be_valid_addr().
*
@@ -2190,13 +2118,6 @@ uint32_t dc_lookup_contact_id_by_addr (dc_context_t* context, const char*
* To add a number of contacts, see dc_add_address_book() which is much faster for adding
* a bunch of addresses.
*
* This will always create or look up an address-contact,
* i.e. a contact identified by an email address,
* with all messages sent to and from this contact being unencrypted.
* If the user just clicked on an email address,
* you should first check `lookup_contact_id_by_addr`,
* and only if there is no contact yet, call this function here.
*
* May result in a #DC_EVENT_CONTACTS_CHANGED event.
*
* @memberof dc_context_t
@@ -2572,6 +2493,7 @@ void dc_stop_ongoing_process (dc_context_t* context);
#define DC_QR_BACKUP 251 // deprecated
#define DC_QR_BACKUP2 252
#define DC_QR_BACKUP_TOO_NEW 255
#define DC_QR_WEBRTC_INSTANCE 260 // text1=domain, text2=instance pattern
#define DC_QR_PROXY 271 // text1=address (e.g. "127.0.0.1:9050")
#define DC_QR_ADDR 320 // id=contact
#define DC_QR_TEXT 330 // text1=text
@@ -2625,6 +2547,10 @@ void dc_stop_ongoing_process (dc_context_t* context);
* show a hint to the user that this backup comes from a newer Delta Chat version
* and this device needs an update
*
* - DC_QR_WEBRTC_INSTANCE with dc_lot_t::text1=domain:
* ask the user if they want to use the given service for video chats;
* if so, call dc_set_config_from_qr().
*
* - DC_QR_PROXY with dc_lot_t::text1=address:
* ask the user if they want to use the given proxy.
* if so, call dc_set_config_from_qr() and restart I/O.
@@ -3892,12 +3818,21 @@ int dc_chat_can_send (const dc_chat_t* chat);
/**
* Check if a chat is protected.
*
* Only verified contacts
* End-to-end encryption is guaranteed in protected chats
* and only verified contacts
* as determined by dc_contact_is_verified()
* can be added to protected chats.
*
* Protected chats are created using dc_create_group_chat()
* by setting the 'protect' parameter to 1.
* 1:1 chats become protected or unprotected automatically
* if `verified_one_on_one_chats` setting is enabled.
*
* UI should display a green checkmark
* in the chat title,
* in the chatlist item
* and in the chat profile
* if chat protection is enabled.
*
* @memberof dc_chat_t
* @param chat The chat object.
@@ -3921,6 +3856,26 @@ int dc_chat_is_protected (const dc_chat_t* chat);
int dc_chat_is_encrypted (const dc_chat_t *chat);
/**
* Checks if the chat was protected, and then an incoming message broke this protection.
*
* This function is only useful if the UI enabled the `verified_one_on_one_chats` feature flag,
* otherwise it will return false for all chats.
*
* 1:1 chats are automatically set as protected when a contact is verified.
* When a message comes in that is not encrypted / signed correctly,
* the chat is automatically set as unprotected again.
* dc_chat_is_protection_broken() will return true until dc_accept_chat() is called.
*
* The UI should let the user confirm that this is OK with a message like
* `Bob sent a message from another device. Tap to learn more` and then call dc_accept_chat().
* @memberof dc_chat_t
* @param chat The chat object.
* @return 1=chat protection broken, 0=otherwise.
*/
int dc_chat_is_protection_broken (const dc_chat_t* chat);
/**
* Check if locations are sent to the chat
* at the time the object was created using dc_get_chat().
@@ -4696,6 +4651,22 @@ int dc_msg_is_setupmessage (const dc_msg_t* msg);
char* dc_msg_get_setupcodebegin (const dc_msg_t* msg);
/**
* Get URL of a videochat invitation.
*
* Videochat invitations are sent out using dc_send_videochat_invitation()
* and dc_msg_get_viewtype() returns #DC_MSG_VIDEOCHAT_INVITATION for such invitations.
*
* @memberof dc_msg_t
* @param msg The message object.
* @return If the message contains a videochat invitation,
* the URL of the invitation is returned.
* If the message is no videochat invitation, NULL is returned.
* Must be released using dc_str_unref() when done.
*/
char* dc_msg_get_videochat_url (const dc_msg_t* msg);
/**
* Gets the error status of the message.
* If there is no error associated with the message, NULL is returned.
@@ -4718,6 +4689,41 @@ char* dc_msg_get_setupcodebegin (const dc_msg_t* msg);
char* dc_msg_get_error (const dc_msg_t* msg);
/**
* Get type of videochat.
*
* Calling this functions only makes sense for messages of type #DC_MSG_VIDEOCHAT_INVITATION,
* in this case, if `basicwebrtc:` as of https://github.com/cracker0dks/basicwebrtc or `jitsi`
* were used to initiate the videochat,
* dc_msg_get_videochat_type() returns the corresponding type.
*
* The videochat URL can be retrieved using dc_msg_get_videochat_url().
* To check if a message is a videochat invitation at all, check the message type for #DC_MSG_VIDEOCHAT_INVITATION.
*
* @memberof dc_msg_t
* @param msg The message object.
* @return Type of the videochat as of DC_VIDEOCHATTYPE_BASICWEBRTC, DC_VIDEOCHATTYPE_JITSI or DC_VIDEOCHATTYPE_UNKNOWN.
*
* Example:
* ~~~
* if (dc_msg_get_viewtype(msg) == DC_MSG_VIDEOCHAT_INVITATION) {
* if (dc_msg_get_videochat_type(msg) == DC_VIDEOCHATTYPE_BASICWEBRTC) {
* // videochat invitation that we ship a client for
* } else {
* // use browser for videochat - or add an additional check for DC_VIDEOCHATTYPE_JITSI
* }
* } else {
* // not a videochat invitation
* }
* ~~~
*/
int dc_msg_get_videochat_type (const dc_msg_t* msg);
#define DC_VIDEOCHATTYPE_UNKNOWN 0
#define DC_VIDEOCHATTYPE_BASICWEBRTC 1
#define DC_VIDEOCHATTYPE_JITSI 2
/**
* Checks if the message has a full HTML version.
*
@@ -5261,14 +5267,20 @@ int dc_contact_is_blocked (const dc_contact_t* contact);
/**
* Check if the contact
* can be added to protected chats.
* can be added to verified chats,
* i.e. has a verified key
* and Autocrypt key matches the verified key.
*
* See dc_contact_get_verifier_id() for a guidance how to display these information.
* If contact is verified
* UI should display green checkmark after the contact name
* in contact list items,
* in chat member list items
* and in profiles if no chat with the contact exist (otherwise, use dc_chat_is_protected()).
*
* @memberof dc_contact_t
* @param contact The contact object.
* @return 0: contact is not verified.
* 2: SELF and contact have verified their fingerprints in both directions.
* 2: SELF and contact have verified their fingerprints in both directions; in the UI typically checkmarks are shown.
*/
int dc_contact_is_verified (dc_contact_t* contact);
@@ -5299,22 +5311,16 @@ int dc_contact_is_key_contact (dc_contact_t* contact);
/**
* Return the contact ID that verified a contact.
*
* As verifier may be unknown,
* use dc_contact_is_verified() to check if a contact can be added to a protected chat.
* If the function returns non-zero result,
* display green checkmark in the profile and "Introduced by ..." line
* with the name and address of the contact
* formatted by dc_contact_get_name_n_addr.
*
* UI should display the information in the contact's profile as follows:
*
* - If dc_contact_get_verifier_id() != 0,
* display text "Introduced by ..."
* with the name and address of the contact
* formatted by dc_contact_get_name_n_addr().
* Prefix the text by a green checkmark.
*
* - If dc_contact_get_verifier_id() == 0 and dc_contact_is_verified() != 0,
* display "Introduced" prefixed by a green checkmark.
*
* - if dc_contact_get_verifier_id() == 0 and dc_contact_is_verified() == 0,
* display nothing
* If this function returns a verifier,
* this does not necessarily mean
* you can add the contact to verified chats.
* Use dc_contact_is_verified() to check
* if a contact can be added to a verified chat instead.
*
* @memberof dc_contact_t
* @param contact The contact object.
@@ -5617,21 +5623,14 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot);
/**
* Message indicating an incoming or outgoing call.
* Message indicating an incoming or outgoing videochat.
* The message was created via dc_send_videochat_invitation() on this or a remote device.
*
* These messages are created by dc_place_outgoing_call()
* and should be rendered by UI similar to text messages,
* maybe with some "phone icon" at the side.
*
* The message text is updated as needed
* and UI will be informed via #DC_EVENT_MSGS_CHANGED as usual.
*
* Do not start ringing when seeing this message;
* the mesage may belong e.g. to an old missed call.
*
* Instead, ringing should start on the event #DC_EVENT_INCOMING_CALL
* Typically, such messages are rendered differently by the UIs,
* e.g. contain a button to join the videochat.
* The URL for joining can be retrieved using dc_msg_get_videochat_url().
*/
#define DC_MSG_CALL 71
#define DC_MSG_VIDEOCHAT_INVITATION 70
/**
@@ -6387,6 +6386,7 @@ void dc_event_unref(dc_event_t* event);
/**
* Chat changed. The name or the image of a chat group was changed or members were added or removed.
* Or the verify state of a chat has changed.
* See dc_set_chat_name(), dc_set_chat_profile_image(), dc_add_contact_to_chat()
* and dc_remove_contact_from_chat().
*
@@ -6476,7 +6476,11 @@ void dc_event_unref(dc_event_t* event);
* generated by dc_get_securejoin_qr().
*
* @param data1 (int) The ID of the contact that wants to join.
* @param data2 (int) The progress, always 1000.
* @param data2 (int) The progress as:
* 300=vg-/vc-request received, typically shown as "bob@addr joins".
* 600=vg-/vc-request-with-auth received, vg-member-added/vc-contact-confirm sent, typically shown as "bob@addr verified".
* 800=contact added to chat, shown as "bob@addr securely joined GROUP". Only for the verified-group-protocol.
* 1000=Protocol finished for this contact.
*/
#define DC_EVENT_SECUREJOIN_INVITER_PROGRESS 2060
@@ -6628,60 +6632,6 @@ void dc_event_unref(dc_event_t* event);
*/
#define DC_EVENT_CHANNEL_OVERFLOW 2400
/**
* Incoming call.
* UI will usually start ringing,
* or show a notification if there is already a call in some profile.
*
* Together with this event,
* a message of type #DC_MSG_CALL is added to the corresponding chat;
* this message is announced and updated by the usual event as #DC_EVENT_MSGS_CHANGED,
* there is usually no need to take care of this message from any of the CALL events.
*
* If user takes action, dc_accept_incoming_call() or dc_end_call() should be called.
*
* Otherwise, ringing should end on #DC_EVENT_CALL_ENDED
* or #DC_EVENT_INCOMING_CALL_ACCEPTED
*
* @param data1 (int) msg_id ID of the message referring to the call.
* @param data2 (char*) place_call_info, text passed to dc_place_outgoing_call()
* @param data2 (int) 1 if incoming call is a video call, 0 otherwise
*/
#define DC_EVENT_INCOMING_CALL 2550
/**
* The callee accepted an incoming call on this or another device using dc_accept_incoming_call().
* The caller gets the event #DC_EVENT_OUTGOING_CALL_ACCEPTED at the same time.
*
* 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
*/
#define DC_EVENT_INCOMING_CALL_ACCEPTED 2560
/**
* A call placed using dc_place_outgoing_call() was accepted by the callee using dc_accept_incoming_call().
*
* 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 (char*) accept_call_info, text passed to dc_accept_incoming_call()
*/
#define DC_EVENT_OUTGOING_CALL_ACCEPTED 2570
/**
* An incoming or outgoing call was ended using dc_end_call() on this or another device, by caller or callee.
* Moreover, the event is sent when the call was not accepted within 1 minute timeout.
*
* 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
*/
#define DC_EVENT_CALL_ENDED 2580
/**
* @}
*/
@@ -7102,8 +7052,6 @@ void dc_event_unref(dc_event_t* event);
/// "Unknown sender for this chat. See 'info' for more details."
///
/// Use as message text if assigning the message to a chat is not totally correct.
///
/// @deprecated 2025-08-18
#define DC_STR_UNKNOWN_SENDER_FOR_CHAT 72
/// "Message from %1$s"
@@ -7166,6 +7114,17 @@ void dc_event_unref(dc_event_t* event);
/// @deprecated Deprecated 2021-01-30, DC_STR_EPHEMERAL_WEEKS is used instead.
#define DC_STR_EPHEMERAL_FOUR_WEEKS 81
/// "Video chat invitation"
///
/// Used in summaries.
#define DC_STR_VIDEOCHAT_INVITATION 82
/// "You are invited to a video chat, click %1$s to join."
///
/// Used as message text of outgoing video chat invitations.
/// - %1$s will be replaced by the URL of the video chat
#define DC_STR_VIDEOCHAT_INVITE_MSG_BODY 83
/// "Error: %1$s"
///
/// Used in error strings.
@@ -7464,7 +7423,7 @@ void dc_event_unref(dc_event_t* event);
/// Used in status messages.
#define DC_STR_REMOVE_MEMBER_BY_OTHER 131
/// "You left the group."
/// "You left."
///
/// Used in status messages.
#define DC_STR_GROUP_LEFT_BY_YOU 132
@@ -7635,18 +7594,6 @@ void dc_event_unref(dc_event_t* event);
/// `%2$s` will be replaced by name and address of the contact.
#define DC_STR_EPHEMERAL_TIMER_WEEKS_BY_OTHER 157
/// "You set message deletion timer to 1 year."
///
/// Used in status messages.
#define DC_STR_EPHEMERAL_TIMER_1_YEAR_BY_YOU 158
/// "Message deletion timer is set to 1 year by %1$s."
///
/// `%1$s` will be replaced by name and address of the contact.
///
/// Used in status messages.
#define DC_STR_EPHEMERAL_TIMER_1_YEAR_BY_OTHER 159
/// "Scan to set up second device for %1$s"
///
/// `%1$s` will be replaced by name and address of the account.
@@ -7687,12 +7634,6 @@ void dc_event_unref(dc_event_t* event);
/// `%1$s` will be replaced by the provider's domain.
#define DC_STR_INVALID_UNENCRYPTED_MAIL 174
/// "⚠️ It seems you are using Delta Chat on multiple devices that cannot decrypt each other's outgoing messages. To fix this, on the older device use \"Settings / Add Second Device\" and follow the instructions."
///
/// Added to the device chat if could not decrypt a new outgoing message (i.e. not when fetching
/// existing messages). But no more than once a day.
#define DC_STR_CANT_DECRYPT_OUTGOING_MSGS 175
/// "You reacted %1$s to '%2$s'"
///
/// `%1$s` will be replaced by the reaction, usually an emoji
@@ -7729,32 +7670,8 @@ 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
/// "Canceled call"
#define DC_STR_CANCELED_CALL 197
/// "Missed call"
#define DC_STR_MISSED_CALL 198
/// "You left the channel."
///
/// Used in status messages.
#define DC_STR_CHANNEL_LEFT_BY_YOU 200
/// "Scan to join channel %1$s"
///
/// Subtitle for channel join qrcode svg image generated by the core.
///
/// `%1$s` will be replaced with the channel name.
#define DC_STR_SECURE_JOIN_CHANNEL_QR_DESC 201
/// "Contact". Deprecated, currently unused.
#define DC_STR_CONTACT 200
/**
* @}

View File

@@ -375,7 +375,7 @@ pub unsafe extern "C" fn dc_get_connectivity(context: *const dc_context_t) -> li
return 0;
}
let ctx = &*context;
ctx.get_connectivity() as u32 as libc::c_int
block_on(ctx.get_connectivity()) as u32 as libc::c_int
}
#[no_mangle]
@@ -556,10 +556,6 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
EventType::AccountsChanged => 2302,
EventType::AccountsItemChanged => 2303,
EventType::EventChannelOverflow { .. } => 2400,
EventType::IncomingCall { .. } => 2550,
EventType::IncomingCallAccepted { .. } => 2560,
EventType::OutgoingCallAccepted { .. } => 2570,
EventType::CallEnded { .. } => 2580,
#[allow(unreachable_patterns)]
#[cfg(test)]
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),
@@ -623,11 +619,7 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
EventType::WebxdcRealtimeData { msg_id, .. }
| EventType::WebxdcStatusUpdate { msg_id, .. }
| EventType::WebxdcRealtimeAdvertisementReceived { msg_id }
| EventType::WebxdcInstanceDeleted { msg_id, .. }
| EventType::IncomingCall { msg_id, .. }
| EventType::IncomingCallAccepted { msg_id, .. }
| EventType::OutgoingCallAccepted { msg_id, .. }
| EventType::CallEnded { msg_id, .. } => msg_id.to_u32() as libc::c_int,
| EventType::WebxdcInstanceDeleted { msg_id, .. } => msg_id.to_u32() as libc::c_int,
EventType::ChatlistItemChanged { chat_id } => {
chat_id.unwrap_or_default().to_u32() as libc::c_int
}
@@ -679,9 +671,6 @@ 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 { .. } => 0,
EventType::MsgsChanged { msg_id, .. }
| EventType::ReactionsChanged { msg_id, .. }
@@ -700,8 +689,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,
#[allow(unreachable_patterns)]
#[cfg(test)]
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),
@@ -780,21 +767,8 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
| EventType::ChatlistChanged
| EventType::AccountsChanged
| EventType::AccountsItemChanged
| EventType::IncomingCallAccepted { .. }
| EventType::WebxdcRealtimeAdvertisementReceived { .. } => ptr::null_mut(),
EventType::IncomingCall {
place_call_info, ..
} => {
let data2 = place_call_info.to_c_string().unwrap_or_default();
data2.into_raw()
}
EventType::OutgoingCallAccepted {
accept_call_info, ..
} => {
let data2 = accept_call_info.to_c_string().unwrap_or_default();
data2.into_raw()
}
EventType::CallEnded { .. } | EventType::EventChannelOverflow { .. } => ptr::null_mut(),
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
| EventType::EventChannelOverflow { .. } => ptr::null_mut(),
EventType::ConfigureProgress { comment, .. } => {
if let Some(comment) = comment {
comment.to_c_string().unwrap_or_default().into_raw()
@@ -1098,6 +1072,25 @@ pub unsafe extern "C" fn dc_send_delete_request(
.ok();
}
#[no_mangle]
pub unsafe extern "C" fn dc_send_videochat_invitation(
context: *mut dc_context_t,
chat_id: u32,
) -> u32 {
if context.is_null() {
eprintln!("ignoring careless call to dc_send_videochat_invitation()");
return 0;
}
let ctx = &*context;
block_on(async move {
chat::send_videochat_invitation(ctx, ChatId::new(chat_id))
.await
.map(|msg_id| msg_id.to_u32())
.unwrap_or_log_default(ctx, "Failed to send video chat invitation")
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_send_webxdc_status_update(
context: *mut dc_context_t,
@@ -1174,61 +1167,6 @@ pub unsafe extern "C" fn dc_init_webxdc_integration(
.unwrap_or(0)
}
#[no_mangle]
pub unsafe extern "C" fn dc_place_outgoing_call(
context: *mut dc_context_t,
chat_id: u32,
place_call_info: *const libc::c_char,
) -> u32 {
if context.is_null() || chat_id == 0 {
eprintln!("ignoring careless call to dc_place_outgoing_call()");
return 0;
}
let ctx = &*context;
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))
.context("Failed to place call")
.log_err(ctx)
.map(|msg_id| msg_id.to_u32())
.unwrap_or_log_default(ctx, "Failed to place call")
}
#[no_mangle]
pub unsafe extern "C" fn dc_accept_incoming_call(
context: *mut dc_context_t,
msg_id: u32,
accept_call_info: *const libc::c_char,
) -> libc::c_int {
if context.is_null() || msg_id == 0 {
eprintln!("ignoring careless call to dc_accept_incoming_call()");
return 0;
}
let ctx = &*context;
let msg_id = MsgId::new(msg_id);
let accept_call_info = to_string_lossy(accept_call_info);
block_on(ctx.accept_incoming_call(msg_id, accept_call_info))
.context("Failed to accept call")
.is_ok() as libc::c_int
}
#[no_mangle]
pub unsafe extern "C" fn dc_end_call(context: *mut dc_context_t, msg_id: u32) -> libc::c_int {
if context.is_null() || msg_id == 0 {
eprintln!("ignoring careless call to dc_end_call()");
return 0;
}
let ctx = &*context;
let msg_id = MsgId::new(msg_id);
block_on(ctx.end_call(msg_id))
.context("Failed to end call")
.log_err(ctx)
.is_ok() as libc::c_int
}
#[no_mangle]
pub unsafe extern "C" fn dc_set_draft(
context: *mut dc_context_t,
@@ -3227,6 +3165,16 @@ pub unsafe extern "C" fn dc_chat_is_encrypted(chat: *mut dc_chat_t) -> libc::c_i
.unwrap_or_log_default(&ffi_chat.context, "Failed dc_chat_is_encrypted") as libc::c_int
}
#[no_mangle]
pub unsafe extern "C" fn dc_chat_is_protection_broken(chat: *mut dc_chat_t) -> libc::c_int {
if chat.is_null() {
eprintln!("ignoring careless call to dc_chat_is_protection_broken()");
return 0;
}
let ffi_chat = &*chat;
ffi_chat.chat.is_protection_broken() as libc::c_int
}
#[no_mangle]
pub unsafe extern "C" fn dc_chat_is_sending_locations(chat: *mut dc_chat_t) -> libc::c_int {
if chat.is_null() {
@@ -3835,6 +3783,31 @@ 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_videochat_url(msg: *mut dc_msg_t) -> *mut libc::c_char {
if msg.is_null() {
eprintln!("ignoring careless call to dc_msg_get_videochat_url()");
return "".strdup();
}
let ffi_msg = &*msg;
ffi_msg
.message
.get_videochat_url()
.unwrap_or_default()
.strdup()
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_get_videochat_type(msg: *mut dc_msg_t) -> libc::c_int {
if msg.is_null() {
eprintln!("ignoring careless call to dc_msg_get_videochat_type()");
return 0;
}
let ffi_msg = &*msg;
ffi_msg.message.get_videochat_type().unwrap_or_default() as i32
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_get_setupcodebegin(msg: *mut dc_msg_t) -> *mut libc::c_char {
if msg.is_null() {

View File

@@ -51,6 +51,7 @@ impl Lot {
Qr::Account { domain } => Some(Cow::Borrowed(domain)),
Qr::Backup2 { .. } => None,
Qr::BackupTooNew { .. } => None,
Qr::WebrtcInstance { domain, .. } => Some(Cow::Borrowed(domain)),
Qr::Proxy { host, port, .. } => Some(Cow::Owned(format!("{host}:{port}"))),
Qr::Addr { draft, .. } => draft.as_deref().map(Cow::Borrowed),
Qr::Url { url } => Some(Cow::Borrowed(url)),
@@ -104,6 +105,7 @@ impl Lot {
Qr::Account { .. } => LotState::QrAccount,
Qr::Backup2 { .. } => LotState::QrBackup2,
Qr::BackupTooNew { .. } => LotState::QrBackupTooNew,
Qr::WebrtcInstance { .. } => LotState::QrWebrtcInstance,
Qr::Proxy { .. } => LotState::QrProxy,
Qr::Addr { .. } => LotState::QrAddr,
Qr::Url { .. } => LotState::QrUrl,
@@ -130,6 +132,7 @@ impl Lot {
Qr::Account { .. } => Default::default(),
Qr::Backup2 { .. } => Default::default(),
Qr::BackupTooNew { .. } => Default::default(),
Qr::WebrtcInstance { .. } => Default::default(),
Qr::Proxy { .. } => Default::default(),
Qr::Addr { contact_id, .. } => contact_id.to_u32(),
Qr::Url { .. } => Default::default(),
@@ -182,6 +185,9 @@ pub enum LotState {
QrBackupTooNew = 255,
/// text1=domain, text2=instance pattern
QrWebrtcInstance = 260,
/// text1=address, text2=protocol
QrProxy = 271,

View File

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

View File

@@ -8,7 +8,6 @@ use std::{collections::HashMap, str::FromStr};
use anyhow::{anyhow, bail, ensure, Context, Result};
pub use deltachat::accounts::Accounts;
use deltachat::blob::BlobObject;
use deltachat::calls::ice_servers;
use deltachat::chat::{
self, add_contact_to_chat, forward_msgs, get_chat_media, get_chat_msgs, get_chat_msgs_ex,
marknoticed_chat, remove_contact_from_chat, Chat, ChatId, ChatItem, MessageListOptions,
@@ -48,7 +47,6 @@ pub mod types;
use num_traits::FromPrimitive;
use types::account::Account;
use types::calls::JsonrpcCallInfo;
use types::chat::FullChat;
use types::contact::{ContactObject, VcardContact};
use types::events::Event;
@@ -93,8 +91,7 @@ pub struct CommandApi {
/// Receiver side of the event channel.
///
/// Events from it can be received by calling
/// [`CommandApi::get_next_event`] method.
/// Events from it can be received by calling `get_next_event` method.
event_emitter: Arc<EventEmitter>,
states: Arc<Mutex<BTreeMap<u32, AccountState>>>,
@@ -126,7 +123,7 @@ impl CommandApi {
.read()
.await
.get_account(id)
.ok_or_else(|| anyhow!("account with id {id} not found"))?;
.ok_or_else(|| anyhow!("account with id {} not found", id))?;
Ok(sc)
}
@@ -176,15 +173,7 @@ impl CommandApi {
get_info()
}
/// Get the next event, and remove it from the event queue.
///
/// If no events have happened since the last `get_next_event`
/// (i.e. if the event queue is empty), the response will be returned
/// only when a new event fires.
///
/// Note that if you are using the `BaseDeltaChat` JavaScript class
/// or the `Rpc` Python class, this function will be invoked
/// by those classes internally and should not be used manually.
/// Get the next event.
async fn get_next_event(&self) -> Result<Event> {
self.event_emitter
.recv()
@@ -308,7 +297,8 @@ impl CommandApi {
Ok(Account::from_context(&ctx, account_id).await?)
} else {
Err(anyhow!(
"account with id {account_id} doesn't exist anymore"
"account with id {} doesn't exist anymore",
account_id
))
}
}
@@ -1237,10 +1227,8 @@ impl CommandApi {
}
/// Returns all messages of a particular chat.
///
/// * `add_daymarker` - If `true`, add day markers as `DC_MSG_ID_DAYMARKER` to the result,
/// e.g. [1234, 1237, 9, 1239]. The day marker timestamp is the midnight one for the
/// corresponding (following) day in the local timezone.
/// If `add_daymarker` is `true`, it will return them as
/// `DC_MSG_ID_DAYMARKER`, e.g. [1234, 1237, 9, 1239].
async fn get_message_ids(
&self,
account_id: u32,
@@ -1483,14 +1471,7 @@ impl CommandApi {
/// Add a single contact as a result of an explicit user action.
///
/// This will always create or look up an address-contact,
/// i.e. a contact identified by an email address,
/// with all messages sent to and from this contact being unencrypted.
/// If the user just clicked on an email address,
/// you should first check [`Self::lookup_contact_id_by_addr`]/`lookupContactIdByAddr.`,
/// and only if there is no contact yet, call this function here.
///
/// Returns contact id of the created or existing contact.
/// Returns contact id of the created or existing contact
async fn create_contact(
&self,
account_id: u32,
@@ -1640,19 +1621,9 @@ impl CommandApi {
Contact::get_encrinfo(&ctx, ContactId::new(contact_id)).await
}
/// Looks up a known and unblocked contact with a given e-mail address.
/// Check if an e-mail address belongs to a known and unblocked contact.
/// To get a list of all known and unblocked contacts, use contacts_get_contacts().
///
/// **POTENTIAL SECURITY ISSUE**: If there are multiple contacts with this address
/// (e.g. an address-contact and a key-contact),
/// this looks up the most recently seen contact,
/// i.e. which contact is returned depends on which contact last sent a message.
/// If the user just clicked on a mailto: link, then this is the best thing you can do.
/// But **DO NOT** internally represent contacts by their email address
/// and do not use this function to look them up;
/// otherwise this function will sometimes look up the wrong contact.
/// Instead, you should internally represent contacts by their ids.
///
/// To validate an e-mail address independently of the contact database
/// use check_email_validity().
async fn lookup_contact_id_by_addr(
@@ -1808,13 +1779,13 @@ impl CommandApi {
/// Offers a backup for remote devices to retrieve.
///
/// Can be canceled by stopping the ongoing process. Success or failure can be tracked
/// Can be cancelled by stopping the ongoing process. Success or failure can be tracked
/// via the `ImexProgress` event which should either reach `1000` for success or `0` for
/// failure.
///
/// This **stops IO** while it is running.
///
/// Returns once a remote device has retrieved the backup, or is canceled.
/// Returns once a remote device has retrieved the backup, or is cancelled.
async fn provide_backup(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
@@ -1880,7 +1851,7 @@ impl CommandApi {
/// This retrieves the backup from a remote device over the network and imports it into
/// the current device.
///
/// Can be canceled by stopping the ongoing process.
/// Can be cancelled by stopping the ongoing process.
///
/// Do not forget to call start_io on the account after a successful import,
/// otherwise it will not connect to the email server.
@@ -1918,7 +1889,7 @@ impl CommandApi {
/// If the connectivity changes, a #DC_EVENT_CONNECTIVITY_CHANGED will be emitted.
async fn get_connectivity(&self, account_id: u32) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
Ok(ctx.get_connectivity() as u32)
Ok(ctx.get_connectivity().await as u32)
}
/// Get an overview of the current connectivity, and possibly more statistics.
@@ -2001,11 +1972,6 @@ impl CommandApi {
Ok(())
}
/// Leaves the gossip of the webxdc with the given message id.
///
/// NB: When this is called before closing a webxdc app in UIs, it must be guaranteed that
/// `send_webxdc_realtime_*()` functions aren't called for the given `instance_message_id`
/// anymore until the app is open again.
async fn leave_webxdc_realtime(&self, account_id: u32, instance_message_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
leave_webxdc_realtime(&ctx, MsgId::new(instance_message_id)).await
@@ -2083,53 +2049,6 @@ impl CommandApi {
.map(|msg_id| msg_id.to_u32()))
}
/// Starts an outgoing call.
async fn place_outgoing_call(
&self,
account_id: u32,
chat_id: u32,
place_call_info: String,
) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
let msg_id = ctx
.place_outgoing_call(ChatId::new(chat_id), place_call_info)
.await?;
Ok(msg_id.to_u32())
}
/// Accepts an incoming call.
async fn accept_incoming_call(
&self,
account_id: u32,
msg_id: u32,
accept_call_info: String,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.accept_incoming_call(MsgId::new(msg_id), accept_call_info)
.await?;
Ok(())
}
/// Ends incoming or outgoing call.
async fn end_call(&self, account_id: u32, msg_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.end_call(MsgId::new(msg_id)).await?;
Ok(())
}
/// Returns information about the call.
async fn call_info(&self, account_id: u32, msg_id: u32) -> Result<JsonrpcCallInfo> {
let ctx = self.get_context(account_id).await?;
let call_info = JsonrpcCallInfo::from_msg_id(&ctx, MsgId::new(msg_id)).await?;
Ok(call_info)
}
/// Returns JSON with ICE servers, to be used for WebRTC video calls.
async fn ice_servers(&self, account_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
ice_servers(&ctx).await
}
/// Makes an HTTP GET request and returns a response.
///
/// `url` is the HTTP or HTTPS URL.
@@ -2281,6 +2200,13 @@ impl CommandApi {
}
}
async fn send_videochat_invitation(&self, account_id: u32, chat_id: u32) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
chat::send_videochat_invitation(&ctx, ChatId::new(chat_id))
.await
.map(|msg_id| msg_id.to_u32())
}
// ---------------------------------------------
// misc prototyping functions
// that might get removed later again
@@ -2311,7 +2237,8 @@ impl CommandApi {
let message = Message::load_from_db(&ctx, MsgId::new(msg_id)).await?;
ensure!(
message.get_viewtype() == Viewtype::Sticker,
"message {msg_id} is not a sticker"
"message {} is not a sticker",
msg_id
);
let account_folder = ctx
.get_dbfile()
@@ -2531,7 +2458,10 @@ impl CommandApi {
.to_u32();
Ok(msg_id)
} else {
Err(anyhow!("chat with id {chat_id} doesn't have draft message"))
Err(anyhow!(
"chat with id {} doesn't have draft message",
chat_id
))
}
}
}

View File

@@ -1,97 +0,0 @@
use anyhow::{Context as _, Result};
use deltachat::calls::{call_state, sdp_has_video, CallState};
use deltachat::context::Context;
use deltachat::message::MsgId;
use serde::Serialize;
use typescript_type_def::TypeDef;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "CallInfo", rename_all = "camelCase")]
pub struct JsonrpcCallInfo {
/// SDP offer.
///
/// Can be used to manually answer the call
/// even if incoming call event was missed.
pub sdp_offer: String,
/// True if SDP offer has a video.
pub has_video: bool,
/// Call state.
///
/// For example, if the call is accepted, active, canceled, declined etc.
pub state: JsonrpcCallState,
}
impl JsonrpcCallInfo {
pub async fn from_msg_id(context: &Context, msg_id: MsgId) -> Result<JsonrpcCallInfo> {
let call_info = context.load_call_by_id(msg_id).await?.with_context(|| {
format!("Attempting to get call state of non-call message {msg_id}")
})?;
let sdp_offer = call_info.place_call_info.clone();
let has_video = sdp_has_video(&sdp_offer).unwrap_or_default();
let state = JsonrpcCallState::from_msg_id(context, msg_id).await?;
Ok(JsonrpcCallInfo {
sdp_offer,
has_video,
state,
})
}
}
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "CallState", tag = "kind")]
pub enum JsonrpcCallState {
/// Fresh incoming or outgoing call that is still ringing.
///
/// There is no separate state for outgoing call
/// that has been dialled but not ringing on the other side yet
/// as we don't know whether the other side received our call.
Alerting,
/// Active call.
Active,
/// Completed call that was once active
/// and then was terminated for any reason.
Completed {
/// Call duration in seconds.
duration: i64,
},
/// Incoming call that was not picked up within a timeout
/// or was explicitly ended by the caller before we picked up.
Missed,
/// Incoming call that was explicitly ended on our side
/// before picking up or outgoing call
/// that was declined before the timeout.
Declined,
/// Outgoing call that has been canceled on our side
/// before receiving a response.
///
/// Incoming calls cannot be canceled,
/// on the receiver side canceled calls
/// usually result in missed calls.
Canceled,
}
impl JsonrpcCallState {
pub async fn from_msg_id(context: &Context, msg_id: MsgId) -> Result<JsonrpcCallState> {
let call_state = call_state(context, msg_id).await?;
let jsonrpc_call_state = match call_state {
CallState::Alerting => JsonrpcCallState::Alerting,
CallState::Active => JsonrpcCallState::Active,
CallState::Completed { duration } => JsonrpcCallState::Completed { duration },
CallState::Missed => JsonrpcCallState::Missed,
CallState::Declined => JsonrpcCallState::Declined,
CallState::Canceled => JsonrpcCallState::Canceled,
};
Ok(jsonrpc_call_state)
}
}

View File

@@ -21,16 +21,15 @@ pub struct FullChat {
/// True if the chat is protected.
///
/// Only verified contacts
/// as determined by [`ContactObject::is_verified`] / `Contact.isVerified`
/// can be added to protected chats.
///
/// Protected chats are created using [`create_group_chat`] / `createGroupChat()`
/// by setting the 'protect' parameter to true.
///
/// [`create_group_chat`]: crate::api::CommandApi::create_group_chat
/// UI should display a green checkmark
/// in the chat title,
/// in the chat profile title and
/// in the chatlist item
/// if chat protection is enabled.
/// UI should also display a green checkmark
/// in the contact profile
/// if 1:1 chat with this contact exists and is protected.
is_protected: bool,
/// True if the chat is encrypted.
/// This means that all messages in the chat are encrypted,
/// and all contacts in the chat are "key-contacts",
@@ -71,7 +70,7 @@ pub struct FullChat {
fresh_message_counter: usize,
// is_group - please check over chat.type in frontend instead
is_contact_request: bool,
is_protection_broken: bool,
is_device_chat: bool,
self_in_group: bool,
is_muted: bool,
@@ -145,6 +144,7 @@ impl FullChat {
color,
fresh_message_counter,
is_contact_request: chat.is_contact_request(),
is_protection_broken: chat.is_protection_broken(),
is_device_chat: chat.is_device_talk(),
self_in_group: contact_ids.contains(&ContactId::SELF),
is_muted: chat.is_muted(),
@@ -215,7 +215,7 @@ pub struct BasicChat {
is_self_talk: bool,
color: String,
is_contact_request: bool,
is_protection_broken: bool,
is_device_chat: bool,
is_muted: bool,
}
@@ -244,6 +244,7 @@ impl BasicChat {
is_self_talk: chat.is_self_talk(),
color,
is_contact_request: chat.is_contact_request(),
is_protection_broken: chat.is_protection_broken(),
is_device_chat: chat.is_device_talk(),
is_muted: chat.is_muted(),
})

View File

@@ -31,36 +31,27 @@ pub struct ContactObject {
/// e.g. if we just scanned the fingerprint from a QR code.
e2ee_avail: bool,
/// True if the contact
/// can be added to protected chats
/// because SELF and contact have verified their fingerprints in both directions.
/// True if the contact can be added to verified groups.
///
/// See [`Self::verifier_id`]/`Contact.verifierId` for a guidance how to display these information.
/// If this is true
/// UI should display green checkmark after the contact name
/// in contact list items,
/// in chat member list items
/// and in profiles if no chat with the contact exist.
is_verified: bool,
/// The contact ID that verified a contact.
/// True if the contact profile title should have a green checkmark.
///
/// As verifier may be unknown,
/// use [`Self::is_verified`]/`Contact.isVerified` to check if a contact can be added to a protected chat.
/// This indicates whether 1:1 chat has a green checkmark
/// or will have a green checkmark if created.
is_profile_verified: bool,
/// The ID of the contact that verified this contact.
///
/// UI should display the information in the contact's profile as follows:
///
/// - If `verifierId` != 0,
/// display text "Introduced by ..."
/// with the name and address of the contact
/// formatted by `name_and_addr`/`nameAndAddr`.
/// Prefix the text by a green checkmark.
///
/// - If `verifierId` == 0 and `isVerified` != 0,
/// display "Introduced" prefixed by a green checkmark.
///
/// - if `verifierId` == 0 and `isVerified` == 0,
/// display nothing
///
/// This contains the contact ID of the verifier.
/// If it is `DC_CONTACT_ID_SELF`, we verified the contact ourself.
/// If it is None/Null, we don't have verifier information or
/// the contact is not verified.
/// If this is present,
/// display a green checkmark and "Introduced by ..."
/// string followed by the verifier contact name and address
/// in the contact profile.
verifier_id: Option<u32>,
/// the contact's last seen timestamp
@@ -81,6 +72,7 @@ impl ContactObject {
None => None,
};
let is_verified = contact.is_verified(context).await?;
let is_profile_verified = contact.is_profile_verified(context).await?;
let verifier_id = contact
.get_verifier_id(context)
@@ -102,6 +94,7 @@ impl ContactObject {
is_key_contact: contact.is_key_contact(),
e2ee_avail: contact.e2ee_avail(context).await?,
is_verified,
is_profile_verified,
verifier_id,
last_seen: contact.last_seen(),
was_seen_recently: contact.was_seen_recently(),

View File

@@ -1,5 +1,4 @@
use deltachat::{Event as CoreEvent, EventType as CoreEventType};
use num_traits::ToPrimitive;
use serde::Serialize;
use typescript_type_def::TypeDef;
@@ -225,6 +224,7 @@ pub enum EventType {
},
/// Chat changed. The name or the image of a chat group was changed or members were added or removed.
/// Or the verify state of a chat has changed.
/// See setChatName(), setChatProfileImage(), addContactToChat()
/// and removeContactFromChat().
///
@@ -294,8 +294,8 @@ pub enum EventType {
#[serde(rename_all = "camelCase")]
ImexFileWritten { path: String },
/// Progress event sent when SecureJoin protocol has finished
/// from the view of the inviter (Alice, the person who shows the QR code).
/// Progress information of a secure-join handshake from the view of the inviter
/// (Alice, the person who shows the QR code).
///
/// These events are typically sent after a joiner has scanned the QR code
/// generated by getChatSecurejoinQrCodeSvg().
@@ -304,14 +304,11 @@ pub enum EventType {
/// ID of the contact that wants to join.
contact_id: u32,
/// The type of the joined chat.
/// This can take the same values
/// as `BasicChat.chatType` ([`crate::api::types::chat::BasicChat::chat_type`]).
chat_type: u32,
/// ID of the chat in case of success.
chat_id: u32,
/// Progress, always 1000.
/// Progress as:
/// 300=vg-/vc-request received, typically shown as "bob@addr joins".
/// 600=vg-/vc-request-with-auth received, vg-member-added/vc-contact-confirm sent, typically shown as "bob@addr verified".
/// 800=contact added to chat, shown as "bob@addr securely joined GROUP". Only for the verified-group-protocol.
/// 1000=Protocol finished for this contact.
progress: usize,
},
@@ -420,45 +417,6 @@ pub enum EventType {
/// Number of events skipped.
n: u64,
},
/// Incoming call.
IncomingCall {
/// ID of the info message referring to the call.
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
/// User-defined info as passed to place_outgoing_call()
place_call_info: String,
/// True if incoming call is a video call.
has_video: bool,
},
/// Incoming call accepted.
/// This is esp. interesting to stop ringing on other devices.
IncomingCallAccepted {
/// ID of the info message referring to the call.
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
},
/// Outgoing call accepted.
OutgoingCallAccepted {
/// ID of the info message referring to the call.
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
/// User-defined info passed to dc_accept_incoming_call(
accept_call_info: String,
},
/// Call ended.
CallEnded {
/// ID of the info message referring to the call.
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
},
}
impl From<CoreEventType> for EventType {
@@ -565,13 +523,9 @@ impl From<CoreEventType> for EventType {
},
CoreEventType::SecurejoinInviterProgress {
contact_id,
chat_type,
chat_id,
progress,
} => SecurejoinInviterProgress {
contact_id: contact_id.to_u32(),
chat_type: chat_type.to_u32().unwrap_or(0),
chat_id: chat_id.to_u32(),
progress,
},
CoreEventType::SecurejoinJoinerProgress {
@@ -613,34 +567,6 @@ impl From<CoreEventType> for EventType {
CoreEventType::EventChannelOverflow { n } => EventChannelOverflow { n },
CoreEventType::AccountsChanged => AccountsChanged,
CoreEventType::AccountsItemChanged => AccountsItemChanged,
CoreEventType::IncomingCall {
msg_id,
chat_id,
place_call_info,
has_video,
} => IncomingCall {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
place_call_info,
has_video,
},
CoreEventType::IncomingCallAccepted { msg_id, chat_id } => IncomingCallAccepted {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
},
CoreEventType::OutgoingCallAccepted {
msg_id,
chat_id,
accept_call_info,
} => OutgoingCallAccepted {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
accept_call_info,
},
CoreEventType::CallEnded { msg_id, chat_id } => CallEnded {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
},
#[allow(unreachable_patterns)]
#[cfg(test)]
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),

View File

@@ -84,6 +84,9 @@ pub struct MessageObject {
dimensions_height: i32,
dimensions_width: i32,
videochat_type: Option<u32>,
videochat_url: Option<String>,
override_sender_name: Option<String>,
sender: ContactObject,
@@ -236,6 +239,15 @@ impl MessageObject {
dimensions_height: message.get_height(),
dimensions_width: message.get_width(),
videochat_type: match message.get_videochat_type() {
Some(vct) => Some(
vct.to_u32()
.context("videochat type conversion to number failed")?,
),
None => None,
},
videochat_url: message.get_videochat_url(),
override_sender_name,
sender,
@@ -309,8 +321,8 @@ pub enum MessageViewtype {
/// Message containing any file, eg. a PDF.
File,
/// Message is a call.
Call,
/// Message is an invitation to a videochat.
VideochatInvitation,
/// Message is an webxdc instance.
Webxdc,
@@ -333,7 +345,7 @@ impl From<Viewtype> for MessageViewtype {
Viewtype::Voice => MessageViewtype::Voice,
Viewtype::Video => MessageViewtype::Video,
Viewtype::File => MessageViewtype::File,
Viewtype::Call => MessageViewtype::Call,
Viewtype::VideochatInvitation => MessageViewtype::VideochatInvitation,
Viewtype::Webxdc => MessageViewtype::Webxdc,
Viewtype::Vcard => MessageViewtype::Vcard,
}
@@ -352,7 +364,7 @@ impl From<MessageViewtype> for Viewtype {
MessageViewtype::Voice => Viewtype::Voice,
MessageViewtype::Video => Viewtype::Video,
MessageViewtype::File => Viewtype::File,
MessageViewtype::Call => Viewtype::Call,
MessageViewtype::VideochatInvitation => Viewtype::VideochatInvitation,
MessageViewtype::Webxdc => Viewtype::Webxdc,
MessageViewtype::Vcard => Viewtype::Vcard,
}
@@ -425,9 +437,6 @@ pub enum SystemMessageType {
/// This message contains a users iroh node address.
IrohNodeAddr,
CallAccepted,
CallEnded,
}
impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
@@ -454,8 +463,6 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
SystemMessage::IrohNodeAddr => SystemMessageType::IrohNodeAddr,
SystemMessage::SecurejoinWait => SystemMessageType::SecurejoinWait,
SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout,
SystemMessage::CallAccepted => SystemMessageType::CallAccepted,
SystemMessage::CallEnded => SystemMessageType::CallEnded,
}
}
}

View File

@@ -1,5 +1,4 @@
pub mod account;
pub mod calls;
pub mod chat;
pub mod chat_list;
pub mod contact;

View File

@@ -225,6 +225,13 @@ impl From<Qr> for QrObject {
auth_token,
},
Qr::BackupTooNew {} => QrObject::BackupTooNew {},
Qr::WebrtcInstance {
domain,
instance_pattern,
} => QrObject::WebrtcInstance {
domain,
instance_pattern,
},
Qr::Proxy { url, host, port } => QrObject::Proxy { url, host, port },
Qr::Addr { contact_id, draft } => {
let contact_id = contact_id.to_u32();

View File

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

View File

@@ -28,6 +28,7 @@ export class BaseDeltaChat<
Transport extends BaseTransport<any>,
> extends TinyEmitter<Events> {
rpc: RawClient;
account?: T.Account;
private contextEmitters: { [key: number]: TinyEmitter<ContextEvents> } = {};
//@ts-ignore
@@ -35,10 +36,6 @@ export class BaseDeltaChat<
constructor(
public transport: Transport,
/**
* Whether to start calling {@linkcode RawClient.getNextEvent}
* and emitting the respective events on this class.
*/
startEventLoop: boolean,
) {
super();
@@ -48,9 +45,6 @@ export class BaseDeltaChat<
}
}
/**
* @see the constructor's `startEventLoop`
*/
async eventLoop(): Promise<void> {
while (true) {
const event = await this.rpc.getNextEvent();
@@ -69,17 +63,10 @@ export class BaseDeltaChat<
}
}
/**
* @deprecated use {@linkcode BaseDeltaChat.rpc.getAllAccounts} instead.
*/
async listAccounts(): Promise<T.Account[]> {
return await this.rpc.getAllAccounts();
}
/**
* A convenience function to listen on events binned by `account_id`
* (see {@linkcode RawClient.getAllAccounts}).
*/
getContextEvents(account_id: number) {
if (this.contextEmitters[account_id]) {
return this.contextEmitters[account_id];

View File

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

View File

@@ -210,7 +210,13 @@ async fn log_msg(context: &Context, prefix: impl AsRef<str>, msg: &Message) {
} else {
""
},
if msg.get_viewtype() == Viewtype::Webxdc {
if msg.get_viewtype() == Viewtype::VideochatInvitation {
format!(
"[VIDEOCHAT-INVITATION: {}, type={}]",
msg.get_videochat_url().unwrap_or_default(),
msg.get_videochat_type().unwrap_or_default()
)
} else if msg.get_viewtype() == Viewtype::Webxdc {
match msg.get_webxdc_info(context).await {
Ok(info) => format!(
"[WEBXDC: {}, icon={}, document={}, summary={}, source_code_url={}]",
@@ -365,6 +371,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
sendhtml <file for html-part> [<text for plain-part>]\n\
sendsyncmsg\n\
sendupdate <msg-id> <json status update>\n\
videochat\n\
draft [<text>]\n\
devicemsg <text>\n\
listmedia\n\
@@ -396,8 +403,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
block <contact-id>\n\
unblock <contact-id>\n\
listblocked\n\
import-vcard <file>\n\
make-vcard <file> <contact-id> [contact-id ...]\n\
======================================Misc.==\n\
getqr [<chat-id>]\n\
getqrsvg [<chat-id>]\n\
@@ -418,7 +423,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
Ok(setup_code) => {
println!("Setup code for the transferred setup message: {setup_code}",)
}
Err(err) => bail!("Failed to generate setup code: {err}"),
Err(err) => bail!("Failed to generate setup code: {}", err),
},
"get-setupcodebegin" => {
ensure!(!arg1.is_empty(), "Argument <msg-id> missing.");
@@ -432,7 +437,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
setupcodebegin.unwrap_or_default(),
);
} else {
bail!("{msg_id} is no setup message.",);
bail!("{} is no setup message.", msg_id,);
}
}
"continue-key-transfer" => {
@@ -527,7 +532,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Report written to: {file:#?}");
}
Err(err) => {
bail!("Failed to get connectivity html: {err}");
bail!("Failed to get connectivity html: {}", err);
}
}
}
@@ -955,6 +960,10 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
let msg_id = MsgId::new(arg1.parse()?);
context.send_webxdc_status_update(msg_id, arg2).await?;
}
"videochat" => {
ensure!(sel_chat.is_some(), "No chat selected.");
chat::send_videochat_invitation(&context, sel_chat.as_ref().unwrap().get_id()).await?;
}
"listmsgs" => {
ensure!(!arg1.is_empty(), "Argument <query> missing.");
@@ -1209,24 +1218,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
log_contactlist(&context, &contacts).await?;
println!("{} blocked contacts.", contacts.len());
}
"import-vcard" => {
ensure!(!arg1.is_empty(), "Argument <file> missing.");
let vcard_content = fs::read_to_string(&arg1.to_string()).await?;
let contacts = import_vcard(&context, &vcard_content).await?;
println!("vCard contacts imported:");
log_contactlist(&context, &contacts).await?;
}
"make-vcard" => {
ensure!(!arg1.is_empty(), "Argument <file> missing.");
ensure!(!arg2.is_empty(), "Argument <contact-id> missing.");
let mut contact_ids = vec![];
for x in arg2.split_whitespace() {
contact_ids.push(ContactId::new(x.parse()?))
}
let vcard_content = make_vcard(&context, &contact_ids).await?;
fs::write(&arg1.to_string(), vcard_content).await?;
println!("vCard written to: {arg1}");
}
"checkqr" => {
ensure!(!arg1.is_empty(), "Argument <qr-content> missing.");
let qr = check_qr(&context, arg1).await?;
@@ -1287,7 +1278,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
);
}
"" => (),
_ => bail!("Unknown command: \"{arg0}\" type ? for help."),
_ => bail!("Unknown command: \"{}\" type ? for help.", arg0),
}
Ok(())

View File

@@ -179,7 +179,7 @@ const DB_COMMANDS: [&str; 11] = [
"housekeeping",
];
const CHAT_COMMANDS: [&str; 38] = [
const CHAT_COMMANDS: [&str; 39] = [
"listchats",
"listarchived",
"start-realtime",
@@ -206,6 +206,7 @@ const CHAT_COMMANDS: [&str; 38] = [
"sendhtml",
"sendsyncmsg",
"sendupdate",
"videochat",
"draft",
"devicemsg",
"listmedia",
@@ -231,7 +232,7 @@ const MESSAGE_COMMANDS: [&str; 10] = [
"delmsg",
"react",
];
const CONTACT_COMMANDS: [&str; 9] = [
const CONTACT_COMMANDS: [&str; 7] = [
"listcontacts",
"addcontact",
"contactinfo",
@@ -239,8 +240,6 @@ const CONTACT_COMMANDS: [&str; 9] = [
"block",
"unblock",
"listblocked",
"import-vcard",
"make-vcard",
];
const MISC_COMMANDS: [&str; 14] = [
"getqr",
@@ -466,7 +465,7 @@ async fn handle_cmd(
println!("QR code svg written to: {file:#?}");
}
Err(err) => {
bail!("Failed to get QR code svg: {err}");
bail!("Failed to get QR code svg: {}", err);
}
}
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.20.0"
version = "2.3.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -19,7 +19,6 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Communications :: Chat",
"Topic :: Communications :: Email"
]

View File

@@ -92,12 +92,6 @@ def _run_cli(
)
parser.add_argument("--email", action="store", help="email address", default=os.getenv("DELTACHAT_EMAIL"))
parser.add_argument("--password", action="store", help="password", default=os.getenv("DELTACHAT_PASSWORD"))
parser.add_argument(
"--displayname", action="store", help="the profile's display name", default=os.getenv("DELTACHAT_DISPLAYNAME"),
)
parser.add_argument(
"--avatar", action="store", help="filename of the profile's avatar", default=os.getenv("DELTACHAT_AVATAR"),
)
args = parser.parse_args(argv[1:])
with Rpc(accounts_dir=args.accounts_dir, **kwargs) as rpc:
@@ -114,12 +108,7 @@ def _run_cli(
configure_thread = Thread(
target=client.configure,
daemon=True,
kwargs={
"email": args.email,
"password": args.password,
"displayname": args.displayname,
"selfavatar": args.avatar,
},
kwargs={"email": args.email, "password": args.password},
)
configure_thread.start()
client.run_forever()

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, Union
from warnings import warn
@@ -186,21 +185,7 @@ class Account:
return Contact(self, contact_id)
def get_contact_by_addr(self, address: str) -> Optional[Contact]:
"""Looks up a known and unblocked contact with a given e-mail address.
To get a list of all known and unblocked contacts, use contacts_get_contacts().
**POTENTIAL SECURITY ISSUE**: If there are multiple contacts with this address
(e.g. an address-contact and a key-contact),
this looks up the most recently seen contact,
i.e. which contact is returned depends on which contact last sent a message.
If the user just clicked on a mailto: link, then this is the best thing you can do.
But **DO NOT** internally represent contacts by their email address
and do not use this function to look them up;
otherwise this function will sometimes look up the wrong contact.
Instead, you should internally represent contacts by their ids.
To validate an e-mail address independently of the contact database
use check_email_validity()."""
"""Check if an e-mail address belongs to a known and unblocked contact."""
contact_id = self._rpc.lookup_contact_id_by_addr(self.id, address)
return contact_id and Contact(self, contact_id)
@@ -471,8 +456,3 @@ class Account:
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)
return json.loads(ice_servers_json)

View File

@@ -168,11 +168,6 @@ class Chat:
msg_id = self._rpc.send_sticker(self.account.id, self.id, path)
return Message(self.account, msg_id)
def resend_messages(self, messages: list[Message]) -> None:
"""Resend a list of messages to this chat."""
msg_ids = [msg.id for msg in messages]
self._rpc.resend_messages(self.account.id, msg_ids)
def forward_messages(self, messages: list[Message]) -> None:
"""Forward a list of messages to this chat."""
msg_ids = [msg.id for msg in messages]
@@ -294,8 +289,3 @@ class Chat:
f.write(vcard.encode())
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) -> Message:
"""Starts an outgoing call."""
msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info)
return Message(self.account, msg_id)

View File

@@ -73,10 +73,6 @@ class EventType(str, Enum):
CHATLIST_ITEM_CHANGED = "ChatlistItemChanged"
ACCOUNTS_CHANGED = "AccountsChanged"
ACCOUNTS_ITEM_CHANGED = "AccountsItemChanged"
INCOMING_CALL = "IncomingCall"
INCOMING_CALL_ACCEPTED = "IncomingCallAccepted"
OUTGOING_CALL_ACCEPTED = "OutgoingCallAccepted"
CALL_ENDED = "CallEnded"
CONFIG_SYNCED = "ConfigSynced"
WEBXDC_REALTIME_DATA = "WebxdcRealtimeData"
WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED = "WebxdcRealtimeAdvertisementReceived"
@@ -160,6 +156,7 @@ class ViewType(str, Enum):
VOICE = "Voice"
VIDEO = "Video"
FILE = "File"
VIDEOCHAT_INVITATION = "VideochatInvitation"
WEBXDC = "Webxdc"
VCARD = "Vcard"
@@ -278,3 +275,11 @@ class SocketSecurity(IntEnum):
SSL = 1
STARTTLS = 2
PLAIN = 3
class VideochatType(IntEnum):
"""Video chat URL type."""
UNKNOWN = 0
BASICWEBRTC = 1
JITSI = 2

View File

@@ -102,15 +102,3 @@ class Message:
def send_webxdc_realtime_data(self, data) -> None:
"""Send data to the realtime channel."""
yield self._rpc.send_webxdc_realtime_data.future(self.account.id, self.id, list(data))
def accept_incoming_call(self, accept_call_info):
"""Accepts an incoming call."""
self._rpc.accept_incoming_call(self.account.id, self.id, accept_call_info)
def end_call(self):
"""Ends incoming or outgoing call."""
self._rpc.end_call(self.account.id, self.id)
def get_call_info(self) -> AttrDict:
"""Return information about the call."""
return AttrDict(self._rpc.call_info(self.account.id, self.id))

View File

@@ -28,7 +28,9 @@ class ACFactory:
def get_unconfigured_account(self) -> Account:
"""Create a new unconfigured account."""
return self.deltachat.add_account()
account = self.deltachat.add_account()
account.set_config("verified_one_on_one_chats", "1")
return account
def get_unconfigured_bot(self) -> Bot:
"""Create a new unconfigured bot."""

View File

@@ -1,86 +0,0 @@
from deltachat_rpc_client import EventType, Message
def test_calls(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
place_call_info = "offer"
accept_call_info = "answer"
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
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 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 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
assert incoming_call_message.get_call_info().state.kind == "Active"
outgoing_call_accepted_event = alice.wait_for_event(EventType.OUTGOING_CALL_ACCEPTED)
assert outgoing_call_accepted_event.accept_call_info == accept_call_info
assert outgoing_call_message.get_call_info().state.kind == "Active"
outgoing_call_message.end_call()
assert outgoing_call_message.get_call_info().state.kind == "Completed"
end_call_event = bob.wait_for_event(EventType.CALL_ENDED)
assert end_call_event.msg_id == outgoing_call_message.id
assert incoming_call_message.get_call_info().state.kind == "Completed"
def test_video_call(acfactory) -> None:
# 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=-`.
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)
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.place_outgoing_call(place_call_info)
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
incoming_call_message = Message(bob, incoming_call_event.msg_id)
assert incoming_call_message.get_call_info().has_video
def test_ice_servers(acfactory) -> None:
alice = acfactory.get_online_account()
ice_servers = alice.ice_servers()
assert len(ice_servers) == 1

View File

@@ -171,10 +171,7 @@ def test_account(acfactory) -> None:
assert alice.get_size()
assert alice.is_configured()
assert not alice.get_avatar()
# get_contact_by_addr() can lookup a key contact by address:
bob_contact = alice.get_contact_by_addr(bob_addr).get_snapshot()
assert bob_contact.display_name == "Bob"
assert bob_contact.is_key_contact
assert alice.get_contact_by_addr(bob_addr) is None # There is no address-contact, only key-contact
assert alice.get_contacts()
assert alice.get_contacts(snapshot=True)
assert alice.self_contact
@@ -252,7 +249,6 @@ def test_chat(acfactory) -> None:
bob_chat_alice.get_encryption_info()
group = alice.create_group("test group")
to_resend = group.send_text("will be resent")
group.add_contact(alice_contact_bob)
group.get_qr_code()
@@ -264,7 +260,6 @@ def test_chat(acfactory) -> None:
msg = group.send_message(text="hi")
assert (msg.get_snapshot()).text == "hi"
group.resend_messages([to_resend])
group.forward_messages([msg])
group.set_draft(text="test draft")
@@ -331,52 +326,6 @@ def test_message(acfactory) -> None:
assert reactions == snapshot.reactions
def test_receive_imf_failure(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
bob.set_config("fail_on_receiving_full_msg", "1")
alice_chat_bob.send_text("Hello!")
event = bob.wait_for_incoming_msg_event()
chat_id = event.chat_id
msg_id = event.msg_id
message = bob.get_message_by_id(msg_id)
snapshot = message.get_snapshot()
assert snapshot.chat_id == chat_id
assert snapshot.download_state == DownloadState.AVAILABLE
assert snapshot.error is not None
assert snapshot.show_padlock
# The failed message doesn't break the IMAP loop.
bob.set_config("fail_on_receiving_full_msg", "0")
alice_chat_bob.send_text("Hello again!")
event = bob.wait_for_incoming_msg_event()
assert event.chat_id == chat_id
msg_id = event.msg_id
message1 = bob.get_message_by_id(msg_id)
snapshot = message1.get_snapshot()
assert snapshot.chat_id == chat_id
assert snapshot.download_state == DownloadState.DONE
assert snapshot.error is None
# The failed message can be re-downloaded later.
bob._rpc.download_full_message(bob.id, message.id)
event = bob.wait_for_event(EventType.MSGS_CHANGED)
message = bob.get_message_by_id(event.msg_id)
snapshot = message.get_snapshot()
assert snapshot.download_state == DownloadState.IN_PROGRESS
event = bob.wait_for_event(EventType.MSGS_CHANGED)
assert event.chat_id == chat_id
msg_id = event.msg_id
message = bob.get_message_by_id(msg_id)
snapshot = message.get_snapshot()
assert snapshot.chat_id == chat_id
assert snapshot.download_state == DownloadState.DONE
assert snapshot.error is None
assert snapshot.text == "Hello!"
def test_selfavatar_sync(acfactory, data, log) -> None:
alice = acfactory.get_online_account()

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "2.20.0"
version = "2.3.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.20.0"
"version": "2.3.0"
}

View File

@@ -41,22 +41,22 @@ async fn main_impl() -> Result<()> {
if let Some(first_arg) = args.next() {
if first_arg.to_str() == Some("--version") {
if let Some(arg) = args.next() {
return Err(anyhow!("Unrecognized argument {arg:?}"));
return Err(anyhow!("Unrecognized argument {:?}", arg));
}
eprintln!("{}", &*DC_VERSION_STR);
return Ok(());
} else if first_arg.to_str() == Some("--openrpc") {
if let Some(arg) = args.next() {
return Err(anyhow!("Unrecognized argument {arg:?}"));
return Err(anyhow!("Unrecognized argument {:?}", arg));
}
println!("{}", CommandApi::openrpc_specification()?);
return Ok(());
} else {
return Err(anyhow!("Unrecognized option {first_arg:?}"));
return Err(anyhow!("Unrecognized option {:?}", first_arg));
}
}
if let Some(arg) = args.next() {
return Err(anyhow!("Unrecognized argument {arg:?}"));
return Err(anyhow!("Unrecognized argument {:?}", arg));
}
// Install signal handlers early so that the shutdown is graceful starting from here.

View File

@@ -33,11 +33,14 @@ skip = [
{ name = "lru", version = "0.12.3" },
{ name = "netlink-packet-route", version = "0.17.1" },
{ name = "nom", version = "7.1.3" },
{ name = "proc-macro-crate", version = "2.0.0" },
{ name = "rand_chacha", version = "0.3.1" },
{ name = "rand_core", version = "0.6.4" },
{ name = "rand", version = "0.8.5" },
{ name = "redox_syscall", version = "0.3.5" },
{ name = "redox_syscall", version = "0.4.1" },
{ name = "regex-automata", version = "0.1.10" },
{ name = "regex-syntax", version = "0.6.29" },
{ name = "rustix", version = "0.38.44" },
{ name = "serdect", version = "0.2.0" },
{ name = "spin", version = "0.9.8" },
@@ -46,7 +49,7 @@ skip = [
{ name = "syn", version = "1.0.109" },
{ name = "thiserror-impl", version = "1.0.69" },
{ name = "thiserror", version = "1.0.69" },
{ name = "toml_datetime", version = "0.6.11" },
{ name = "toml_edit", version = "0.20.7" },
{ name = "wasi", version = "0.11.0+wasi-snapshot-preview1" },
{ name = "windows" },
{ name = "windows_aarch64_gnullvm" },
@@ -64,6 +67,7 @@ skip = [
{ name = "windows_x86_64_gnu" },
{ name = "windows_x86_64_gnullvm" },
{ name = "windows_x86_64_msvc" },
{ name = "winnow", version = "0.5.40" },
{ name = "zerocopy", version = "0.7.32" },
]

View File

@@ -587,7 +587,6 @@
(python3.withPackages (pypkgs: with pypkgs; [
tox
]))
nodejs
];
};
}

View File

@@ -6,7 +6,7 @@ edition = "2021"
license = "MPL-2.0"
[dev-dependencies]
bolero = "0.13.4"
bolero = "0.13.3"
[dependencies]
mailparse = { workspace = true }

View File

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

View File

@@ -330,21 +330,7 @@ class Account:
return bool(lib.dc_delete_contact(self._dc_context, contact_id))
def get_contact_by_addr(self, email: str) -> Optional[Contact]:
"""Looks up a known and unblocked contact with a given e-mail address.
To get a list of all known and unblocked contacts, use contacts_get_contacts().
**POTENTIAL SECURITY ISSUE**: If there are multiple contacts with this address
(e.g. an address-contact and a key-contact),
this looks up the most recently seen contact,
i.e. which contact is returned depends on which contact last sent a message.
If the user just clicked on a mailto: link, then this is the best thing you can do.
But **DO NOT** internally represent contacts by their email address
and do not use this function to look them up;
otherwise this function will sometimes look up the wrong contact.
Instead, you should internally represent contacts by their ids.
To validate an e-mail address independently of the contact database
use check_email_validity()."""
"""get a contact for the email address or None if it's blocked or doesn't exist."""
_, addr = parseaddr(email)
addr = as_dc_charpointer(addr)
contact_id = lib.dc_lookup_contact_id_by_addr(self._dc_context, addr)

View File

@@ -8,6 +8,7 @@ from typing import Optional, Union
from . import const, props
from .capi import ffi, lib
from .cutil import as_dc_charpointer, from_dc_charpointer, from_optional_dc_charpointer
from .reactions import Reactions
class Message:
@@ -163,6 +164,17 @@ class Message:
),
)
def send_reaction(self, reaction: str):
"""Send a reaction to message and return the resulting Message instance."""
msg_id = lib.dc_send_reaction(self.account._dc_context, self.id, as_dc_charpointer(reaction))
if msg_id == 0:
raise ValueError("reaction could not be send")
return Message.from_db(self.account, msg_id)
def get_reactions(self) -> Reactions:
"""Get :class:`deltachat.reactions.Reactions` to the message."""
return Reactions.from_msg(self)
def is_system_message(self):
"""return True if this message is a system/info message."""
return bool(lib.dc_msg_is_info(self._dc_msg))
@@ -435,6 +447,10 @@ class Message:
"""return True if it's a video message."""
return self._view_type == const.DC_MSG_VIDEO
def is_videochat_invitation(self):
"""return True if it's a videochat invitation message."""
return self._view_type == const.DC_MSG_VIDEOCHAT_INVITATION
def is_webxdc(self):
"""return True if it's a Webxdc message."""
return self._view_type == const.DC_MSG_WEBXDC
@@ -475,6 +491,7 @@ _view_type_mapping = {
"video": const.DC_MSG_VIDEO,
"file": const.DC_MSG_FILE,
"sticker": const.DC_MSG_STICKER,
"videochat": const.DC_MSG_VIDEOCHAT_INVITATION,
"webxdc": const.DC_MSG_WEBXDC,
}

View File

@@ -0,0 +1,43 @@
"""The Reactions object."""
from .capi import ffi, lib
from .cutil import from_dc_charpointer, iter_array
class Reactions:
"""Reactions object.
You obtain instances of it through :class:`deltachat.message.Message`.
"""
def __init__(self, account, dc_reactions) -> None:
assert isinstance(account._dc_context, ffi.CData)
assert isinstance(dc_reactions, ffi.CData)
assert dc_reactions != ffi.NULL
self.account = account
self._dc_reactions = dc_reactions
def __repr__(self) -> str:
return f"<Reactions dc_reactions={self._dc_reactions}>"
@classmethod
def from_msg(cls, msg):
assert msg.id > 0
return cls(
msg.account,
ffi.gc(lib.dc_get_msg_reactions(msg.account._dc_context, msg.id), lib.dc_reactions_unref),
)
def get_contacts(self) -> list:
"""Get list of contacts reacted to the message.
:returns: list of :class:`deltachat.contact.Contact` objects for this reaction.
"""
from .contact import Contact
dc_array = ffi.gc(lib.dc_reactions_get_contacts(self._dc_reactions), lib.dc_array_unref)
return list(iter_array(dc_array, lambda x: Contact(self.account, x)))
def get_by_contact(self, contact) -> str:
"""Get a string containing space-separated reactions of a single :class:`deltachat.contact.Contact`."""
return from_dc_charpointer(lib.dc_reactions_get_by_contact_id(self._dc_reactions, contact.id))

View File

@@ -160,6 +160,32 @@ def test_html_message(acfactory, lp):
assert html_text in msg2.html
def test_videochat_invitation_message(acfactory, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat = acfactory.get_accepted_chat(ac1, ac2)
text = "You are invited to a video chat, click https://meet.jit.si/WxEGad0gGzX to join."
lp.sec("ac1: prepare and send text message to ac2")
msg1 = chat.send_text("message0")
assert not msg1.is_videochat_invitation()
lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == "message0"
assert not msg2.is_videochat_invitation()
lp.sec("ac1: prepare and send videochat invitation to ac2")
msg1 = Message.new_empty(ac1, "videochat")
msg1.set_text(text)
msg1 = chat.send_msg(msg1)
assert msg1.is_videochat_invitation()
lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == text
assert msg2.is_videochat_invitation()
def test_webxdc_message(acfactory, data, lp):
ac1, ac2 = acfactory.get_online_accounts(2)
chat = acfactory.get_accepted_chat(ac1, ac2)
@@ -406,7 +432,7 @@ def test_forward_messages(acfactory, lp):
lp.sec("ac2: check new chat has a forwarded message")
assert chat3.is_promoted()
messages = chat3.get_messages()
assert len(messages) == 3
assert len(messages) == 2
msg = messages[-1]
assert msg.is_forwarded()
ac2.delete_messages(messages)
@@ -1755,12 +1781,12 @@ def test_group_quote(acfactory, lp):
"xyz",
False,
"xyz",
), # Test that emails aren't found in a random folder
), # Test that emails are recognized in a random folder but not moved
(
"Spam",
"xyz",
True,
"DeltaChat",
), # ...emails are moved from the spam folder to "DeltaChat"
), # ...emails are found in a random folder and moved to DeltaChat
(
"Spam",
False,
@@ -1785,7 +1811,7 @@ def test_scan_folders(acfactory, lp, folder, move, expected_destination):
ac1.stop_io()
assert folder in ac1.direct_imap.list_folders()
lp.sec("Send a message to from ac2 to ac1 and manually move it to `folder`")
lp.sec("Send a message to from ac2 to ac1 and manually move it to the mvbox")
ac1.direct_imap.select_config_folder("inbox")
with ac1.direct_imap.idle() as idle1:
acfactory.get_accepted_chat(ac2, ac1).send_text("hello")
@@ -1795,17 +1821,10 @@ def test_scan_folders(acfactory, lp, folder, move, expected_destination):
lp.sec("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._evtracker.wait_next_incoming_message()
assert msg.text == "hello"
n_msgs += 1
else:
ac1._evtracker.wait_idle_inbox_ready()
assert len(chat.get_messages()) == n_msgs
msg = ac1._evtracker.wait_next_incoming_message()
assert msg.text == "hello"
# The message has reached its destination.
# The message has been downloaded, which means it has reached its destination.
ac1.direct_imap.select_folder(expected_destination)
assert len(ac1.direct_imap.get_all_messages()) == 1
if folder != expected_destination:

View File

@@ -663,4 +663,4 @@ class TestOfflineChat:
lp.sec("check message count of only system messages (without daymarkers)")
sysmessages = [x for x in chat.get_messages() if x.is_system_message()]
assert len(sysmessages) == 4
assert len(sysmessages) == 3

View File

@@ -1 +1 @@
2025-10-13
2025-07-19

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

View File

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

View File

@@ -78,7 +78,7 @@ impl Accounts {
ensure!(dir.exists(), "directory does not exist");
let config_file = dir.join(CONFIG_NAME);
ensure!(config_file.exists(), "{config_file:?} does not exist");
ensure!(config_file.exists(), "{:?} does not exist", config_file);
let config = Config::from_file(config_file, writable).await?;
let events = Events::new();
@@ -724,7 +724,8 @@ impl Config {
{
ensure!(
self.inner.accounts.iter().any(|e| e.id == id),
"invalid account id: {id}"
"invalid account id: {}",
id
);
self.inner.selected_account = id;

View File

@@ -17,6 +17,7 @@ pub enum EncryptPreference {
#[default]
NoPreference = 0,
Mutual = 1,
Reset = 20,
}
impl fmt::Display for EncryptPreference {
@@ -24,6 +25,7 @@ impl fmt::Display for EncryptPreference {
match *self {
EncryptPreference::Mutual => write!(fmt, "mutual"),
EncryptPreference::NoPreference => write!(fmt, "nopreference"),
EncryptPreference::Reset => write!(fmt, "reset"),
}
}
}
@@ -35,7 +37,7 @@ impl FromStr for EncryptPreference {
match s {
"mutual" => Ok(EncryptPreference::Mutual),
"nopreference" => Ok(EncryptPreference::NoPreference),
_ => bail!("Cannot parse encryption preference {s}"),
_ => bail!("Cannot parse encryption preference {}", s),
}
}
}
@@ -46,13 +48,21 @@ pub struct Aheader {
pub addr: String,
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.
pub verified: bool,
impl Aheader {
/// Creates new autocrypt header
pub fn new(
addr: String,
public_key: SignedPublicKey,
prefer_encrypt: EncryptPreference,
) -> Self {
Aheader {
addr,
public_key,
prefer_encrypt,
}
}
}
impl fmt::Display for Aheader {
@@ -61,9 +71,6 @@ impl fmt::Display for Aheader {
if self.prefer_encrypt == EncryptPreference::Mutual {
write!(fmt, " prefer-encrypt=mutual;")?;
}
if self.verified {
write!(fmt, " _verified=1;")?;
}
// adds a whitespace every 78 characters, this allows
// email crate to wrap the lines according to RFC 5322
@@ -118,8 +125,6 @@ impl FromStr for Aheader {
.and_then(|raw| raw.parse().ok())
.unwrap_or_default();
let verified = attributes.remove("_verified").is_some();
// Autocrypt-Level0: unknown attributes starting with an underscore can be safely ignored
// Autocrypt-Level0: unknown attribute, treat the header as invalid
if attributes.keys().any(|k| !k.starts_with('_')) {
@@ -130,7 +135,6 @@ impl FromStr for Aheader {
addr,
public_key,
prefer_encrypt,
verified,
})
}
}
@@ -148,11 +152,10 @@ mod tests {
assert_eq!(h.addr, "me@mail.com");
assert_eq!(h.prefer_encrypt, EncryptPreference::Mutual);
assert_eq!(h.verified, false);
Ok(())
}
// Non-standard values of prefer-encrypt such as `reset` are treated as no preference.
// EncryptPreference::Reset is an internal value, parser should never return it
#[test]
fn test_from_str_reset() -> Result<()> {
let raw = format!("addr=reset@example.com; prefer-encrypt=reset; keydata={RAWKEY}");
@@ -242,12 +245,11 @@ mod tests {
assert!(
format!(
"{}",
Aheader {
addr: "test@example.com".to_string(),
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
prefer_encrypt: EncryptPreference::Mutual,
verified: false
}
Aheader::new(
"test@example.com".to_string(),
SignedPublicKey::from_base64(RAWKEY).unwrap(),
EncryptPreference::Mutual
)
)
.contains("prefer-encrypt=mutual;")
);
@@ -258,12 +260,11 @@ mod tests {
assert!(
!format!(
"{}",
Aheader {
addr: "test@example.com".to_string(),
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
prefer_encrypt: EncryptPreference::NoPreference,
verified: false
}
Aheader::new(
"test@example.com".to_string(),
SignedPublicKey::from_base64(RAWKEY).unwrap(),
EncryptPreference::NoPreference
)
)
.contains("prefer-encrypt")
);
@@ -272,27 +273,13 @@ mod tests {
assert!(
format!(
"{}",
Aheader {
addr: "TeSt@eXaMpLe.cOm".to_string(),
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
prefer_encrypt: EncryptPreference::Mutual,
verified: false
}
Aheader::new(
"TeSt@eXaMpLe.cOm".to_string(),
SignedPublicKey::from_base64(RAWKEY).unwrap(),
EncryptPreference::Mutual
)
)
.contains("test@example.com")
);
assert!(
format!(
"{}",
Aheader {
addr: "test@example.com".to_string(),
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
prefer_encrypt: EncryptPreference::NoPreference,
verified: true
}
)
.contains("_verified")
);
}
}

View File

@@ -32,7 +32,7 @@ pub(crate) async fn handle_authres(
let from_domain = match EmailAddress::new(from) {
Ok(email) => email.domain,
Err(e) => {
return Err(anyhow::format_err!("invalid email {from}: {e:#}"));
return Err(anyhow::format_err!("invalid email {}: {:#}", from, e));
}
};

View File

@@ -170,7 +170,7 @@ impl<'a> BlobObject<'a> {
false => name,
};
if !BlobObject::is_acceptible_blob_name(name) {
return Err(format_err!("not an acceptable blob name: {name}"));
return Err(format_err!("not an acceptable blob name: {}", name));
}
Ok(BlobObject {
blobdir: context.get_blobdir(),
@@ -367,12 +367,11 @@ impl<'a> BlobObject<'a> {
|| img.get_pixel(x_max, y_max).0[3] == 0)
{
*vt = Viewtype::Image;
} else {
// Core doesn't auto-assign `Viewtype::Sticker` to messages and stickers coming
// from UIs shouldn't contain sensitive Exif info.
return Ok(name);
}
}
if *vt == Viewtype::Sticker && exif.is_none() {
return Ok(name);
}
img = match orientation {
Some(90) => img.rotate90(),
@@ -458,7 +457,8 @@ impl<'a> BlobObject<'a> {
{
if img_wh < 20 {
return Err(format_err!(
"Failed to scale image to below {max_bytes}B.",
"Failed to scale image to below {}B.",
max_bytes,
));
}

View File

@@ -416,28 +416,6 @@ async fn test_recode_image_balanced_png() {
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sticker_with_exif() {
let bytes = include_bytes!("../../test-data/image/logo.png");
SendImageCheckMediaquality {
viewtype: Viewtype::Sticker,
bytes,
extension: "png",
// TODO: Pretend there's no Exif. Currently `exif` crate doesn't detect Exif in this image,
// so the test doesn't check all the logic it should.
has_exif: false,
original_width: 135,
original_height: 135,
res_viewtype: Some(Viewtype::Sticker),
compressed_width: 135,
compressed_height: 135,
..Default::default()
}
.test()
.await
.unwrap();
}
/// Tests that RGBA PNG can be recoded into JPEG
/// by dropping alpha channel.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -507,7 +485,6 @@ struct SendImageCheckMediaquality<'a> {
pub(crate) original_width: u32,
pub(crate) original_height: u32,
pub(crate) orientation: i32,
pub(crate) res_viewtype: Option<Viewtype>,
pub(crate) compressed_width: u32,
pub(crate) compressed_height: u32,
pub(crate) set_draft: bool,
@@ -523,7 +500,6 @@ impl SendImageCheckMediaquality<'_> {
let original_width = self.original_width;
let original_height = self.original_height;
let orientation = self.orientation;
let res_viewtype = self.res_viewtype.unwrap_or(Viewtype::Image);
let compressed_width = self.compressed_width;
let compressed_height = self.compressed_height;
let set_draft = self.set_draft;
@@ -574,7 +550,7 @@ impl SendImageCheckMediaquality<'_> {
}
let bob_msg = bob.recv_msg(&sent).await;
assert_eq!(bob_msg.get_viewtype(), res_viewtype);
assert_eq!(bob_msg.get_viewtype(), Viewtype::Image);
assert_eq!(bob_msg.get_width() as u32, compressed_width);
assert_eq!(bob_msg.get_height() as u32, compressed_height);
let file_saved = bob
@@ -588,7 +564,7 @@ impl SendImageCheckMediaquality<'_> {
}
let (_, exif) = image_metadata(&std::fs::File::open(&file_saved)?)?;
assert!(res_viewtype != Viewtype::Image || exif.is_none());
assert!(exif.is_none());
let img = check_image_size(file_saved, compressed_width, compressed_height);

View File

@@ -1,686 +0,0 @@
//! # Handle calls.
//!
//! Internally, calls are bound a user-visible message initializing the call.
//! This means, the "Call ID" is a "Message ID" - similar to Webxdc IDs.
use crate::chat::{Chat, ChatId, send_msg};
use crate::constants::Chattype;
use crate::contact::ContactId;
use crate::context::Context;
use crate::events::EventType;
use crate::headerdef::HeaderDef;
use crate::log::{info, warn};
use crate::message::{self, Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::net::dns::lookup_host_with_cache;
use crate::param::Param;
use crate::tools::time;
use anyhow::{Context as _, Result, ensure};
use sdp::SessionDescription;
use serde::Serialize;
use std::io::Cursor;
use std::str::FromStr;
use std::time::Duration;
use tokio::task;
use tokio::time::sleep;
/// How long callee's or caller's phone ring.
///
/// For the callee, this is to prevent endless ringing
/// in case the initial "call" is received, but then the caller went offline.
/// Moreover, this prevents outdated calls to ring
/// in case the initial "call" message arrives delayed.
///
/// For the caller, this means they should also not wait longer,
/// as the callee won't start the call afterwards.
const RINGING_SECONDS: i64 = 60;
// For persisting parameters in the call, we use Param::Arg*
const CALL_ACCEPTED_TIMESTAMP: Param = Param::Arg;
const CALL_ENDED_TIMESTAMP: Param = Param::Arg4;
const STUN_PORT: u16 = 3478;
/// Set if incoming call was ended explicitly
/// by the other side before we accepted it.
///
/// It is used to distinguish "ended" calls
/// that are rejected by us from the calls
/// canceled by the other side
/// immediately after ringing started.
const CALL_CANCELED_TIMESTAMP: Param = Param::Arg2;
/// Information about the status of a call.
#[derive(Debug, Default)]
pub struct CallInfo {
/// User-defined text as given to place_outgoing_call()
pub place_call_info: String,
/// User-defined text as given to accept_incoming_call()
pub accept_call_info: String,
/// Message referring to the call.
/// Data are persisted along with the message using Param::Arg*
pub msg: Message,
}
impl CallInfo {
/// Returns true if the call is an incoming call.
pub fn is_incoming(&self) -> bool {
self.msg.from_id != ContactId::SELF
}
/// Returns true if the call should not ring anymore.
pub fn is_stale(&self) -> bool {
(self.is_incoming() || self.msg.timestamp_sent != 0) && self.remaining_ring_seconds() <= 0
}
fn remaining_ring_seconds(&self) -> i64 {
let remaining_seconds = self.msg.timestamp_sent + RINGING_SECONDS - time();
remaining_seconds.clamp(0, RINGING_SECONDS)
}
async fn update_text(&self, context: &Context, text: &str) -> Result<()> {
context
.sql
.execute(
"UPDATE msgs SET txt=?, txt_normalized=? WHERE id=?",
(text, message::normalize_text(text), self.msg.id),
)
.await?;
Ok(())
}
async fn update_text_duration(&self, context: &Context) -> Result<()> {
let minutes = self.duration_seconds() / 60;
let duration = match minutes {
0 => "<1 minute".to_string(),
1 => "1 minute".to_string(),
n => format!("{n} minutes"),
};
if self.is_incoming() {
self.update_text(context, &format!("Incoming call\n{duration}"))
.await?;
} else {
self.update_text(context, &format!("Outgoing call\n{duration}"))
.await?;
}
Ok(())
}
/// Mark calls as accepted.
/// This is needed for all devices where a stale-timer runs, to prevent accepted calls being terminated as stale.
async fn mark_as_accepted(&mut self, context: &Context) -> Result<()> {
self.msg.param.set_i64(CALL_ACCEPTED_TIMESTAMP, time());
self.msg.update_param(context).await?;
Ok(())
}
/// Returns true if the call is accepted.
pub fn is_accepted(&self) -> bool {
self.msg.param.exists(CALL_ACCEPTED_TIMESTAMP)
}
/// Returns true if the call is missed
/// because the caller canceled it
/// explicitly before ringing stopped.
///
/// For outgoing calls this means
/// the receiver has rejected the call
/// explicitly.
pub fn is_canceled(&self) -> bool {
self.msg.param.exists(CALL_CANCELED_TIMESTAMP)
}
async fn mark_as_ended(&mut self, context: &Context) -> Result<()> {
self.msg.param.set_i64(CALL_ENDED_TIMESTAMP, time());
self.msg.update_param(context).await?;
Ok(())
}
/// Explicitly mark the call as canceled.
///
/// For incoming calls this should be called
/// when "call ended" message is received
/// from the caller before we picked up the call.
/// In this case the call becomes "missed" early
/// before the ringing timeout.
async fn mark_as_canceled(&mut self, context: &Context) -> Result<()> {
let now = time();
self.msg.param.set_i64(CALL_ENDED_TIMESTAMP, now);
self.msg.param.set_i64(CALL_CANCELED_TIMESTAMP, now);
self.msg.update_param(context).await?;
Ok(())
}
/// Returns true if the call is ended.
pub fn is_ended(&self) -> bool {
self.msg.param.exists(CALL_ENDED_TIMESTAMP)
}
/// Returns call duration in seconds.
pub fn duration_seconds(&self) -> i64 {
if let (Some(start), Some(end)) = (
self.msg.param.get_i64(CALL_ACCEPTED_TIMESTAMP),
self.msg.param.get_i64(CALL_ENDED_TIMESTAMP),
) {
let seconds = end - start;
if seconds <= 0 {
return 1;
}
return seconds;
}
0
}
}
impl Context {
/// Start an outgoing call.
pub async fn place_outgoing_call(
&self,
chat_id: ChatId,
place_call_info: String,
) -> Result<MsgId> {
let chat = Chat::load_from_db(self, chat_id).await?;
ensure!(
chat.typ == Chattype::Single,
"Can only place calls in 1:1 chats"
);
ensure!(!chat.is_self_talk(), "Cannot call self");
let mut call = Message {
viewtype: Viewtype::Call,
text: "Outgoing call".into(),
..Default::default()
};
call.param.set(Param::WebrtcRoom, &place_call_info);
call.id = send_msg(self, chat_id, &mut call).await?;
let wait = RINGING_SECONDS;
task::spawn(Context::emit_end_call_if_unaccepted(
self.clone(),
wait.try_into()?,
call.id,
));
Ok(call.id)
}
/// Accept an incoming call.
pub async fn accept_incoming_call(
&self,
call_id: MsgId,
accept_call_info: String,
) -> Result<()> {
let mut call: CallInfo = self.load_call_by_id(call_id).await?.with_context(|| {
format!("accept_incoming_call is called with {call_id} which does not refer to a call")
})?;
ensure!(call.is_incoming());
if call.is_accepted() || call.is_ended() {
info!(self, "Call already accepted/ended");
return Ok(());
}
call.mark_as_accepted(self).await?;
let chat = Chat::load_from_db(self, call.msg.chat_id).await?;
if chat.is_contact_request() {
chat.id.accept(self).await?;
}
// send an acceptance message around: to the caller as well as to the other devices of the callee
let mut msg = Message {
viewtype: Viewtype::Text,
text: "[Call accepted]".into(),
..Default::default()
};
msg.param.set_cmd(SystemMessage::CallAccepted);
msg.hidden = true;
msg.param
.set(Param::WebrtcAccepted, accept_call_info.to_string());
msg.set_quote(self, Some(&call.msg)).await?;
msg.id = send_msg(self, call.msg.chat_id, &mut msg).await?;
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
});
self.emit_msgs_changed(call.msg.chat_id, call_id);
Ok(())
}
/// Cancel, decline or hangup an incoming or outgoing call.
pub async fn end_call(&self, call_id: MsgId) -> Result<()> {
let mut call: CallInfo = self.load_call_by_id(call_id).await?.with_context(|| {
format!("end_call is called with {call_id} which does not refer to a call")
})?;
if call.is_ended() {
info!(self, "Call already ended");
return Ok(());
}
if !call.is_accepted() {
if call.is_incoming() {
call.mark_as_ended(self).await?;
call.update_text(self, "Declined call").await?;
} else {
call.mark_as_canceled(self).await?;
call.update_text(self, "Canceled call").await?;
}
} else {
call.mark_as_ended(self).await?;
call.update_text_duration(self).await?;
}
let mut msg = Message {
viewtype: Viewtype::Text,
text: "[Call ended]".into(),
..Default::default()
};
msg.param.set_cmd(SystemMessage::CallEnded);
msg.hidden = true;
msg.set_quote(self, Some(&call.msg)).await?;
msg.id = send_msg(self, call.msg.chat_id, &mut msg).await?;
self.emit_event(EventType::CallEnded {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
});
self.emit_msgs_changed(call.msg.chat_id, call_id);
Ok(())
}
async fn emit_end_call_if_unaccepted(
context: Context,
wait: u64,
call_id: MsgId,
) -> Result<()> {
sleep(Duration::from_secs(wait)).await;
let Some(mut call) = context.load_call_by_id(call_id).await? else {
warn!(
context,
"emit_end_call_if_unaccepted is called with {call_id} which does not refer to a call."
);
return Ok(());
};
if !call.is_accepted() && !call.is_ended() {
if call.is_incoming() {
call.mark_as_canceled(&context).await?;
call.update_text(&context, "Missed call").await?;
} else {
call.mark_as_ended(&context).await?;
call.update_text(&context, "Canceled call").await?;
}
context.emit_msgs_changed(call.msg.chat_id, call_id);
context.emit_event(EventType::CallEnded {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
});
}
Ok(())
}
pub(crate) async fn handle_call_msg(
&self,
call_id: MsgId,
mime_message: &MimeMessage,
from_id: ContactId,
) -> Result<()> {
if mime_message.is_call() {
let Some(call) = self.load_call_by_id(call_id).await? else {
warn!(self, "{call_id} does not refer to a call message");
return Ok(());
};
if call.is_incoming() {
if call.is_stale() {
call.update_text(self, "Missed call").await?;
self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call
} else {
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
}
};
self.emit_event(EventType::IncomingCall {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
place_call_info: call.place_call_info.to_string(),
has_video,
});
let wait = call.remaining_ring_seconds();
task::spawn(Context::emit_end_call_if_unaccepted(
self.clone(),
wait.try_into()?,
call.msg.id,
));
}
} else {
call.update_text(self, "Outgoing call").await?;
self.emit_msgs_changed(call.msg.chat_id, call_id);
}
} else {
match mime_message.is_system_message {
SystemMessage::CallAccepted => {
let Some(mut call) = self.load_call_by_id(call_id).await? else {
warn!(self, "{call_id} does not refer to a call message");
return Ok(());
};
if call.is_ended() || call.is_accepted() {
info!(self, "CallAccepted received for accepted/ended call");
return Ok(());
}
call.mark_as_accepted(self).await?;
self.emit_msgs_changed(call.msg.chat_id, call_id);
if call.is_incoming() {
self.emit_event(EventType::IncomingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
});
} else {
let accept_call_info = mime_message
.get_header(HeaderDef::ChatWebrtcAccepted)
.unwrap_or_default();
self.emit_event(EventType::OutgoingCallAccepted {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
accept_call_info: accept_call_info.to_string(),
});
}
}
SystemMessage::CallEnded => {
let Some(mut call) = self.load_call_by_id(call_id).await? else {
warn!(self, "{call_id} does not refer to a call message");
return Ok(());
};
if call.is_ended() {
// may happen eg. if a a message is missed
info!(self, "CallEnded received for ended call");
return Ok(());
}
if !call.is_accepted() {
if call.is_incoming() {
if from_id == ContactId::SELF {
call.mark_as_ended(self).await?;
call.update_text(self, "Declined call").await?;
} else {
call.mark_as_canceled(self).await?;
call.update_text(self, "Missed call").await?;
}
} else {
// outgoing
if from_id == ContactId::SELF {
call.mark_as_canceled(self).await?;
call.update_text(self, "Canceled call").await?;
} else {
call.mark_as_ended(self).await?;
call.update_text(self, "Declined call").await?;
}
}
} else {
call.mark_as_ended(self).await?;
call.update_text_duration(self).await?;
}
self.emit_msgs_changed(call.msg.chat_id, call_id);
self.emit_event(EventType::CallEnded {
msg_id: call.msg.id,
chat_id: call.msg.chat_id,
});
}
_ => {}
}
}
Ok(())
}
/// Loads information about the call given its ID.
///
/// If the message referred to by ID is
/// not a call message, returns `None`.
pub async fn load_call_by_id(&self, call_id: MsgId) -> Result<Option<CallInfo>> {
let call = Message::load_from_db(self, call_id).await?;
Ok(self.load_call_by_message(call))
}
// Loads information about the call given the `Message`.
//
// If the `Message` is not a call message, returns `None`
fn load_call_by_message(&self, call: Message) -> Option<CallInfo> {
if call.viewtype != Viewtype::Call {
// This can happen e.g. if a "call accepted"
// or "call ended" message is received
// with `In-Reply-To` referring to non-call message.
return None;
}
Some(CallInfo {
place_call_info: call
.param
.get(Param::WebrtcRoom)
.unwrap_or_default()
.to_string(),
accept_call_info: call
.param
.get(Param::WebrtcAccepted)
.unwrap_or_default()
.to_string(),
msg: call,
})
}
}
/// 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 {
/// Fresh incoming or outgoing call that is still ringing.
///
/// There is no separate state for outgoing call
/// that has been dialled but not ringing on the other side yet
/// as we don't know whether the other side received our call.
Alerting,
/// Active call.
Active,
/// Completed call that was once active
/// and then was terminated for any reason.
Completed {
/// Call duration in seconds.
duration: i64,
},
/// Incoming call that was not picked up within a timeout
/// or was explicitly ended by the caller before we picked up.
Missed,
/// Incoming call that was explicitly ended on our side
/// before picking up or outgoing call
/// that was declined before the timeout.
Declined,
/// Outgoing call that has been canceled on our side
/// before receiving a response.
///
/// Incoming calls cannot be canceled,
/// on the receiver side canceled calls
/// usually result in missed calls.
Canceled,
}
/// Returns call state given the message ID.
///
/// Returns an error if the message is not a call message.
pub async fn call_state(context: &Context, msg_id: MsgId) -> Result<CallState> {
let call = context
.load_call_by_id(msg_id)
.await?
.with_context(|| format!("{msg_id} is not a call message"))?;
let state = if call.is_incoming() {
if call.is_accepted() {
if call.is_ended() {
CallState::Completed {
duration: call.duration_seconds(),
}
} else {
CallState::Active
}
} else if call.is_canceled() {
// Call was explicitly canceled
// by the caller before we picked it up.
CallState::Missed
} else if call.is_ended() {
CallState::Declined
} else if call.is_stale() {
CallState::Missed
} else {
CallState::Alerting
}
} else if call.is_accepted() {
if call.is_ended() {
CallState::Completed {
duration: call.duration_seconds(),
}
} else {
CallState::Active
}
} else if call.is_canceled() {
CallState::Canceled
} else if call.is_ended() || call.is_stale() {
CallState::Declined
} else {
CallState::Alerting
};
Ok(state)
}
/// ICE server for JSON serialization.
#[derive(Serialize, Debug, Clone, PartialEq)]
struct IceServer {
/// STUN or TURN URLs.
pub urls: Vec<String>,
/// Username for TURN server authentication.
pub username: Option<String>,
/// Password for logging into the server.
pub credential: Option<String>,
}
/// Creates JSON with ICE servers.
async fn create_ice_servers(
context: &Context,
hostname: &str,
port: u16,
username: &str,
password: &str,
) -> Result<String> {
// Do not use cache because there is no TLS.
let load_cache = false;
let urls: Vec<String> = lookup_host_with_cache(context, hostname, port, "", load_cache)
.await?
.into_iter()
.map(|addr| format!("turn:{addr}"))
.collect();
let ice_server = IceServer {
urls,
username: Some(username.to_string()),
credential: Some(password.to_string()),
};
let json = serde_json::to_string(&[ice_server])?;
Ok(json)
}
/// Creates JSON with ICE servers from a line received over IMAP METADATA.
///
/// IMAP METADATA returns a line such as
/// `example.com:3478:1758650868:8Dqkyyu11MVESBqjbIylmB06rv8=`
///
/// 1758650868 is the username and expiration timestamp
/// at the same time,
/// while `8Dqkyyu11MVESBqjbIylmB06rv8=`
/// is the password.
pub(crate) async fn create_ice_servers_from_metadata(
context: &Context,
metadata: &str,
) -> Result<(i64, String)> {
let (hostname, rest) = metadata.split_once(':').context("Missing hostname")?;
let (port, rest) = rest.split_once(':').context("Missing port")?;
let port = u16::from_str(port).context("Failed to parse the port")?;
let (ts, password) = rest.split_once(':').context("Missing timestamp")?;
let expiration_timestamp = i64::from_str(ts).context("Failed to parse the timestamp")?;
let ice_servers = create_ice_servers(context, hostname, port, ts, password).await?;
Ok((expiration_timestamp, ice_servers))
}
/// Creates JSON with ICE servers when no TURN servers are known.
pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result<String> {
// Do not use public STUN server from https://stunprotocol.org/.
// It changes the hostname every year
// (e.g. stunserver2025.stunprotocol.org
// which was previously stunserver2024.stunprotocol.org)
// because of bandwidth costs:
// <https://github.com/jselbie/stunserver/issues/50>
// We use nine.testrun.org for a default STUN server.
let hostname = "nine.testrun.org";
// Do not use cache because there is no TLS.
let load_cache = false;
let urls: Vec<String> = lookup_host_with_cache(context, hostname, STUN_PORT, "", load_cache)
.await?
.into_iter()
.map(|addr| format!("stun:{addr}"))
.collect();
let ice_server = IceServer {
urls,
username: None,
credential: None,
};
let json = serde_json::to_string(&[ice_server])?;
Ok(json)
}
/// Returns JSON with ICE servers.
///
/// <https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/RTCPeerConnection#iceservers>
///
/// All returned servers are resolved to their IP addresses.
/// The primary point of DNS lookup is that Delta Chat Desktop
/// relies on the servers being specified by IP,
/// because it itself cannot utilize DNS. See
/// <https://github.com/deltachat/deltachat-desktop/issues/5447>.
pub async fn ice_servers(context: &Context) -> Result<String> {
if let Some(ref metadata) = *context.metadata.read().await {
Ok(metadata.ice_servers.clone())
} else {
Ok("[]".to_string())
}
}
#[cfg(test)]
mod calls_tests;

View File

@@ -1,668 +0,0 @@
use super::*;
use crate::chat::forward_msgs;
use crate::config::Config;
use crate::constants::DC_CHAT_ID_TRASH;
use crate::receive_imf::{receive_imf, receive_imf_from_inbox};
use crate::test_utils::{TestContext, TestContextManager};
struct CallSetup {
pub alice: TestContext,
pub alice2: TestContext,
pub alice_call: Message,
pub alice2_call: Message,
pub bob: TestContext,
pub bob2: TestContext,
pub bob_call: Message,
pub bob2_call: Message,
}
async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()> {
assert_eq!(Message::load_from_db(t, call_id).await?.text, text);
Ok(())
}
// Offer and answer examples from <https://www.rfc-editor.org/rfc/rfc3264>
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;
let alice2 = tcm.alice().await;
let bob = tcm.bob().await;
let bob2 = tcm.bob().await;
for t in [&alice, &alice2, &bob, &bob2] {
t.set_config_bool(Config::SyncMsgs, true).await?;
}
// Alice creates a chat with Bob and places an outgoing call there.
// Alice's other device sees the same message as an outgoing call.
let alice_chat = alice.create_chat(&bob).await;
let test_msg_id = alice
.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);
let alice_call = Message::load_from_db(&alice, sent1.sender_msg_id).await?;
let alice2_call = alice2.recv_msg(&sent1).await;
for (t, m) in [(&alice, &alice_call), (&alice2, &alice2_call)] {
assert!(!m.is_info());
assert_eq!(m.viewtype, Viewtype::Call);
let info = t
.load_call_by_id(m.id)
.await?
.expect("m should be a call message");
assert!(!info.is_incoming());
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_text(t, m.id, "Outgoing call").await?;
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
}
// Bob receives the message referring to the call on two devices;
// it is an incoming call from the view of Bob
let bob_call = bob.recv_msg(&sent1).await;
let bob2_call = bob2.recv_msg(&sent1).await;
for (t, m) in [(&bob, &bob_call), (&bob2, &bob2_call)] {
assert!(!m.is_info());
assert_eq!(m.viewtype, Viewtype::Call);
t.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCall { .. }))
.await;
let info = t
.load_call_by_id(m.id)
.await?
.expect("IncomingCall event should refer to a call message");
assert!(info.is_incoming());
assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_text(t, m.id, "Incoming call").await?;
assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
}
Ok(CallSetup {
alice,
alice2,
alice_call,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
})
}
async fn accept_call() -> Result<CallSetup> {
let CallSetup {
alice,
alice2,
alice_call,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
} = setup_call().await?;
// Bob accepts the incoming call
bob.accept_incoming_call(bob_call.id, ACCEPT_INFO.to_string())
.await?;
assert_text(&bob, bob_call.id, "Incoming call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.await;
let sent2 = bob.pop_sent_msg().await;
let info = bob
.load_call_by_id(bob_call.id)
.await?
.expect("bob_call should be a call message");
assert!(info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_eq!(call_state(&bob, bob_call.id).await?, CallState::Active);
bob2.recv_msg_trash(&sent2).await;
assert_text(&bob, bob_call.id, "Incoming call").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
.await;
let info = bob2
.load_call_by_id(bob2_call.id)
.await?
.expect("bob2_call should be a call message");
assert!(info.is_accepted());
assert_eq!(call_state(&bob2, bob2_call.id).await?, CallState::Active);
// Alice receives the acceptance message
alice.recv_msg_trash(&sent2).await;
assert_text(&alice, alice_call.id, "Outgoing call").await?;
let ev = alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
.await;
assert_eq!(
ev,
EventType::OutgoingCallAccepted {
msg_id: alice_call.id,
chat_id: alice_call.chat_id,
accept_call_info: ACCEPT_INFO.to_string()
}
);
let info = alice
.load_call_by_id(alice_call.id)
.await?
.expect("alice_call should be a call message");
assert!(info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO);
assert_eq!(call_state(&alice, alice_call.id).await?, CallState::Active);
alice2.recv_msg_trash(&sent2).await;
assert_text(&alice2, alice2_call.id, "Outgoing call").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
.await;
assert_eq!(
call_state(&alice2, alice2_call.id).await?,
CallState::Active
);
Ok(CallSetup {
alice,
alice2,
alice_call,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_accept_call_callee_ends() -> Result<()> {
// Alice calls Bob, Bob accepts
let CallSetup {
alice,
alice_call,
alice2,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
..
} = accept_call().await?;
// Bob has accepted the call and also ends it
bob.end_call(bob_call.id).await?;
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let sent3 = bob.pop_sent_msg().await;
assert!(matches!(
call_state(&bob, bob_call.id).await?,
CallState::Completed { .. }
));
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
assert!(matches!(
call_state(&bob2, bob2_call.id).await?,
CallState::Completed { .. }
));
// Alice receives the ending message
alice.recv_msg_trash(&sent3).await;
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
assert!(matches!(
call_state(&alice, alice_call.id).await?,
CallState::Completed { .. }
));
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
assert!(matches!(
call_state(&alice2, alice2_call.id).await?,
CallState::Completed { .. }
));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_accept_call_caller_ends() -> Result<()> {
// Alice calls Bob, Bob accepts
let CallSetup {
alice,
alice_call,
alice2,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
..
} = accept_call().await?;
// Bob has accepted the call but Alice ends it
alice.end_call(alice_call.id).await?;
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let sent3 = alice.pop_sent_msg().await;
assert!(matches!(
call_state(&alice, alice_call.id).await?,
CallState::Completed { .. }
));
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
assert!(matches!(
call_state(&alice2, alice2_call.id).await?,
CallState::Completed { .. }
));
// Bob receives the ending message
bob.recv_msg_trash(&sent3).await;
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
assert!(matches!(
call_state(&bob, bob_call.id).await?,
CallState::Completed { .. }
));
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
assert!(matches!(
call_state(&bob2, bob2_call.id).await?,
CallState::Completed { .. }
));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_callee_rejects_call() -> Result<()> {
// Alice calls Bob
let CallSetup {
alice,
alice2,
alice_call,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
..
} = 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_text(&bob, bob_call.id, "Declined call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let sent3 = bob.pop_sent_msg().await;
assert_eq!(call_state(&bob, bob_call.id).await?, CallState::Declined);
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Declined call").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
assert_eq!(call_state(&bob2, bob2_call.id).await?, CallState::Declined);
// Alice receives decline message
alice.recv_msg_trash(&sent3).await;
assert_text(&alice, alice_call.id, "Declined call").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
assert_eq!(
call_state(&alice, alice_call.id).await?,
CallState::Declined
);
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Declined call").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
assert_eq!(
call_state(&alice2, alice2_call.id).await?,
CallState::Declined
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_caller_cancels_call() -> Result<()> {
// Alice calls Bob
let CallSetup {
alice,
alice2,
alice_call,
alice2_call,
bob,
bob2,
bob_call,
bob2_call,
..
} = setup_call().await?;
// Alice changes their mind before Bob picks up
alice.end_call(alice_call.id).await?;
assert_text(&alice, alice_call.id, "Canceled call").await?;
alice
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
let sent3 = alice.pop_sent_msg().await;
assert_eq!(
call_state(&alice, alice_call.id).await?,
CallState::Canceled
);
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Canceled call").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
assert_eq!(
call_state(&alice2, alice2_call.id).await?,
CallState::Canceled
);
// Bob receives the ending message
bob.recv_msg_trash(&sent3).await;
assert_text(&bob, bob_call.id, "Missed call").await?;
bob.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
assert_eq!(call_state(&bob, bob_call.id).await?, CallState::Missed);
// Test that message summary says it is a missed call.
let bob_call_msg = Message::load_from_db(&bob, bob_call.id).await?;
let summary = bob_call_msg.get_summary(&bob, None).await?;
assert_eq!(summary.text, "📞 Missed call");
bob2.recv_msg_trash(&sent3).await;
assert_text(&bob2, bob2_call.id, "Missed call").await?;
bob2.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
assert_eq!(call_state(&bob2, bob2_call.id).await?, CallState::Missed);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_stale_call() -> Result<()> {
// a call started now is not stale
let call_info = CallInfo {
msg: Message {
timestamp_sent: time(),
..Default::default()
},
..Default::default()
};
assert!(!call_info.is_stale());
let remaining_seconds = call_info.remaining_ring_seconds();
assert!(remaining_seconds == RINGING_SECONDS || remaining_seconds == RINGING_SECONDS - 1);
// call started 5 seconds ago, this is not stale as well
let call_info = CallInfo {
msg: Message {
timestamp_sent: time() - 5,
..Default::default()
},
..Default::default()
};
assert!(!call_info.is_stale());
let remaining_seconds = call_info.remaining_ring_seconds();
assert!(remaining_seconds == RINGING_SECONDS - 5 || remaining_seconds == RINGING_SECONDS - 6);
// a call started one hour ago is clearly stale
let call_info = CallInfo {
msg: Message {
timestamp_sent: time() - 3600,
..Default::default()
},
..Default::default()
};
assert!(call_info.is_stale());
assert_eq!(call_info.remaining_ring_seconds(), 0);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mark_calls() -> Result<()> {
let CallSetup {
alice, alice_call, ..
} = setup_call().await?;
let mut call_info: CallInfo = alice
.load_call_by_id(alice_call.id)
.await?
.expect("alice_call should be a call message");
assert!(!call_info.is_accepted());
assert!(!call_info.is_ended());
call_info.mark_as_accepted(&alice).await?;
assert!(call_info.is_accepted());
assert!(!call_info.is_ended());
let mut call_info: CallInfo = alice
.load_call_by_id(alice_call.id)
.await?
.expect("alice_call should be a call message");
assert!(call_info.is_accepted());
assert!(!call_info.is_ended());
call_info.mark_as_ended(&alice).await?;
assert!(call_info.is_accepted());
assert!(call_info.is_ended());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_update_call_text() -> Result<()> {
let CallSetup {
alice, alice_call, ..
} = setup_call().await?;
let call_info = alice
.load_call_by_id(alice_call.id)
.await?
.expect("alice_call should be a call message");
call_info.update_text(&alice, "foo bar").await?;
let alice_call = Message::load_from_db(&alice, alice_call.id).await?;
assert_eq!(alice_call.get_text(), "foo bar");
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<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
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())
.await
.context("Failed to place a call")?;
let alice_call = Message::load_from_db(alice, alice_msg_id).await?;
let _alice_sent_call = alice.pop_sent_msg().await;
assert_eq!(alice_call.viewtype, Viewtype::Call);
let alice_charlie_chat = alice.create_chat(charlie).await;
forward_msgs(alice, &[alice_call.id], alice_charlie_chat.id).await?;
let alice_forwarded_call = alice.pop_sent_msg().await;
let alice_forwarded_call_msg = alice_forwarded_call.load_from_db().await;
assert_eq!(alice_forwarded_call_msg.viewtype, Viewtype::Text);
let charlie_forwarded_call = charlie.recv_msg(&alice_forwarded_call).await;
assert_eq!(charlie_forwarded_call.viewtype, Viewtype::Text);
Ok(())
}
/// Tests that "end call" message referring
/// to a text message does not make receive_imf fail.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_end_text_call() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let received1 = receive_imf(
alice,
b"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <first@example.net>\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
Chat-Version: 1.0\n\
\n\
Hello\n",
false,
)
.await?
.unwrap();
assert_eq!(received1.msg_ids.len(), 1);
let msg = Message::load_from_db(alice, received1.msg_ids[0])
.await
.unwrap();
assert_eq!(msg.viewtype, Viewtype::Text);
// Receiving "Call ended" message that refers
// to the text message does not result in an error.
let received2 = receive_imf(
alice,
b"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <second@example.net>\n\
Date: Sun, 22 Mar 2020 23:37:57 +0000\n\
In-Reply-To: <first@example.net>\n\
Chat-Version: 1.0\n\
Chat-Content: call-ended\n\
\n\
Call ended\n",
false,
)
.await?
.unwrap();
assert_eq!(received2.msg_ids.len(), 1);
assert_eq!(received2.chat_id, DC_CHAT_ID_TRASH);
Ok(())
}
/// Tests that partially downloaded "call ended"
/// messages are not processed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_partial_calls() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let seen = false;
// The messages in the test
// have no `Date` on purpose,
// so they are treated as new.
let received_call = receive_imf(
alice,
b"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <first@example.net>\n\
Chat-Version: 1.0\n\
Chat-Content: call\n\
Chat-Webrtc-Room: YWFhYWFhYWFhCg==\n\
\n\
Hello, this is a call\n",
seen,
)
.await?
.unwrap();
assert_eq!(received_call.msg_ids.len(), 1);
let call_msg = Message::load_from_db(alice, received_call.msg_ids[0])
.await
.unwrap();
assert_eq!(call_msg.viewtype, Viewtype::Call);
assert_eq!(call_state(alice, call_msg.id).await?, CallState::Alerting);
let imf_raw = b"From: bob@example.net\n\
To: alice@example.org\n\
Message-ID: <second@example.net>\n\
In-Reply-To: <first@example.net>\n\
Chat-Version: 1.0\n\
Chat-Content: call-ended\n\
\n\
Call ended\n";
receive_imf_from_inbox(
alice,
"second@example.net",
imf_raw,
seen,
Some(imf_raw.len().try_into().unwrap()),
)
.await?;
// The call is still not ended.
assert_eq!(call_state(alice, call_msg.id).await?, CallState::Alerting);
// Fully downloading the message ends the call.
receive_imf_from_inbox(alice, "second@example.net", imf_raw, seen, None)
.await
.context("Failed to fully download end call message")?;
assert_eq!(call_state(alice, call_msg.id).await?, CallState::Missed);
Ok(())
}

View File

@@ -94,12 +94,14 @@ pub enum ProtectionStatus {
///
/// All members of the chat must be verified.
Protected = 1,
// `2` was never used as a value.
// Chats don't break in Core v2 anymore. Chats with broken protection existing before the
// key-contacts migration are treated as `Unprotected`.
//
// ProtectionBroken = 3,
/// The chat was protected, but now a new message came in
/// which was not encrypted / signed correctly.
/// The user has to confirm that this is OK.
///
/// We only do this in 1:1 chats; in group chats, the chat just
/// stays protected.
ProtectionBroken = 3, // `2` was never used as a value.
}
/// The reason why messages cannot be sent to the chat.
@@ -116,6 +118,10 @@ pub(crate) enum CantSendReason {
/// The chat is a contact request, it needs to be accepted before sending a message.
ContactRequest,
/// The chat was protected, but now a new message came in
/// which was not encrypted / signed correctly.
ProtectionBroken,
/// Mailing list without known List-Post header.
ReadOnlyMailingList,
@@ -138,6 +144,10 @@ impl fmt::Display for CantSendReason {
f,
"contact request chat should be accepted before sending messages"
),
Self::ProtectionBroken => write!(
f,
"accept that the encryption isn't verified anymore before sending messages"
),
Self::ReadOnlyMailingList => {
write!(f, "mailing list does not have a know post address")
}
@@ -373,7 +383,7 @@ impl ChatId {
/// Returns true if the value was modified.
pub(crate) async fn set_blocked(self, context: &Context, new_blocked: Blocked) -> Result<bool> {
if self.is_special() {
bail!("ignoring setting of Block-status for {self}");
bail!("ignoring setting of Block-status for {}", self);
}
let count = context
.sql
@@ -469,6 +479,16 @@ impl ChatId {
let chat = Chat::load_from_db(context, self).await?;
match chat.typ {
Chattype::Single
if chat.blocked == Blocked::Not
&& chat.protected == ProtectionStatus::ProtectionBroken =>
{
// The protection was broken, then the user clicked 'Accept'/'OK',
// so, now we want to set the status to Unprotected again:
chat.id
.inner_set_protection(context, ProtectionStatus::Unprotected)
.await?;
}
Chattype::Single | Chattype::Group | Chattype::OutBroadcast | Chattype::InBroadcast => {
// User has "created a chat" with all these contacts.
//
@@ -525,7 +545,7 @@ impl ChatId {
| Chattype::InBroadcast => {}
Chattype::Mailinglist => bail!("Cannot protect mailing lists"),
},
ProtectionStatus::Unprotected => {}
ProtectionStatus::Unprotected | ProtectionStatus::ProtectionBroken => {}
};
context
@@ -568,6 +588,7 @@ impl ChatId {
let cmd = match protect {
ProtectionStatus::Protected => SystemMessage::ChatProtectionEnabled,
ProtectionStatus::Unprotected => SystemMessage::ChatProtectionDisabled,
ProtectionStatus::ProtectionBroken => SystemMessage::ChatProtectionDisabled,
};
add_info_msg_with_cmd(
context,
@@ -702,7 +723,8 @@ impl ChatId {
) -> Result<()> {
ensure!(
!self.is_special(),
"bad chat_id, can not be special chat: {self}"
"bad chat_id, can not be special chat: {}",
self
);
context
@@ -812,7 +834,8 @@ impl ChatId {
pub(crate) async fn delete_ex(self, context: &Context, sync: sync::Sync) -> Result<()> {
ensure!(
!self.is_special(),
"bad chat_id, can not be a special chat: {self}"
"bad chat_id, can not be a special chat: {}",
self
);
let chat = Chat::load_from_db(context, self).await?;
@@ -1677,6 +1700,12 @@ impl Chat {
return Ok(Some(reason));
}
}
if self.is_protection_broken() {
let reason = ProtectionBroken;
if !skip_fn(&reason) {
return Ok(Some(reason));
}
}
if self.is_mailing_list() && self.get_mailinglist_addr().is_none_or_empty() {
let reason = ReadOnlyMailingList;
if !skip_fn(&reason) {
@@ -1769,12 +1798,6 @@ impl Chat {
return Ok(Some(get_device_icon(context).await?));
} else if self.is_self_talk() {
return Ok(Some(get_saved_messages_icon(context).await?));
} else if !self.is_encrypted(context).await? {
// This is an unencrypted chat, show a special avatar that marks it as such.
return Ok(Some(get_abs_path(
context,
Path::new(&get_unencrypted_icon(context).await?),
)));
} else if self.typ == Chattype::Single {
// For 1:1 chats, we always use the same avatar as for the contact
// This is before the `self.is_encrypted()` check, because that function
@@ -1784,6 +1807,12 @@ impl Chat {
let contact = Contact::get_by_id(context, *contact_id).await?;
return contact.get_profile_image(context).await;
}
} else if !self.is_encrypted(context).await? {
// This is an address-contact chat, show a special avatar that marks it as such
return Ok(Some(get_abs_path(
context,
Path::new(&get_address_contact_icon(context).await?),
)));
} else if let Some(image_rel) = self.param.get(Param::ProfileImage) {
// Load the group avatar, or the device-chat / saved-messages icon
if !image_rel.is_empty() {
@@ -1795,9 +1824,8 @@ impl Chat {
/// Returns chat avatar color.
///
/// For 1:1 chats, the color is calculated from the contact's address
/// for address-contacts and from the OpenPGP key fingerprint for key-contacts.
/// For group chats the color is calculated from the grpid, if present, or the chat name.
/// For 1:1 chats, the color is calculated from the contact's address.
/// For group chats the color is calculated from the chat name.
pub async fn get_color(&self, context: &Context) -> Result<u32> {
let mut color = 0;
@@ -1808,8 +1836,6 @@ impl Chat {
color = contact.get_color();
}
}
} else if !self.grpid.is_empty() {
color = str_to_color(&self.grpid);
} else {
color = str_to_color(&self.name);
}
@@ -1887,25 +1913,16 @@ impl Chat {
let is_encrypted = self.is_protected()
|| match self.typ {
Chattype::Single => {
match context
.sql
.query_row_optional(
"SELECT cc.contact_id, c.fingerprint<>''
FROM chats_contacts cc LEFT JOIN contacts c
ON c.id=cc.contact_id
WHERE cc.chat_id=?
",
(self.id,),
|row| {
let id: ContactId = row.get(0)?;
let is_key: bool = row.get(1)?;
Ok((id, is_key))
},
)
.await?
{
Some((id, is_key)) => is_key || id == ContactId::DEVICE,
None => true,
let chat_contact_ids = get_chat_contacts(context, self.id).await?;
if let Some(contact_id) = chat_contact_ids.first() {
if *contact_id == ContactId::DEVICE {
true
} else {
let contact = Contact::get_by_id(context, *contact_id).await?;
contact.is_key_contact()
}
} else {
true
}
}
Chattype::Group => {
@@ -1918,6 +1935,27 @@ impl Chat {
Ok(is_encrypted)
}
/// Returns true if the chat was protected, and then an incoming message broke this protection.
///
/// This function is only useful if the UI enabled the `verified_one_on_one_chats` feature flag,
/// otherwise it will return false for all chats.
///
/// 1:1 chats are automatically set as protected when a contact is verified.
/// When a message comes in that is not encrypted / signed correctly,
/// the chat is automatically set as unprotected again.
/// `is_protection_broken()` will return true until `chat_id.accept()` is called.
///
/// The UI should let the user confirm that this is OK with a message like
/// `Bob sent a message from another device. Tap to learn more`
/// and then call `chat_id.accept()`.
pub fn is_protection_broken(&self) -> bool {
match self.protected {
ProtectionStatus::Protected => false,
ProtectionStatus::Unprotected => false,
ProtectionStatus::ProtectionBroken => true,
}
}
/// Returns true if location streaming is enabled in the chat.
pub fn is_sending_locations(&self) -> bool {
self.is_sending_locations
@@ -1955,7 +1993,7 @@ impl Chat {
}
/// Adds missing values to the msg object,
/// writes the record to the database.
/// writes the record to the database and returns its msg_id.
///
/// If `update_msg_id` is set, that record is reused;
/// if `update_msg_id` is None, a new record is created.
@@ -1964,7 +2002,7 @@ impl Chat {
context: &Context,
msg: &mut Message,
update_msg_id: Option<MsgId>,
) -> Result<()> {
) -> Result<MsgId> {
let mut to_id = 0;
let mut location_id = 0;
@@ -2242,7 +2280,7 @@ impl Chat {
.await?;
}
context.scheduler.interrupt_ephemeral_task().await;
Ok(())
Ok(msg.id)
}
/// Sends a `SyncAction` synchronising chat contacts to other devices.
@@ -2495,13 +2533,11 @@ pub(crate) async fn get_archive_icon(context: &Context) -> Result<PathBuf> {
.await
}
/// Returns path to the icon
/// indicating unencrypted chats and address-contacts.
pub(crate) async fn get_unencrypted_icon(context: &Context) -> Result<PathBuf> {
pub(crate) async fn get_address_contact_icon(context: &Context) -> Result<PathBuf> {
get_asset_icon(
context,
"icon-unencrypted",
include_bytes!("../assets/icon-unencrypted.png"),
"icon-address-contact",
include_bytes!("../assets/icon-address-contact.png"),
)
.await
}
@@ -2689,7 +2725,7 @@ impl ChatIdBlocked {
}
async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
if msg.viewtype == Viewtype::Text || msg.viewtype == Viewtype::Call {
if msg.viewtype == Viewtype::Text || msg.viewtype == Viewtype::VideochatInvitation {
// the caller should check if the message text is empty
} else if msg.viewtype.has_file() {
let viewtype_orig = msg.viewtype;
@@ -2911,7 +2947,7 @@ async fn prepare_send_msg(
let mut chat = Chat::load_from_db(context, chat_id).await?;
let skip_fn = |reason: &CantSendReason| match reason {
CantSendReason::ContactRequest => {
CantSendReason::ProtectionBroken | CantSendReason::ContactRequest => {
// Allow securejoin messages, they are supposed to repair the verification.
// If the chat is a contact request, let the user accept it later.
msg.param.get_cmd() == SystemMessage::SecurejoinMessage
@@ -2967,7 +3003,8 @@ async fn prepare_send_msg(
if !msg.hidden {
chat_id.unarchive_if_not_muted(context, msg.state).await?;
}
chat.prepare_msg_raw(context, msg, update_msg_id).await?;
msg.id = chat.prepare_msg_raw(context, msg, update_msg_id).await?;
msg.chat_id = chat_id;
let row_ids = create_send_msg_jobs(context, msg)
.await
@@ -2980,10 +3017,6 @@ async fn prepare_send_msg(
/// Constructs jobs for sending a message and inserts them into the appropriate table.
///
/// Updates the message `GuaranteeE2ee` parameter and persists it
/// in the database depending on whether the message
/// is added to the outgoing queue as encrypted or not.
///
/// Returns row ids if `smtp` table jobs were created or an empty `Vec` otherwise.
///
/// The caller has to interrupt SMTP loop or otherwise process new rows.
@@ -2995,16 +3028,7 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
}
let needs_encryption = msg.param.get_bool(Param::GuaranteeE2ee).unwrap_or_default();
let mimefactory = match MimeFactory::from_msg(context, msg.clone()).await {
Ok(mf) => mf,
Err(err) => {
// Mark message as failed
message::set_msg_failed(context, msg, &err.to_string())
.await
.ok();
return Err(err);
}
};
let mimefactory = MimeFactory::from_msg(context, msg.clone()).await?;
let attach_selfavatar = mimefactory.attach_selfavatar;
let mut recipients = mimefactory.recipients();
@@ -3086,20 +3110,13 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
}
}
if rendered_msg.is_encrypted {
if rendered_msg.is_encrypted && !needs_encryption {
msg.param.set_int(Param::GuaranteeE2ee, 1);
} else {
msg.param.remove(Param::GuaranteeE2ee);
msg.update_param(context).await?;
}
msg.subject.clone_from(&rendered_msg.subject);
context
.sql
.execute(
"UPDATE msgs SET subject=?, param=? WHERE id=?",
(&msg.subject, msg.param.to_string(), msg.id),
)
.await?;
msg.subject.clone_from(&rendered_msg.subject);
msg.update_subject(context).await?;
let chunk_size = context.get_max_smtp_rcpt_to().await?;
let trans_fn = |t: &mut rusqlite::Transaction| {
let mut row_ids = Vec::<i64>::new();
@@ -3143,7 +3160,8 @@ pub async fn send_text_msg(
) -> Result<MsgId> {
ensure!(
!chat_id.is_special(),
"bad chat_id, can not be a special chat: {chat_id}"
"bad chat_id, can not be a special chat: {}",
chat_id
);
let mut msg = Message::new_text(text_to_send);
@@ -3159,7 +3177,10 @@ pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: Strin
);
ensure!(!original_msg.is_info(), "Cannot edit info messages");
ensure!(!original_msg.has_html(), "Cannot edit HTML messages");
ensure!(original_msg.viewtype != Viewtype::Call, "Cannot edit calls");
ensure!(
original_msg.viewtype != Viewtype::VideochatInvitation,
"Cannot edit videochat invitations"
);
ensure!(
!original_msg.text.is_empty(), // avoid complexity in UI element changes. focus is typos and rewordings
"Cannot add text"
@@ -3207,6 +3228,34 @@ pub(crate) async fn save_text_edit_to_db(
Ok(())
}
/// Sends invitation to a videochat.
pub async fn send_videochat_invitation(context: &Context, chat_id: ChatId) -> Result<MsgId> {
ensure!(
!chat_id.is_special(),
"video chat invitation cannot be sent to special chat: {}",
chat_id
);
let instance = if let Some(instance) = context.get_config(Config::WebrtcInstance).await? {
if !instance.is_empty() {
instance
} else {
bail!("webrtc_instance is empty");
}
} else {
bail!("webrtc_instance not set");
};
let instance = Message::create_webrtc_instance(&instance, &create_id());
let mut msg = Message::new(Viewtype::VideochatInvitation);
msg.param.set(Param::WebrtcRoom, &instance);
msg.text =
stock_str::videochat_invite_msg_body(context, &Message::parse_webrtc_instance(&instance).1)
.await;
send_msg(context, chat_id, &mut msg).await
}
async fn donation_request_maybe(context: &Context) -> Result<()> {
let secs_between_checks = 30 * 24 * 60 * 60;
let now = time();
@@ -3317,11 +3366,10 @@ pub async fn get_chat_msgs_ex(
for (ts, curr_id) in sorted_rows {
if add_daymarker {
let curr_local_timestamp = ts + cnv_to_local;
let secs_in_day = 86400;
let curr_day = curr_local_timestamp / secs_in_day;
let curr_day = curr_local_timestamp / 86400;
if curr_day != last_day {
ret.push(ChatItem::DayMarker {
timestamp: curr_day * secs_in_day - cnv_to_local,
timestamp: curr_day * 86400, // Convert day back to Unix timestamp
});
last_day = curr_day;
}
@@ -3669,13 +3717,8 @@ pub async fn create_group_ex(
encryption: Option<ProtectionStatus>,
name: &str,
) -> Result<ChatId> {
let mut chat_name = sanitize_single_line(name);
if chat_name.is_empty() {
// We can't just fail because the user would lose the work already done in the UI like
// selecting members.
error!(context, "Invalid chat name: {name}.");
chat_name = "".to_string();
}
let chat_name = sanitize_single_line(name);
ensure!(!chat_name.is_empty(), "Invalid chat name");
let grpid = match encryption {
Some(_) => create_id(),
@@ -3700,19 +3743,11 @@ pub async fn create_group_ex(
chatlist_events::emit_chatlist_changed(context);
chatlist_events::emit_chatlist_item_changed(context, chat_id);
match encryption {
Some(ProtectionStatus::Protected) => {
let protect = ProtectionStatus::Protected;
chat_id
.set_protection_for_timestamp_sort(context, protect, timestamp, None)
.await?;
}
Some(ProtectionStatus::Unprotected) => {
// Add "Messages are end-to-end encrypted." message
// even to unprotected chats.
chat_id.maybe_add_encrypted_msg(context, timestamp).await?;
}
None => {}
if encryption == Some(ProtectionStatus::Protected) {
let protect = ProtectionStatus::Protected;
chat_id
.set_protection_for_timestamp_sort(context, protect, timestamp, None)
.await?;
}
if !context.get_config_bool(Config::Bot).await?
@@ -3911,11 +3946,13 @@ pub(crate) async fn add_contact_to_chat_ex(
let mut chat = Chat::load_from_db(context, chat_id).await?;
ensure!(
chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast,
"{chat_id} is not a group/broadcast where one can add members"
"{} is not a group/broadcast where one can add members",
chat_id
);
ensure!(
Contact::real_exists_by_id(context, contact_id).await? || contact_id == ContactId::SELF,
"invalid contact_id {contact_id} for adding to group"
"invalid contact_id {} for adding to group",
contact_id
);
ensure!(!chat.is_mailing_list(), "Mailing lists can't be changed");
ensure!(
@@ -4128,7 +4165,8 @@ pub async fn remove_contact_from_chat(
) -> Result<()> {
ensure!(
!chat_id.is_special(),
"bad chat_id, can not be special chat: {chat_id}"
"bad chat_id, can not be special chat: {}",
chat_id
);
ensure!(
!contact_id.is_special() || contact_id == ContactId::SELF,
@@ -4142,7 +4180,7 @@ pub async fn remove_contact_from_chat(
"Cannot remove contact {contact_id} from chat {chat_id}: self not in group."
);
context.emit_event(EventType::ErrorSelfNotInGroup(err_msg.clone()));
bail!("{err_msg}");
bail!("{}", err_msg);
} else {
let mut sync = Nosync;
@@ -4166,7 +4204,7 @@ pub async fn remove_contact_from_chat(
if chat.typ == Chattype::Group && chat.is_promoted() {
let addr = contact.get_addr();
let res = send_member_removal_msg(context, &chat, contact_id, addr).await;
let res = send_member_removal_msg(context, chat_id, contact_id, addr).await;
if contact_id == ContactId::SELF {
res?;
@@ -4190,7 +4228,7 @@ pub async fn remove_contact_from_chat(
// For incoming broadcast channels, it's not possible to remove members,
// but it's possible to leave:
let self_addr = context.get_primary_self_addr().await?;
send_member_removal_msg(context, &chat, contact_id, &self_addr).await?;
send_member_removal_msg(context, chat_id, contact_id, &self_addr).await?;
} else {
bail!("Cannot remove members from non-group chats.");
}
@@ -4200,18 +4238,14 @@ pub async fn remove_contact_from_chat(
async fn send_member_removal_msg(
context: &Context,
chat: &Chat,
chat_id: ChatId,
contact_id: ContactId,
addr: &str,
) -> Result<MsgId> {
let mut msg = Message::new(Viewtype::Text);
if contact_id == ContactId::SELF {
if chat.typ == Chattype::InBroadcast {
msg.text = stock_str::msg_you_left_broadcast(context).await;
} else {
msg.text = stock_str::msg_group_left_local(context, ContactId::SELF).await;
}
msg.text = stock_str::msg_group_left_local(context, ContactId::SELF).await;
} else {
msg.text = stock_str::msg_del_member_local(context, contact_id, ContactId::SELF).await;
}
@@ -4221,7 +4255,7 @@ async fn send_member_removal_msg(
msg.param
.set(Param::ContactAddedRemoved, contact_id.to_u32());
send_msg(context, chat.id, &mut msg).await
send_msg(context, chat_id, &mut msg).await
}
async fn set_group_explicitly_left(context: &Context, grpid: &str) -> Result<()> {
@@ -4362,7 +4396,7 @@ pub async fn set_chat_profile_image(
msg.text = stock_str::msg_grp_img_changed(context, ContactId::SELF).await;
}
chat.update_param(context).await?;
if chat.is_promoted() {
if chat.is_promoted() && !chat.is_mailing_list() {
msg.id = send_msg(context, chat_id, &mut msg).await?;
context.emit_msgs_changed(chat_id, msg.id);
}
@@ -4384,7 +4418,7 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
.await?;
let mut chat = Chat::load_from_db(context, chat_id).await?;
if let Some(reason) = chat.why_cant_send(context).await? {
bail!("cannot send to {chat_id}: {reason}");
bail!("cannot send to {}: {}", chat_id, reason);
}
curr_timestamp = create_smeared_timestamps(context, msg_ids.len());
let mut msgs = Vec::with_capacity(msg_ids.len());
@@ -4409,10 +4443,6 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
.set_int(Param::Forwarded, src_msg_id.to_u32() as i32);
}
if msg.get_viewtype() == Viewtype::Call {
msg.viewtype = Viewtype::Text;
}
msg.param.remove(Param::GuaranteeE2ee);
msg.param.remove(Param::ForcePlaintext);
msg.param.remove(Param::Cmd);
@@ -4422,8 +4452,6 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
msg.param.remove(Param::WebxdcSummary);
msg.param.remove(Param::WebxdcSummaryTimestamp);
msg.param.remove(Param::IsEdited);
msg.param.remove(Param::WebrtcRoom);
msg.param.remove(Param::WebrtcAccepted);
msg.in_reply_to = None;
// do not leak data as group names; a default subject is generated by mimefactory
@@ -4432,13 +4460,13 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
msg.state = MessageState::OutPending;
msg.rfc724_mid = create_outgoing_rfc724_mid();
msg.timestamp_sort = curr_timestamp;
chat.prepare_msg_raw(context, &mut msg, None).await?;
let new_msg_id = chat.prepare_msg_raw(context, &mut msg, None).await?;
curr_timestamp += 1;
if !create_send_msg_jobs(context, &mut msg).await?.is_empty() {
context.scheduler.interrupt_smtp().await;
}
created_msgs.push(msg.id);
created_msgs.push(new_msg_id);
}
for msg_id in created_msgs {
context.emit_msgs_changed(chat_id, msg_id);
@@ -4495,24 +4523,15 @@ pub(crate) async fn save_copy_in_self_talk(
bail!("message already saved.");
}
let copy_fields = "from_id, to_id, timestamp_rcvd, type, txt,
mime_modified, mime_headers, mime_compressed, mime_in_reply_to, subject, msgrmsg";
let copy_fields = "from_id, to_id, timestamp_sent, timestamp_rcvd, type, txt, \
mime_modified, mime_headers, mime_compressed, mime_in_reply_to, subject, msgrmsg";
let row_id = context
.sql
.insert(
&format!(
"INSERT INTO msgs ({copy_fields},
timestamp_sent,
chat_id, rfc724_mid, state, timestamp, param, starred)
SELECT {copy_fields},
-- Outgoing messages on originating device
-- have timestamp_sent == 0.
-- We copy sort timestamp instead
-- so UIs display the same timestamp
-- for saved and original message.
IIF(timestamp_sent == 0, timestamp, timestamp_sent),
?, ?, ?, ?, ?, ?
FROM msgs WHERE id=?;"
"INSERT INTO msgs ({copy_fields}, chat_id, rfc724_mid, state, timestamp, param, starred) \
SELECT {copy_fields}, ?, ?, ?, ?, ?, ? \
FROM msgs WHERE id=?;"
),
(
dest_chat_id,
@@ -4543,9 +4562,18 @@ pub(crate) async fn save_copy_in_self_talk(
///
/// This is primarily intended to make existing webxdcs available to new chat members.
pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
let mut chat_id = None;
let mut msgs: Vec<Message> = Vec::new();
for msg_id in msg_ids {
let msg = Message::load_from_db(context, *msg_id).await?;
if let Some(chat_id) = chat_id {
ensure!(
chat_id == msg.chat_id,
"messages to resend needs to be in the same chat"
);
} else {
chat_id = Some(msg.chat_id);
}
ensure!(
msg.from_id == ContactId::SELF,
"can resend only own messages"
@@ -4554,7 +4582,16 @@ pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
msgs.push(msg)
}
let Some(chat_id) = chat_id else {
return Ok(());
};
let chat = Chat::load_from_db(context, chat_id).await?;
for mut msg in msgs {
if msg.get_showpadlock() && !chat.is_protected() {
msg.param.remove(Param::GuaranteeE2ee);
msg.update_param(context).await?;
}
match msg.get_state() {
// `get_state()` may return an outdated `OutPending`, so update anyway.
MessageState::OutPending
@@ -4565,21 +4602,16 @@ 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;
}
// Emit the event only after `create_send_msg_jobs`
// because `create_send_msg_jobs` may change the message
// encryption status and call `msg.update_param`.
context.emit_event(EventType::MsgsChanged {
chat_id: msg.chat_id,
msg_id: msg.id,
});
msg.timestamp_sort = create_smeared_timestamp(context);
// note(treefit): only matters if it is the last message in chat (but probably to expensive to check, debounce also solves it)
chatlist_events::emit_chatlist_item_changed(context, msg.chat_id);
if create_send_msg_jobs(context, &mut msg).await?.is_empty() {
continue;
}
if msg.viewtype == Viewtype::Webxdc {
let conn_fn = |conn: &mut rusqlite::Connection| {
let range = conn.query_row(

View File

@@ -11,9 +11,7 @@ use crate::test_utils::{
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, E2EE_INFO_MSGS, TestContext, TestContextManager,
TimeShiftFalsePositiveNote, sync,
};
use crate::tools::SystemTime;
use pretty_assertions::assert_eq;
use std::time::Duration;
use strum::IntoEnumIterator;
use tokio::fs;
@@ -34,7 +32,7 @@ async fn test_chat_info() {
"archived": false,
"param": "",
"is_sending_locations": false,
"color": 29381,
"color": 35391,
"profile_image": {},
"draft": "",
"is_muted": false,
@@ -1646,7 +1644,7 @@ async fn test_set_mute_duration() {
async fn test_add_info_msg() -> Result<()> {
let t = TestContext::new().await;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?;
add_info_msg(&t, chat_id, "foo info", time()).await?;
add_info_msg(&t, chat_id, "foo info", 200000).await?;
let msg = t.get_last_msg_in(chat_id).await;
assert_eq!(msg.get_chat_id(), chat_id);
@@ -1668,7 +1666,7 @@ async fn test_add_info_msg_with_cmd() -> Result<()> {
chat_id,
"foo bar info",
SystemMessage::EphemeralTimerChanged,
time(),
10000,
None,
None,
None,
@@ -1931,31 +1929,19 @@ async fn test_classic_email_chat() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_get_color() -> Result<()> {
let t = TestContext::new().await;
let chat_id = create_group_ex(&t, None, "a chat").await?;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "a chat").await?;
let color1 = Chat::load_from_db(&t, chat_id).await?.get_color(&t).await?;
assert_eq!(color1, 0x6239dc);
assert_eq!(color1, 0x008772);
// upper-/lowercase makes a difference for the colors, these are different groups
// (in contrast to email addresses, where upper-/lowercase is ignored in practise)
let t = TestContext::new().await;
let chat_id = create_group_ex(&t, None, "A CHAT").await?;
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "A CHAT").await?;
let color2 = Chat::load_from_db(&t, chat_id).await?.get_color(&t).await?;
assert_ne!(color2, color1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chat_get_color_encrypted() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let chat_id = create_group_ex(t, Some(ProtectionStatus::Unprotected), "a chat").await?;
let color1 = Chat::load_from_db(t, chat_id).await?.get_color(t).await?;
set_chat_name(t, chat_id, "A CHAT").await?;
let color2 = Chat::load_from_db(t, chat_id).await?.get_color(t).await?;
assert_eq!(color2, color1);
Ok(())
}
async fn test_sticker(
filename: &str,
bytes: &[u8],
@@ -2294,19 +2280,14 @@ async fn test_only_minimal_data_are_forwarded() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_save_msgs() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
let sent = alice.send_text(alice_chat.get_id(), "hi, bob").await;
let sent_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?;
assert!(sent_msg.get_saved_msg_id(&alice).await?.is_none());
assert!(sent_msg.get_original_msg_id(&alice).await?.is_none());
let sent_timestamp = sent_msg.get_timestamp();
assert!(sent_timestamp > 0);
SystemTime::shift(Duration::from_secs(60));
let self_chat = alice.get_self_chat().await;
save_msgs(&alice, &[sent.sender_msg_id]).await?;
@@ -2324,8 +2305,6 @@ async fn test_save_msgs() -> Result<()> {
assert_eq!(saved_msg.get_from_id(), ContactId::SELF);
assert_eq!(saved_msg.get_state(), MessageState::OutDelivered);
assert_ne!(saved_msg.rfc724_mid(), sent_msg.rfc724_mid());
let saved_timestamp = saved_msg.get_timestamp();
assert_eq!(saved_timestamp, sent_timestamp);
let sent_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?;
assert_eq!(
@@ -3026,7 +3005,7 @@ async fn test_leave_broadcast() -> Result<()> {
}
/// Tests that if Bob leaves a broadcast channel with one device,
/// the other device shows a correct info message "You left the channel.".
/// the other device shows a correct info message "You left.".
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_leave_broadcast_multidevice() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -3061,7 +3040,10 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
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).await);
assert_eq!(
rcvd.text,
stock_str::msg_group_left_local(bob1, ContactId::SELF).await
);
Ok(())
}
@@ -3170,30 +3152,6 @@ async fn test_chat_get_encryption_info() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_out_failed_on_all_keys_missing() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let bob_chat_id = bob
.create_group_with_members(ProtectionStatus::Unprotected, "", &[alice, fiona])
.await;
bob.send_text(bob_chat_id, "Gossiping Fiona's key").await;
alice
.recv_msg(&bob.send_text(bob_chat_id, "No key gossip").await)
.await;
SystemTime::shift(Duration::from_secs(60));
remove_contact_from_chat(bob, bob_chat_id, ContactId::SELF).await?;
let alice_chat_id = alice.recv_msg(&bob.pop_sent_msg().await).await.chat_id;
alice_chat_id.accept(alice).await?;
let mut msg = Message::new_text("Hi".to_string());
send_msg(alice, alice_chat_id, &mut msg).await.ok();
assert_eq!(msg.id.get_state(alice).await?, MessageState::OutFailed);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_chat_media() -> Result<()> {
let t = TestContext::new_alice().await;
@@ -4157,7 +4115,7 @@ async fn test_past_members() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_non_member_cannot_modify_member_list() -> Result<()> {
async fn non_member_cannot_modify_member_list() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
@@ -4189,12 +4147,6 @@ async fn test_non_member_cannot_modify_member_list() -> Result<()> {
alice.recv_msg_trash(&bob_sent_add_msg).await;
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 1);
// The same for removal.
let bob_alice_contact_id = bob.add_or_lookup_contact_id(alice).await;
remove_contact_from_chat(bob, bob_chat_id, bob_alice_contact_id).await?;
let bob_sent_add_msg = bob.pop_sent_msg().await;
alice.recv_msg_trash(&bob_sent_add_msg).await;
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 1);
Ok(())
}
@@ -4568,6 +4520,17 @@ async fn test_cannot_send_edit_request() -> Result<()> {
.is_err()
);
// Videochat invitations cannot be edited
alice
.set_config(Config::WebrtcInstance, Some("https://foo.bar"))
.await?;
let msg_id = send_videochat_invitation(alice, chat_id).await?;
assert!(
send_edit_request(alice, msg_id, "bar".to_string())
.await
.is_err()
);
// If not text was given initally, there is nothing to edit
// (this also avoids complexity in UI element changes; focus is typos and rewordings)
let mut msg = Message::new(Viewtype::File);
@@ -4779,16 +4742,6 @@ async fn test_create_unencrypted_group_chat() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_group_invalid_name() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let chat_id = create_group_ex(alice, None, " ").await?;
let chat = Chat::load_from_db(alice, chat_id).await?;
assert_eq!(chat.get_name(), "");
Ok(())
}
/// Tests that avatar cannot be set in ad hoc groups.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_avatar_in_adhoc_chats() -> Result<()> {
@@ -4819,25 +4772,3 @@ async fn test_no_avatar_in_adhoc_chats() -> Result<()> {
Ok(())
}
/// Tests that long group name with non-ASCII characters is correctly received
/// by other members.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_long_group_name() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let group_name = "δδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδ";
let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, group_name).await?;
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
let sent = alice
.send_text(alice_chat_id, "Hi! I created a group.")
.await;
let bob_chat_id = bob.recv_msg(&sent).await.chat_id;
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
assert_eq!(bob_chat.name, group_name);
Ok(())
}

View File

@@ -245,6 +245,9 @@ impl Chatlist {
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
};
// Return ProtectionBroken chats also, as that may happen to a verified chat at any
// time. It may be confusing if a chat that is normally in the list disappears
// suddenly. The UI need to deal with that case anyway.
context.sql.query_map(
"SELECT c.id, c.type, c.param, m.id
FROM chats c
@@ -488,8 +491,6 @@ mod tests {
use crate::stock_str::StockMessage;
use crate::test_utils::TestContext;
use crate::test_utils::TestContextManager;
use crate::tools::SystemTime;
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_try_load() {
@@ -512,8 +513,6 @@ mod tests {
assert_eq!(chats.get_chat_id(1).unwrap(), chat_id2);
assert_eq!(chats.get_chat_id(2).unwrap(), chat_id1);
SystemTime::shift(Duration::from_secs(5));
// New drafts are sorted to the top
// We have to set a draft on the other two messages, too, as
// chat timestamps are only exact to the second and sorting by timestamp

View File

@@ -1,39 +1,38 @@
//! Color generation.
//! Implementation of Consistent Color Generation.
//!
//! This is similar to Consistent Color Generation defined in XEP-0392,
//! but uses OKLCh colorspace instead of HSLuv
//! to ensure that colors have the same lightness.
use colorutils_rs::{Oklch, Rgb, TransferFunction};
//! Consistent Color Generation is defined in XEP-0392.
//!
//! Color Vision Deficiency correction is not implemented as Delta Chat does not offer
//! corresponding settings.
use hsluv::hsluv_to_rgb;
use sha1::{Digest, Sha1};
/// Converts an identifier to Hue angle.
fn str_to_angle(s: &str) -> f32 {
fn str_to_angle(s: &str) -> f64 {
let bytes = s.as_bytes();
let result = Sha1::digest(bytes);
let checksum: u16 = result.first().map_or(0, |&x| u16::from(x))
+ 256 * result.get(1).map_or(0, |&x| u16::from(x));
f32::from(checksum) / 65536.0 * 360.0
f64::from(checksum) / 65536.0 * 360.0
}
/// Converts RGB tuple to a 24-bit number.
///
/// 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.
fn rgb_to_u32(rgb: Rgb<u8>) -> u32 {
65536 * u32::from(rgb.r) + 256 * u32::from(rgb.g) + u32::from(rgb.b)
fn rgb_to_u32((r, g, b): (f64, f64, f64)) -> u32 {
let r = ((r * 256.0) as u32).min(255);
let g = ((g * 256.0) as u32).min(255);
let b = ((b * 256.0) as u32).min(255);
65536 * r + 256 * g + b
}
/// Converts an identifier to RGB color.
///
/// Lightness is set to half (0.5) to make colors suitable both for light and dark theme.
/// Saturation is set to maximum (100.0) to make colors distinguishable, and lightness is set to
/// half (50.0) to make colors suitable both for light and dark theme.
pub fn str_to_color(s: &str) -> u32 {
let lightness = 0.5;
let chroma = 0.23;
let angle = str_to_angle(s);
let oklch = Oklch::new(lightness, chroma, angle);
let rgb = oklch.to_rgb(TransferFunction::Srgb);
rgb_to_u32(rgb)
rgb_to_u32(hsluv_to_rgb((str_to_angle(s), 100.0, 50.0)))
}
/// Returns color as a "#RRGGBB" `String` where R, G, B are hex digits.
@@ -46,7 +45,6 @@ mod tests {
use super::*;
#[test]
#[allow(clippy::excessive_precision)]
fn test_str_to_angle() {
// Test against test vectors from
// <https://xmpp.org/extensions/xep-0392.html#testvectors-fullrange-no-cvd>
@@ -59,11 +57,11 @@ mod tests {
#[test]
fn test_rgb_to_u32() {
assert_eq!(rgb_to_u32(Rgb::new(0, 0, 0)), 0);
assert_eq!(rgb_to_u32(Rgb::new(0xff, 0xff, 0xff)), 0xffffff);
assert_eq!(rgb_to_u32(Rgb::new(0, 0, 0xff)), 0x0000ff);
assert_eq!(rgb_to_u32(Rgb::new(0, 0xff, 0)), 0x00ff00);
assert_eq!(rgb_to_u32(Rgb::new(0xff, 0, 0)), 0xff0000);
assert_eq!(rgb_to_u32(Rgb::new(0xff, 0x80, 0)), 0xff8000);
assert_eq!(rgb_to_u32((0.0, 0.0, 0.0)), 0);
assert_eq!(rgb_to_u32((1.0, 1.0, 1.0)), 0xffffff);
assert_eq!(rgb_to_u32((0.0, 0.0, 1.0)), 0x0000ff);
assert_eq!(rgb_to_u32((0.0, 1.0, 0.0)), 0x00ff00);
assert_eq!(rgb_to_u32((1.0, 0.0, 0.0)), 0xff0000);
assert_eq!(rgb_to_u32((1.0, 0.5, 0.0)), 0xff8000);
}
}

View File

@@ -151,6 +151,10 @@ pub enum Config {
/// setting up a second device, or receiving a sync message.
BccSelf,
/// True if encryption is preferred according to Autocrypt standard.
#[strum(props(default = "1"))]
E2eeEnabled,
/// True if Message Delivery Notifications (read receipts) should
/// be sent and requested.
#[strum(props(default = "1"))]
@@ -346,6 +350,9 @@ pub enum Config {
/// Unset, when quota falls below minimal warning threshold again.
QuotaExceeding,
/// address to webrtc instance to use for videochats
WebrtcInstance,
/// Timestamp of the last time housekeeping was run
LastHousekeeping,
@@ -410,6 +417,16 @@ pub enum Config {
#[strum(props(default = "172800"))]
GossipPeriod,
/// Feature flag for verified 1:1 chats; the UI should set it
/// to 1 if it supports verified 1:1 chats.
/// Regardless of this setting, `chat.is_protected()` returns true while the key is verified,
/// and when the key changes, an info message is posted into the chat.
/// 0=Nothing else happens when the key changes.
/// 1=After the key changed, `can_send()` returns false and `is_protection_broken()` returns true
/// until `chat_id.accept()` is called.
#[strum(props(default = "0"))]
VerifiedOneOnOneChats,
/// Row ID of the key in the `keypairs` table
/// used for signatures, encryption to self and included in `Autocrypt` header.
KeyId,
@@ -437,9 +454,6 @@ pub enum Config {
/// to avoid encrypting it differently and
/// storing the same token multiple times on the server.
EncryptedDeviceToken,
/// Return an error from `receive_imf_inner()` for a fully downloaded message. For tests.
FailOnReceivingFullMsg,
}
impl Config {
@@ -691,6 +705,7 @@ impl Context {
Config::Socks5Enabled
| Config::ProxyEnabled
| Config::BccSelf
| Config::E2eeEnabled
| Config::MdnsEnabled
| Config::SentboxWatch
| Config::MvboxMove
@@ -719,7 +734,7 @@ impl Context {
Self::check_config(key, value)?;
let _pause = match key.needs_io_restart() {
true => self.scheduler.pause(self).await?,
true => self.scheduler.pause(self.clone()).await?,
_ => Default::default(),
};
self.set_config_internal(key, value).await?;

View File

@@ -137,7 +137,7 @@ impl Context {
let res = self
.inner_configure(param)
.race(cancel_channel.recv().map(|_| Err(format_err!("Canceled"))))
.race(cancel_channel.recv().map(|_| Err(format_err!("Cancelled"))))
.await;
self.free_ongoing().await;

View File

@@ -106,7 +106,7 @@ fn parse_server<B: BufRead>(
}
}
Event::Text(ref event) => {
let val = event.xml_content().unwrap_or_default().trim().to_owned();
let val = event.unescape().unwrap_or_default().trim().to_owned();
match tag_config {
MozConfigTag::Hostname => hostname = Some(val),

View File

@@ -79,7 +79,7 @@ fn parse_protocol<B: BufRead>(
}
}
Event::Text(ref e) => {
let val = e.xml_content().unwrap_or_default();
let val = e.unescape().unwrap_or_default();
if let Some(ref tag) = current_tag {
match tag.as_str() {
@@ -123,7 +123,7 @@ fn parse_redirecturl<B: BufRead>(
let mut buf = Vec::new();
match reader.read_event_into(&mut buf)? {
Event::Text(ref e) => {
let val = e.xml_content().unwrap_or_default();
let val = e.unescape().unwrap_or_default();
Ok(val.trim().to_string())
}
_ => Ok("".to_string()),

View File

@@ -60,6 +60,23 @@ pub enum MediaQuality {
Worse = 1,
}
/// Video chat URL type.
#[derive(
Debug, Default, Display, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive, FromSql, ToSql,
)]
#[repr(i8)]
pub enum VideochatType {
/// Unknown type.
#[default]
Unknown = 0,
/// [basicWebRTC](https://github.com/cracker0dks/basicwebrtc) instance.
BasicWebrtc = 1,
/// [Jitsi Meet](https://jitsi.org/jitsi-meet/) instance.
Jitsi = 2,
}
pub const DC_HANDSHAKE_CONTINUE_NORMAL_PROCESSING: i32 = 0x01;
pub const DC_HANDSHAKE_STOP_NORMAL_PROCESSING: i32 = 0x02;
pub const DC_HANDSHAKE_ADD_DELETE_JOB: i32 = 0x04;
@@ -78,11 +95,10 @@ pub const DC_GCL_ADDRESS: u32 = 0x04;
pub(crate) const DC_RESEND_USER_AVATAR_DAYS: i64 = 14;
// warn about an outdated app after a given number of days.
// reference is the release date.
// as not all system get speedy updates,
// as we use the "provider-db generation date" as reference (that might not be updated very often)
// and as not all system get speedy updates,
// do not use too small value that will annoy users checking for nonexistent updates.
// "90 days" has proven to be too short at some point (user were informed but there was no update)
pub(crate) const DC_OUTDATED_WARNING_DAYS: i64 = 183;
pub(crate) const DC_OUTDATED_WARNING_DAYS: i64 = 365;
/// messages that should be deleted get this chat_id; the messages are deleted from the working thread later then. This is also needed as rfc724_mid should be preset as long as the message is not deleted on the server (otherwise it is downloaded again)
pub const DC_CHAT_ID_TRASH: ChatId = ChatId::new(3);
@@ -291,4 +307,16 @@ mod tests {
assert_eq!(MediaQuality::Balanced, MediaQuality::from_i32(0).unwrap());
assert_eq!(MediaQuality::Worse, MediaQuality::from_i32(1).unwrap());
}
#[test]
fn test_videochattype_values() {
// values may be written to disk and must not change
assert_eq!(VideochatType::Unknown, VideochatType::default());
assert_eq!(VideochatType::Unknown, VideochatType::from_i32(0).unwrap());
assert_eq!(
VideochatType::BasicWebrtc,
VideochatType::from_i32(1).unwrap()
);
assert_eq!(VideochatType::Jitsi, VideochatType::from_i32(2).unwrap());
}
}

View File

@@ -21,7 +21,7 @@ use tokio::task;
use tokio::time::{Duration, timeout};
use crate::blob::BlobObject;
use crate::chat::ChatId;
use crate::chat::{ChatId, ChatIdBlocked, ProtectionStatus};
use crate::color::str_to_color;
use crate::config::Config;
use crate::constants::{self, Blocked, Chattype};
@@ -755,19 +755,7 @@ impl Contact {
self.is_bot
}
/// Looks up a known and unblocked contact with a given e-mail address.
/// To get a list of all known and unblocked contacts, use contacts_get_contacts().
///
///
/// **POTENTIAL SECURITY ISSUE**: If there are multiple contacts with this address
/// (e.g. an address-contact and a key-contact),
/// this looks up the most recently seen contact,
/// i.e. which contact is returned depends on which contact last sent a message.
/// If the user just clicked on a mailto: link, then this is the best thing you can do.
/// But **DO NOT** internally represent contacts by their email address
/// and do not use this function to look them up;
/// otherwise this function will sometimes look up the wrong contact.
/// Instead, you should internally represent contacts by their ids.
/// Check if an e-mail address belongs to a known and unblocked contact.
///
/// Known and unblocked contacts will be returned by `get_contacts()`.
///
@@ -807,28 +795,14 @@ impl Contact {
.query_get_value(
"SELECT id FROM contacts
WHERE addr=?1 COLLATE NOCASE
AND id>?2 AND origin>=?3 AND (? OR blocked=?)
ORDER BY
(
SELECT COUNT(*) FROM chats c
INNER JOIN chats_contacts cc
ON c.id=cc.chat_id
WHERE c.type=?
AND c.id>?
AND c.blocked=?
AND cc.contact_id=contacts.id
) DESC,
last_seen DESC, fingerprint DESC
LIMIT 1",
AND fingerprint='' -- Do not lookup key-contacts
AND id>?2 AND origin>=?3 AND (? OR blocked=?)",
(
&addr_normalized,
ContactId::LAST_SPECIAL,
min_origin as u32,
blocked.is_none(),
blocked.unwrap_or(Blocked::Not),
Chattype::Single,
constants::DC_CHAT_ID_LAST_SPECIAL,
blocked.unwrap_or(Blocked::Not),
blocked.unwrap_or_default(),
),
)
.await?;
@@ -1564,7 +1538,7 @@ impl Contact {
return Ok(Some(chat::get_device_icon(context).await?));
}
if show_fallback_icon && !self.id.is_special() && !self.is_key_contact() {
return Ok(Some(chat::get_unencrypted_icon(context).await?));
return Ok(Some(chat::get_address_contact_icon(context).await?));
}
if let Some(image_rel) = self.param.get(Param::ProfileImage) {
if !image_rel.is_empty() {
@@ -1575,18 +1549,11 @@ impl Contact {
}
/// Get a color for the contact.
/// The color is calculated from the contact's fingerprint (for key-contacts)
/// or email address (for address-contacts) and can be used
/// for an fallback avatar with white initials
/// The color is calculated from the contact's email address
/// and can be used for an fallback avatar with white initials
/// as well as for headlines in bubbles of group chats.
pub fn get_color(&self) -> u32 {
if let Some(fingerprint) = self.fingerprint() {
str_to_color(&fingerprint.hex())
} else if self.id == ContactId::SELF {
0x808080
} else {
str_to_color(&self.addr.to_lowercase())
}
str_to_color(&self.addr.to_lowercase())
}
/// Gets the contact's status.
@@ -1652,6 +1619,29 @@ impl Contact {
}
}
/// Returns if the contact profile title should display a green checkmark.
///
/// This generally should be consistent with the 1:1 chat with the contact
/// so 1:1 chat with the contact and the contact profile
/// either both display the green checkmark or both don't display a green checkmark.
///
/// UI often knows beforehand if a chat exists and can also call
/// `chat.is_protected()` (if there is a chat)
/// or `contact.is_verified()` (if there is no chat) directly.
/// This is often easier and also skips some database calls.
pub async fn is_profile_verified(&self, context: &Context) -> Result<bool> {
let contact_id = self.id;
if let Some(ChatIdBlocked { id: chat_id, .. }) =
ChatIdBlocked::lookup_by_contact(context, contact_id).await?
{
Ok(chat_id.is_protected(context).await? == ProtectionStatus::Protected)
} else {
// 1:1 chat does not exist.
Ok(self.is_verified(context).await?)
}
}
/// Returns the number of real (i.e. non-special) contacts in the database.
pub async fn get_real_cnt(context: &Context) -> Result<usize> {
if !context.sql.is_open().await {
@@ -1744,7 +1734,8 @@ pub(crate) async fn set_blocked(
) -> Result<()> {
ensure!(
!contact_id.is_special(),
"Can't block special contact {contact_id}"
"Can't block special contact {}",
contact_id
);
let contact = Contact::get_by_id(context, contact_id).await?;
@@ -1926,21 +1917,16 @@ pub(crate) async fn update_last_seen(
}
/// Marks contact `contact_id` as verified by `verifier_id`.
///
/// `verifier_id == None` means that the verifier is unknown.
pub(crate) async fn mark_contact_id_as_verified(
context: &Context,
contact_id: ContactId,
verifier_id: Option<ContactId>,
verifier_id: ContactId,
) -> Result<()> {
ensure_and_debug_assert_ne!(contact_id, ContactId::SELF,);
ensure_and_debug_assert_ne!(
Some(contact_id),
contact_id,
verifier_id,
"Contact cannot be verified by self",
);
let by_self = verifier_id == Some(ContactId::SELF);
let mut verifier_id = verifier_id.unwrap_or(contact_id);
context
.sql
.transaction(|transaction| {
@@ -1953,33 +1939,20 @@ pub(crate) async fn mark_contact_id_as_verified(
bail!("Non-key-contact {contact_id} cannot be verified");
}
if verifier_id != ContactId::SELF {
let (verifier_fingerprint, verifier_verifier_id): (String, ContactId) = transaction
.query_row(
"SELECT fingerprint, verifier FROM contacts WHERE id=?",
(verifier_id,),
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
let verifier_fingerprint: String = transaction.query_row(
"SELECT fingerprint FROM contacts WHERE id=?",
(verifier_id,),
|row| row.get(0),
)?;
if verifier_fingerprint.is_empty() {
bail!(
"Contact {contact_id} cannot be verified by non-key-contact {verifier_id}"
);
}
ensure!(
verifier_id == contact_id || verifier_verifier_id != ContactId::UNDEFINED,
"Contact {contact_id} cannot be verified by unverified contact {verifier_id}",
);
if verifier_verifier_id == verifier_id {
// Avoid introducing incorrect reverse chains: if the verifier itself has an
// unknown verifier, it may be `contact_id` actually (directly or indirectly) on
// the other device (which is needed for getting "verified by unknown contact"
// in the first place).
verifier_id = contact_id;
}
}
transaction.execute(
"UPDATE contacts SET verifier=?1
WHERE id=?2 AND (verifier=0 OR verifier=id OR ?3)",
(verifier_id, contact_id, by_self),
"UPDATE contacts SET verifier=? WHERE id=?",
(verifier_id, contact_id),
)?;
Ok(())
})

View File

@@ -1,10 +1,9 @@
use deltachat_contact_tools::{addr_cmp, may_be_valid_addr};
use super::*;
use crate::chat::{Chat, ProtectionStatus, get_chat_contacts, send_text_msg};
use crate::chat::{Chat, get_chat_contacts, send_text_msg};
use crate::chatlist::Chatlist;
use crate::receive_imf::receive_imf;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote};
#[test]
@@ -760,7 +759,7 @@ async fn test_contact_get_color() -> Result<()> {
let t = TestContext::new().await;
let contact_id = Contact::create(&t, "name", "name@example.net").await?;
let color1 = Contact::get_by_id(&t, contact_id).await?.get_color();
assert_eq!(color1, 0x4844e2);
assert_eq!(color1, 0xA739FF);
let t = TestContext::new().await;
let contact_id = Contact::create(&t, "prename name", "name@example.net").await?;
@@ -774,20 +773,6 @@ async fn test_contact_get_color() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_self_color_vs_key() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.unconfigured().await;
t.configure_addr("alice@example.org").await;
assert!(t.is_configured().await?);
let color = Contact::get_by_id(t, ContactId::SELF).await?.get_color();
assert_eq!(color, 0x808080);
get_securejoin_qr(t, None).await?;
let color1 = Contact::get_by_id(t, ContactId::SELF).await?.get_color();
assert_ne!(color1, color);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_contact_get_encrinfo() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -1050,50 +1035,6 @@ async fn test_was_seen_recently_event() -> Result<()> {
Ok(())
}
async fn test_lookup_id_by_addr_recent_ex(accept_unencrypted_chat: bool) -> Result<()> {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
let raw = include_bytes!("../../test-data/message/thunderbird_with_autocrypt.eml");
assert!(std::str::from_utf8(raw)?.contains("Date: Thu, 24 Nov 2022 20:05:57 +0100"));
let received_msg = receive_imf(bob, raw, false).await?.unwrap();
received_msg.chat_id.accept(bob).await?;
let raw = r#"From: Alice <alice@example.org>
To: bob@example.net
Message-ID: message$TIME@example.org
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
Date: Thu, 24 Nov 2022 $TIME +0100
Hi"#
.to_string();
for (time, is_key_contact) in [("20:05:57", true), ("20:05:58", !accept_unencrypted_chat)] {
let raw = raw.replace("$TIME", time);
let received_msg = receive_imf(bob, raw.as_bytes(), false).await?.unwrap();
if accept_unencrypted_chat {
received_msg.chat_id.accept(bob).await?;
}
let contact_id = Contact::lookup_id_by_addr(bob, "alice@example.org", Origin::Unknown)
.await?
.unwrap();
let contact = Contact::get_by_id(bob, contact_id).await?;
assert_eq!(contact.is_key_contact(), is_key_contact);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lookup_id_by_addr_recent() -> Result<()> {
let accept_unencrypted_chat = true;
test_lookup_id_by_addr_recent_ex(accept_unencrypted_chat).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lookup_id_by_addr_recent_accepted() -> Result<()> {
let accept_unencrypted_chat = false;
test_lookup_id_by_addr_recent_ex(accept_unencrypted_chat).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_verified_by_none() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -1131,7 +1072,6 @@ async fn test_sync_create() -> Result<()> {
.unwrap();
let a1b_contact = Contact::get_by_id(alice1, a1b_contact_id).await?;
assert_eq!(a1b_contact.name, "Bob");
assert_eq!(a1b_contact.is_key_contact(), false);
Contact::create(alice0, "Bob Renamed", "bob@example.net").await?;
test_utils::sync(alice0, alice1).await;
@@ -1141,7 +1081,6 @@ async fn test_sync_create() -> Result<()> {
assert_eq!(id, a1b_contact_id);
let a1b_contact = Contact::get_by_id(alice1, a1b_contact_id).await?;
assert_eq!(a1b_contact.name, "Bob Renamed");
assert_eq!(a1b_contact.is_key_contact(), false);
Ok(())
}
@@ -1317,6 +1256,7 @@ async fn test_self_is_verified() -> Result<()> {
let contact = Contact::get_by_id(&alice, ContactId::SELF).await?;
assert_eq!(contact.is_verified(&alice).await?, true);
assert!(contact.is_profile_verified(&alice).await?);
assert!(contact.get_verifier_id(&alice).await?.is_none());
assert!(contact.is_key_contact());

View File

@@ -34,7 +34,7 @@ use crate::param::{Param, Params};
use crate::peer_channels::Iroh;
use crate::push::PushSubscriber;
use crate::quota::QuotaInfo;
use crate::scheduler::{ConnectivityStore, SchedulerState, convert_folder_meaning};
use crate::scheduler::{SchedulerState, convert_folder_meaning};
use crate::sql::Sql;
use crate::stock_str::StockStrings;
use crate::timesmearing::SmearedTimestamp;
@@ -304,10 +304,6 @@ pub struct InnerContext {
/// tokio::sync::OnceCell would be possible to use, but overkill for our usecase;
/// the standard library's OnceLock is enough, and it's a lot smaller in memory.
pub(crate) self_fingerprint: OnceLock<String>,
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
/// see [`Context::get_connectivity()`].
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
}
/// The state of ongoing process.
@@ -337,15 +333,6 @@ impl Default for RunningState {
/// about the context on top of the information here.
pub fn get_info() -> BTreeMap<&'static str, String> {
let mut res = BTreeMap::new();
#[cfg(debug_assertions)]
res.insert(
"debug_assertions",
"On - DO NOT RELEASE THIS BUILD".to_string(),
);
#[cfg(not(debug_assertions))]
res.insert("debug_assertions", "Off".to_string());
res.insert("deltachat_core_version", format!("v{}", &*DC_VERSION_STR));
res.insert("sqlite_version", rusqlite::version().to_string());
res.insert("arch", (std::mem::size_of::<usize>() * 8).to_string());
@@ -477,7 +464,6 @@ impl Context {
push_subscribed: AtomicBool::new(false),
iroh: Arc::new(RwLock::new(None)),
self_fingerprint: OnceLock::new(),
connectivities: parking_lot::Mutex::new(Vec::new()),
};
let ctx = Context {
@@ -507,7 +493,7 @@ impl Context {
// Now, some configs may have changed, so, we need to invalidate the cache.
self.sql.config_cache.write().await.clear();
self.scheduler.start(self).await;
self.scheduler.start(self.clone()).await;
}
/// Stops the IO scheduler.
@@ -584,7 +570,7 @@ impl Context {
} else {
// Pause the scheduler to ensure another connection does not start
// while we are fetching on a dedicated connection.
let _pause_guard = self.scheduler.pause(self).await?;
let _pause_guard = self.scheduler.pause(self.clone()).await?;
// Start a new dedicated connection.
let mut connection = Imap::new_configured(self, channel::bounded(1).1).await?;
@@ -833,6 +819,7 @@ impl Context {
.query_get_value("PRAGMA journal_mode;", ())
.await?
.unwrap_or_else(|| "unknown".to_string());
let e2ee_enabled = self.get_config_int(Config::E2eeEnabled).await?;
let mdns_enabled = self.get_config_int(Config::MdnsEnabled).await?;
let bcc_self = self.get_config_int(Config::BccSelf).await?;
let sync_msgs = self.get_config_int(Config::SyncMsgs).await?;
@@ -966,12 +953,19 @@ impl Context {
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("e2ee_enabled", e2ee_enabled.to_string());
res.insert("bcc_self", bcc_self.to_string());
res.insert("sync_msgs", sync_msgs.to_string());
res.insert("disable_idle", disable_idle.to_string());
res.insert("private_key_count", prv_key_cnt.to_string());
res.insert("public_key_count", pub_key_cnt.to_string());
res.insert("fingerprint", fingerprint_str);
res.insert(
"webrtc_instance",
self.get_config(Config::WebrtcInstance)
.await?
.unwrap_or_else(|| "<unset>".to_string()),
);
res.insert(
"media_quality",
self.get_config_int(Config::MediaQuality).await?.to_string(),
@@ -1048,6 +1042,12 @@ impl Context {
"gossip_period",
self.get_config_int(Config::GossipPeriod).await?.to_string(),
);
res.insert(
"verified_one_on_one_chats",
self.get_config_bool(Config::VerifiedOneOnOneChats)
.await?
.to_string(),
);
res.insert(
"webxdc_realtime_enabled",
self.get_config_bool(Config::WebxdcRealtimeEnabled)
@@ -1067,13 +1067,6 @@ impl Context {
.await?
.unwrap_or_default(),
);
res.insert(
"fail_on_receiving_full_msg",
self.sql
.get_raw_config("fail_on_receiving_full_msg")
.await?
.unwrap_or_default(),
);
let elapsed = time_elapsed(&self.creation_time);
res.insert("uptime", duration_to_str(elapsed));
@@ -1085,6 +1078,7 @@ impl Context {
#[derive(Default)]
struct ChatNumbers {
protected: u32,
protection_broken: u32,
opportunistic_dc: u32,
opportunistic_mua: u32,
unencrypted_dc: u32,
@@ -1120,6 +1114,7 @@ impl Context {
// how many of the chats active in the last months are:
// - protected
// - protection-broken
// - opportunistic-encrypted and the contact uses Delta Chat
// - opportunistic-encrypted and the contact uses a classical MUA
// - unencrypted and the contact uses Delta Chat
@@ -1162,6 +1157,8 @@ impl Context {
if protected == ProtectionStatus::Protected {
chats.protected += 1;
} else if protected == ProtectionStatus::ProtectionBroken {
chats.protection_broken += 1;
} else if encrypted {
if is_dc_message {
chats.opportunistic_dc += 1;
@@ -1179,6 +1176,7 @@ impl Context {
)
.await?;
res += &format!("chats_protected {}\n", chats.protected);
res += &format!("chats_protection_broken {}\n", chats.protection_broken);
res += &format!("chats_opportunistic_dc {}\n", chats.opportunistic_dc);
res += &format!("chats_opportunistic_mua {}\n", chats.opportunistic_mua);
res += &format!("chats_unencrypted_dc {}\n", chats.unencrypted_dc);
@@ -1208,7 +1206,7 @@ impl Context {
.await?
.first()
.context("Self reporting bot vCard does not contain a contact")?;
mark_contact_id_as_verified(self, contact_id, Some(ContactId::SELF)).await?;
mark_contact_id_as_verified(self, contact_id, ContactId::SELF).await?;
let chat_id = ChatId::create_for_contact(self, contact_id).await?;
chat_id

View File

@@ -7,7 +7,6 @@ use std::sync::LazyLock;
use quick_xml::{
Reader,
errors::Error as QuickXmlError,
events::{BytesEnd, BytesStart, BytesText},
};
@@ -133,7 +132,6 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
reader.config_mut().check_end_names = false;
let mut buf = Vec::new();
let mut char_buf = String::with_capacity(4);
loop {
match reader.read_event_into(&mut buf) {
@@ -142,9 +140,16 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
}
Ok(quick_xml::events::Event::End(ref e)) => dehtml_endtag_cb(e, &mut dehtml),
Ok(quick_xml::events::Event::Text(ref e)) => dehtml_text_cb(e, &mut dehtml),
Ok(quick_xml::events::Event::CData(e)) => {
str_cb(&String::from_utf8_lossy(&e as &[_]), &mut dehtml)
}
Ok(quick_xml::events::Event::CData(e)) => match e.escape() {
Ok(e) => dehtml_text_cb(&e, &mut dehtml),
Err(e) => {
eprintln!(
"CDATA escape error at position {}: {:?}",
reader.buffer_position(),
e,
);
}
},
Ok(quick_xml::events::Event::Empty(ref e)) => {
// Handle empty tags as a start tag immediately followed by end tag.
// For example, `<p/>` is treated as `<p></p>`.
@@ -154,33 +159,6 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
&mut dehtml,
);
}
Ok(quick_xml::events::Event::GeneralRef(ref e)) => {
match e.resolve_char_ref() {
Err(err) => eprintln!(
"resolve_char_ref() error at position {}: {:?}",
reader.buffer_position(),
err,
),
Ok(Some(ch)) => {
char_buf.clear();
char_buf.push(ch);
str_cb(&char_buf, &mut dehtml);
}
Ok(None) => {
let event_str = String::from_utf8_lossy(e);
if let Some(s) = quick_xml::escape::resolve_html5_entity(&event_str) {
str_cb(s, &mut dehtml);
} else {
// Nonstandard entity. Add escaped.
str_cb(&format!("&{event_str};"), &mut dehtml);
}
}
}
}
Err(QuickXmlError::IllFormed(_)) => {
// This is probably not HTML at all and should be left as is.
str_cb(&String::from_utf8_lossy(&buf), &mut dehtml);
}
Err(e) => {
eprintln!(
"Parse html error: Error at position {}: {:?}",
@@ -198,36 +176,36 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) {
}
fn dehtml_text_cb(event: &BytesText, dehtml: &mut Dehtml) {
static LINE_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(\r?\n)+").unwrap());
if dehtml.get_add_text() == AddText::YesPreserveLineEnds
|| dehtml.get_add_text() == AddText::YesRemoveLineEnds
{
let event = event as &[_];
let event_str = std::str::from_utf8(event).unwrap_or_default();
str_cb(event_str, dehtml);
}
}
fn str_cb(event_str: &str, dehtml: &mut Dehtml) {
static LINE_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(\r?\n)+").unwrap());
let add_text = dehtml.get_add_text();
if add_text == AddText::YesRemoveLineEnds {
// Replace all line ends with spaces.
// E.g. `\r\n\r\n` is replaced with one space.
let event_str = LINE_RE.replace_all(event_str, " ");
// Add a space if `event_str` starts with a space
// and there is no whitespace at the end of the buffer yet.
// Trim the rest of leading whitespace from `event_str`.
let buf = dehtml.get_buf();
if !buf.ends_with(' ') && !buf.ends_with('\n') && event_str.starts_with(' ') {
*buf += " ";
let mut last_added = escaper::decode_html_buf_sloppy(event).unwrap_or_default();
if event_str.starts_with(&last_added) {
last_added = event_str.to_string();
}
*buf += event_str.trim_start();
} else if add_text == AddText::YesPreserveLineEnds {
*dehtml.get_buf() += LINE_RE.replace_all(event_str, "\n").as_ref();
if dehtml.get_add_text() == AddText::YesRemoveLineEnds {
// Replace all line ends with spaces.
// E.g. `\r\n\r\n` is replaced with one space.
let last_added = LINE_RE.replace_all(&last_added, " ");
// Add a space if `last_added` starts with a space
// and there is no whitespace at the end of the buffer yet.
// Trim the rest of leading whitespace from `last_added`.
let buf = dehtml.get_buf();
if !buf.ends_with(' ') && !buf.ends_with('\n') && last_added.starts_with(' ') {
*buf += " ";
}
*buf += last_added.trim_start();
} else {
*dehtml.get_buf() += LINE_RE.replace_all(&last_added, "\n").as_ref();
}
}
}

View File

@@ -238,20 +238,14 @@ impl MimeMessage {
/// the mime-structure itself is not available.
///
/// The placeholder part currently contains a text with size and availability of the message;
/// `error` is set as the part error;
/// in the future, we may do more advanced things as previews here.
pub(crate) async fn create_stub_from_partial_download(
&mut self,
context: &Context,
org_bytes: u32,
error: Option<String>,
) -> Result<()> {
let prefix = match error {
None => "",
Some(_) => "[❗] ",
};
let mut text = format!(
"{prefix}[{}]",
"[{}]",
stock_str::partial_download_msg_body(context, org_bytes).await
);
if let Some(delete_server_after) = context.get_config_delete_server_after().await? {
@@ -265,10 +259,9 @@ impl MimeMessage {
info!(context, "Partial download: {}", text);
self.do_add_single_part(Part {
self.parts.push(Part {
typ: Viewtype::Text,
msg: text,
error,
..Default::default()
});
@@ -283,9 +276,8 @@ mod tests {
use super::*;
use crate::chat::{get_chat_msgs, send_msg};
use crate::ephemeral::Timer;
use crate::message::delete_msgs;
use crate::receive_imf::receive_imf_from_inbox;
use crate::test_utils::{E2EE_INFO_MSGS, TestContext, TestContextManager};
use crate::test_utils::{E2EE_INFO_MSGS, TestContext};
#[test]
fn test_downloadstate_values() {
@@ -544,43 +536,4 @@ mod tests {
Ok(())
}
/// Tests that fully downloading the message
/// works even if the Message-ID already exists
/// in the database assigned to the trash chat.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_partial_download_trashed() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let imf_raw = b"From: Bob <bob@example.org>\n\
To: Alice <alice@example.org>\n\
Chat-Version: 1.0\n\
Subject: subject\n\
Message-ID: <first@example.org>\n\
Date: Sun, 14 Nov 2021 00:10:00 +0000\
Content-Type: text/plain";
// Download message from Bob partially.
let partial_received_msg =
receive_imf_from_inbox(alice, "first@example.org", imf_raw, false, Some(100000))
.await?
.unwrap();
assert_eq!(partial_received_msg.msg_ids.len(), 1);
// Delete the received message.
// Not it is still in the database,
// but in the trash chat.
delete_msgs(alice, &[partial_received_msg.msg_ids[0]]).await?;
// Fully download message after deletion.
let full_received_msg =
receive_imf_from_inbox(alice, "first@example.org", imf_raw, false, None).await?;
// The message does not reappear.
// However, `receive_imf` should not fail.
assert!(full_received_msg.is_none());
Ok(())
}
}

View File

@@ -4,8 +4,10 @@ use std::io::Cursor;
use anyhow::Result;
use mail_builder::mime::MimePart;
use num_traits::FromPrimitive;
use crate::aheader::{Aheader, EncryptPreference};
use crate::config::Config;
use crate::context::Context;
use crate::key::{SignedPublicKey, load_self_public_key, load_self_secret_key};
use crate::pgp;
@@ -19,7 +21,9 @@ pub struct EncryptHelper {
impl EncryptHelper {
pub async fn new(context: &Context) -> Result<EncryptHelper> {
let prefer_encrypt = EncryptPreference::Mutual;
let prefer_encrypt =
EncryptPreference::from_i32(context.get_config_int(Config::E2eeEnabled).await?)
.unwrap_or_default();
let addr = context.get_primary_self_addr().await?;
let public_key = load_self_public_key(context).await?;
@@ -31,12 +35,9 @@ impl EncryptHelper {
}
pub fn get_aheader(&self) -> Aheader {
Aheader {
addr: self.addr.clone(),
public_key: self.public_key.clone(),
prefer_encrypt: self.prefer_encrypt,
verified: false,
}
let pk = self.public_key.clone();
let addr = self.addr.to_string();
Aheader::new(addr, pk, self.prefer_encrypt)
}
/// Tries to encrypt the passed in `mail`.

View File

@@ -277,7 +277,6 @@ pub(crate) async fn stock_ephemeral_timer_changed(
.await
}
604_800 => stock_str::msg_ephemeral_timer_week(context, from_id).await,
31_536_000..=31_708_800 => stock_str::msg_ephemeral_timer_year(context, from_id).await,
_ => {
stock_str::msg_ephemeral_timer_weeks(
context,
@@ -376,10 +375,6 @@ pub(crate) async fn start_chat_ephemeral_timers(context: &Context, chat_id: Chat
/// `delete_device_after` setting or `ephemeral_timestamp` column.
///
/// For each message a row ID, chat id, viewtype and location ID is returned.
///
/// Unknown viewtypes are returned as `Viewtype::Unknown`
/// and not as errors bubbled up, easily resulting in infinite loop or leaving messages undeleted.
/// (Happens when viewtypes are removed or added on another device which was backup/add-second-device source)
async fn select_expired_messages(
context: &Context,
now: i64,
@@ -399,11 +394,7 @@ WHERE
|row| {
let id: MsgId = row.get("id")?;
let chat_id: ChatId = row.get("chat_id")?;
let viewtype: Viewtype = row
.get("type")
.context("Using default viewtype for ephemeral handling.")
.log_err(context)
.unwrap_or_default();
let viewtype: Viewtype = row.get("type")?;
let location_id: u32 = row.get("location_id")?;
Ok((id, chat_id, viewtype, location_id))
},
@@ -445,11 +436,7 @@ WHERE
|row| {
let id: MsgId = row.get("id")?;
let chat_id: ChatId = row.get("chat_id")?;
let viewtype: Viewtype = row
.get("type")
.context("Using default viewtype for delete-old handling.")
.log_err(context)
.unwrap_or_default();
let viewtype: Viewtype = row.get("type")?;
let location_id: u32 = row.get("location_id")?;
Ok((id, chat_id, viewtype, location_id))
},

View File

@@ -128,33 +128,31 @@ async fn test_stock_ephemeral_messages() {
/// Test enabling and disabling ephemeral timer remotely.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_enable_disable() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat_alice = alice.create_chat(bob).await.id;
let chat_bob = bob.create_chat(alice).await.id;
let chat_alice = alice.create_chat(&bob).await.id;
let chat_bob = bob.create_chat(&alice).await.id;
chat_alice
.set_ephemeral_timer(alice, Timer::Enabled { duration: 60 })
.set_ephemeral_timer(&alice.ctx, Timer::Enabled { duration: 60 })
.await?;
let sent = alice.pop_sent_msg().await;
let bob_received_message = bob.recv_msg(&sent).await;
bob.recv_msg(&sent).await;
assert_eq!(
bob_received_message.text,
"Message deletion timer is set to 1 minute by alice@example.org."
);
assert_eq!(
chat_bob.get_ephemeral_timer(bob).await?,
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Enabled { duration: 60 }
);
chat_alice
.set_ephemeral_timer(alice, Timer::Disabled)
.set_ephemeral_timer(&alice.ctx, Timer::Disabled)
.await?;
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
assert_eq!(chat_bob.get_ephemeral_timer(bob).await?, Timer::Disabled);
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Disabled
);
Ok(())
}

View File

@@ -5,7 +5,6 @@ use std::path::PathBuf;
use crate::chat::ChatId;
use crate::config::Config;
use crate::constants::Chattype;
use crate::contact::ContactId;
use crate::ephemeral::Timer as EphemeralTimer;
use crate::message::MsgId;
@@ -273,13 +272,11 @@ pub enum EventType {
/// ID of the contact that wants to join.
contact_id: ContactId,
/// ID of the chat in case of success.
chat_id: ChatId,
/// The type of the joined chat.
chat_type: Chattype,
/// Progress, always 1000.
/// Progress as:
/// 300=vg-/vc-request received, typically shown as "bob@addr joins".
/// 600=vg-/vc-request-with-auth received, vg-member-added/vc-contact-confirm sent, typically shown as "bob@addr verified".
/// 800=contact added to chat, shown as "bob@addr securely joined GROUP". Only for the verified-group-protocol.
/// 1000=Protocol finished for this contact.
progress: usize,
},
@@ -379,44 +376,6 @@ pub enum EventType {
/// This event is emitted from the account whose property changed.
AccountsItemChanged,
/// Incoming call.
IncomingCall {
/// ID of the message referring to the call.
msg_id: MsgId,
/// ID of the chat which the message belongs to.
chat_id: ChatId,
/// User-defined info as passed to place_outgoing_call()
place_call_info: String,
/// True if incoming call is a video call.
has_video: bool,
},
/// Incoming call accepted.
IncomingCallAccepted {
/// ID of the message referring to the call.
msg_id: MsgId,
/// ID of the chat which the message belongs to.
chat_id: ChatId,
},
/// Outgoing call accepted.
OutgoingCallAccepted {
/// ID of the message referring to the call.
msg_id: MsgId,
/// ID of the chat which the message belongs to.
chat_id: ChatId,
/// User-defined info as passed to accept_incoming_call()
accept_call_info: String,
},
/// Call ended.
CallEnded {
/// ID of the message referring to the call.
msg_id: MsgId,
/// ID of the chat which the message belongs to.
chat_id: ChatId,
},
/// Event for using in tests, e.g. as a fence between normally generated events.
#[cfg(test)]
Test,

View File

@@ -86,7 +86,6 @@ pub enum HeaderDef {
ChatDispositionNotificationTo,
ChatWebrtcRoom,
ChatWebrtcAccepted,
/// This message deletes the messages listed in the value by rfc724_mid.
ChatDelete,
@@ -119,11 +118,6 @@ pub enum HeaderDef {
AuthenticationResults,
/// Node address from iroh where direct addresses have been removed.
///
/// The node address sent in this header must have
/// a non-null relay URL as contacting home relay
/// is the only way to reach the node without
/// direct addresses and global discovery.
IrohNodeAddr,
/// Advertised gossip topic for one webxdc.

View File

@@ -17,14 +17,13 @@ use anyhow::{Context as _, Result, bail, ensure, format_err};
use async_channel::{self, Receiver, Sender};
use async_imap::types::{Fetch, Flag, Name, NameAttribute, UnsolicitedResponse};
use deltachat_contact_tools::ContactAddress;
use futures::{FutureExt as _, TryStreamExt};
use futures::{FutureExt as _, StreamExt, TryStreamExt};
use futures_lite::FutureExt;
use num_traits::FromPrimitive;
use rand::Rng;
use ratelimit::Ratelimit;
use url::Url;
use crate::calls::{create_fallback_ice_servers, create_ice_servers_from_metadata};
use crate::chat::{self, ChatId, ChatIdBlocked};
use crate::chatlist_events;
use crate::config::Config;
@@ -48,7 +47,7 @@ use crate::receive_imf::{
};
use crate::scheduler::connectivity::ConnectivityStore;
use crate::stock_str;
use crate::tools::{self, create_id, duration_to_str, time};
use crate::tools::{self, create_id, duration_to_str};
pub(crate) mod capabilities;
mod client;
@@ -124,18 +123,6 @@ pub(crate) struct ServerMetadata {
pub admin: Option<String>,
pub iroh_relay: Option<Url>,
/// JSON with ICE servers for WebRTC calls
/// and the expiration timestamp.
///
/// If JSON is about to expire, new TURN credentials
/// should be fetched from the server
/// to be ready for WebRTC calls.
pub ice_servers: String,
/// Timestamp when ICE servers are considered
/// expired and should be updated.
pub ice_servers_expiration_timestamp: i64,
}
impl async_imap::Authenticator for OAuth2 {
@@ -159,6 +146,7 @@ pub enum FolderMeaning {
Mvbox,
Sent,
Trash,
Drafts,
/// Virtual folders.
///
@@ -178,6 +166,7 @@ impl FolderMeaning {
FolderMeaning::Mvbox => Some(Config::ConfiguredMvboxFolder),
FolderMeaning::Sent => Some(Config::ConfiguredSentboxFolder),
FolderMeaning::Trash => Some(Config::ConfiguredTrashFolder),
FolderMeaning::Drafts => None,
FolderMeaning::Virtual => None,
}
}
@@ -336,7 +325,7 @@ impl Imap {
}
info!(context, "Connecting to IMAP server.");
self.connectivity.set_connecting(context);
self.connectivity.set_connecting(context).await;
self.conn_last_try = tools::Time::now();
const BACKOFF_MIN_MS: u64 = 2000;
@@ -419,7 +408,7 @@ impl Imap {
"IMAP-LOGIN as {}",
lp.user
)));
self.connectivity.set_preparing(context);
self.connectivity.set_preparing(context).await;
info!(context, "Successfully logged into IMAP server.");
return Ok(session);
}
@@ -477,7 +466,7 @@ impl Imap {
let mut session = match self.connect(context, configuring).await {
Ok(session) => session,
Err(err) => {
self.connectivity.set_err(context, &err);
self.connectivity.set_err(context, &err).await;
return Err(err);
}
};
@@ -566,38 +555,10 @@ impl Imap {
}
session.new_mail = false;
let mut read_cnt = 0;
loop {
let (n, fetch_more) = self
.fetch_new_msg_batch(context, session, folder, folder_meaning)
.await?;
read_cnt += n;
if !fetch_more {
return Ok(read_cnt > 0);
}
}
}
/// Returns number of messages processed and whether the function should be called again.
async fn fetch_new_msg_batch(
&mut self,
context: &Context,
session: &mut Session,
folder: &str,
folder_meaning: FolderMeaning,
) -> Result<(usize, bool)> {
let uid_validity = get_uidvalidity(context, folder).await?;
let old_uid_next = get_uid_next(context, folder).await?;
info!(
context,
"fetch_new_msg_batch({folder}): UIDVALIDITY={uid_validity}, UIDNEXT={old_uid_next}."
);
let uids_to_prefetch = 500;
let msgs = session
.prefetch(old_uid_next, uids_to_prefetch)
.await
.context("prefetch")?;
let msgs = session.prefetch(old_uid_next).await.context("prefetch")?;
let read_cnt = msgs.len();
let download_limit = context.download_limit().await?;
@@ -731,7 +692,7 @@ impl Imap {
}
if !uids_fetch.is_empty() {
self.connectivity.set_working(context);
self.connectivity.set_working(context).await;
}
let (sender, receiver) = async_channel::unbounded();
@@ -757,8 +718,7 @@ impl Imap {
largest_uid_fetched
};
let actually_download_messages_future = async {
let sender = sender;
let actually_download_messages_future = async move {
let mut uids_fetch_in_batch = Vec::with_capacity(max(uids_fetch.len(), 1));
let mut fetch_partially = false;
uids_fetch.push((0, !uids_fetch.last().unwrap_or(&(0, false)).1));
@@ -793,17 +753,14 @@ impl Imap {
// if the message has arrived after selecting mailbox
// and determining its UIDNEXT and before prefetch.
let mut new_uid_next = largest_uid_fetched + 1;
let fetch_more = fetch_res.is_ok() && {
let prefetch_uid_next = old_uid_next + uids_to_prefetch;
if fetch_res.is_ok() {
// If we have successfully fetched all messages we planned during prefetch,
// then we have covered at least the range between old UIDNEXT
// and UIDNEXT of the mailbox at the time of selecting it.
new_uid_next = max(new_uid_next, min(prefetch_uid_next, mailbox_uid_next));
new_uid_next = max(new_uid_next, mailbox_uid_next);
new_uid_next = max(new_uid_next, largest_uid_skipped.unwrap_or(0) + 1);
prefetch_uid_next < mailbox_uid_next
};
}
if new_uid_next > old_uid_next {
set_uid_next(context, folder, new_uid_next).await?;
}
@@ -820,7 +777,7 @@ impl Imap {
// establish a new session if this one is broken.
fetch_res?;
Ok((read_cnt, fetch_more))
Ok(read_cnt > 0)
}
/// Read the recipients from old emails sent by the user and add them as contacts.
@@ -857,10 +814,7 @@ impl Session {
.context("listing folders for resync")?;
for folder in all_folders {
let folder_meaning = get_folder_meaning(&folder);
if !matches!(
folder_meaning,
FolderMeaning::Virtual | FolderMeaning::Unknown
) {
if folder_meaning != FolderMeaning::Virtual {
self.resync_folder_uids(context, folder.name(), folder_meaning)
.await?;
}
@@ -1430,15 +1384,14 @@ impl Session {
// Try to find a requested UID in returned FETCH responses.
while fetch_response.is_none() {
let Some(next_fetch_response) = fetch_responses
.try_next()
.await
.context("Failed to process IMAP FETCH result")?
else {
let Some(next_fetch_response) = fetch_responses.next().await else {
// No more FETCH responses received from the server.
break;
};
let next_fetch_response =
next_fetch_response.context("Failed to process IMAP FETCH result")?;
if let Some(next_uid) = next_fetch_response.uid {
if next_uid == request_uid {
fetch_response = Some(next_fetch_response);
@@ -1512,7 +1465,7 @@ impl Session {
context,
"Passing message UID {} to receive_imf().", request_uid
);
let res = receive_imf_inner(
match receive_imf_inner(
context,
folder,
uidvalidity,
@@ -1520,45 +1473,25 @@ impl Session {
rfc724_mid,
body,
is_seen,
partial.map(|msg_size| (msg_size, None)),
partial,
)
.await;
let received_msg = if let Err(err) = res {
warn!(context, "receive_imf error: {:#}.", err);
if partial.is_some() {
return Err(err);
.await
{
Ok(received_msg) => {
received_msgs_channel
.send((request_uid, received_msg))
.await?;
}
Err(err) => {
warn!(context, "receive_imf error: {:#}.", err);
received_msgs_channel.send((request_uid, None)).await?;
}
receive_imf_inner(
context,
folder,
uidvalidity,
request_uid,
rfc724_mid,
body,
is_seen,
Some((body.len().try_into()?, Some(format!("{err:#}")))),
)
.await?
} else {
res?
};
received_msgs_channel
.send((request_uid, received_msg))
.await?;
}
// If we don't process the whole response, IMAP client is left in a broken state where
// it will try to process the rest of response as the next response.
//
// Make sure to not ignore the errors, because
// if connection times out, it will return
// infinite stream of `Some(Err(_))` results.
while fetch_responses
.try_next()
.await
.context("Failed to drain FETCH responses")?
.is_some()
{}
while fetch_responses.next().await.is_some() {}
if count != request_uids.len() {
warn!(
@@ -1591,43 +1524,7 @@ impl Session {
}
let mut lock = context.metadata.write().await;
if let Some(ref mut old_metadata) = *lock {
let now = time();
// Refresh TURN server credentials if they expire in 12 hours.
if now + 3600 * 12 < old_metadata.ice_servers_expiration_timestamp {
return Ok(());
}
info!(context, "ICE servers expired, requesting new credentials.");
let mailbox = "";
let options = "";
let metadata = self
.get_metadata(mailbox, options, "(/shared/vendor/deltachat/turn)")
.await?;
let mut got_turn_server = false;
for m in metadata {
if m.entry == "/shared/vendor/deltachat/turn" {
if let Some(value) = m.value {
match create_ice_servers_from_metadata(context, &value).await {
Ok((parsed_timestamp, parsed_ice_servers)) => {
old_metadata.ice_servers_expiration_timestamp = parsed_timestamp;
old_metadata.ice_servers = parsed_ice_servers;
got_turn_server = false;
}
Err(err) => {
warn!(context, "Failed to parse TURN server metadata: {err:#}.");
}
}
}
}
}
if !got_turn_server {
// Set expiration timestamp 7 days in the future so we don't request it again.
old_metadata.ice_servers_expiration_timestamp = time() + 3600 * 24 * 7;
old_metadata.ice_servers = create_fallback_ice_servers(context).await?;
}
if (*lock).is_some() {
return Ok(());
}
@@ -1639,8 +1536,6 @@ impl Session {
let mut comment = None;
let mut admin = None;
let mut iroh_relay = None;
let mut ice_servers = None;
let mut ice_servers_expiration_timestamp = 0;
let mailbox = "";
let options = "";
@@ -1648,7 +1543,7 @@ impl Session {
.get_metadata(
mailbox,
options,
"(/shared/comment /shared/admin /shared/vendor/deltachat/irohrelay /shared/vendor/deltachat/turn)",
"(/shared/comment /shared/admin /shared/vendor/deltachat/irohrelay)",
)
.await?;
for m in metadata {
@@ -1671,36 +1566,13 @@ impl Session {
}
}
}
"/shared/vendor/deltachat/turn" => {
if let Some(value) = m.value {
match create_ice_servers_from_metadata(context, &value).await {
Ok((parsed_timestamp, parsed_ice_servers)) => {
ice_servers_expiration_timestamp = parsed_timestamp;
ice_servers = Some(parsed_ice_servers);
}
Err(err) => {
warn!(context, "Failed to parse TURN server metadata: {err:#}.");
}
}
}
}
_ => {}
}
}
let ice_servers = if let Some(ice_servers) = ice_servers {
ice_servers
} else {
// Set expiration timestamp 7 days in the future so we don't request it again.
ice_servers_expiration_timestamp = time() + 3600 * 24 * 7;
create_fallback_ice_servers(context).await?
};
*lock = Some(ServerMetadata {
comment,
admin,
iroh_relay,
ice_servers,
ice_servers_expiration_timestamp,
});
Ok(())
}
@@ -1816,7 +1688,7 @@ impl Session {
.uid_store(uid_set, &query)
.await
.with_context(|| format!("IMAP failed to store: ({uid_set}, {query})"))?;
while let Some(_response) = responses.try_next().await? {
while let Some(_response) = responses.next().await {
// Read all the responses
}
Ok(())
@@ -2238,6 +2110,27 @@ fn get_folder_meaning_by_name(folder_name: &str) -> FolderMeaning {
"迷惑メール",
"스팸",
];
const DRAFT_NAMES: &[&str] = &[
"Drafts",
"Kladder",
"Entw?rfe",
"Borradores",
"Brouillons",
"Bozze",
"Concepten",
"Wersje robocze",
"Rascunhos",
"Entwürfe",
"Koncepty",
"Kopie robocze",
"Taslaklar",
"Utkast",
"Πρόχειρα",
"Черновики",
"下書き",
"草稿",
"임시보관함",
];
const TRASH_NAMES: &[&str] = &[
"Trash",
"Bin",
@@ -2264,6 +2157,8 @@ fn get_folder_meaning_by_name(folder_name: &str) -> FolderMeaning {
FolderMeaning::Sent
} else if SPAM_NAMES.iter().any(|s| s.to_lowercase() == lower) {
FolderMeaning::Spam
} else if DRAFT_NAMES.iter().any(|s| s.to_lowercase() == lower) {
FolderMeaning::Drafts
} else if TRASH_NAMES.iter().any(|s| s.to_lowercase() == lower) {
FolderMeaning::Trash
} else {
@@ -2277,6 +2172,7 @@ fn get_folder_meaning_by_attrs(folder_attrs: &[NameAttribute]) -> FolderMeaning
NameAttribute::Trash => return FolderMeaning::Trash,
NameAttribute::Sent => return FolderMeaning::Sent,
NameAttribute::Junk => return FolderMeaning::Spam,
NameAttribute::Drafts => return FolderMeaning::Drafts,
NameAttribute::All | NameAttribute::Flagged => return FolderMeaning::Virtual,
NameAttribute::Extension(label) => {
match label.as_ref() {

View File

@@ -216,8 +216,8 @@ impl Client {
let mut client = Client::new(session_stream);
let _greeting = client
.read_response()
.await?
.context("Failed to read greeting")?;
.await
.context("failed to read greeting")??;
Ok(client)
}
@@ -231,8 +231,8 @@ impl Client {
let mut client = Client::new(session_stream);
let _greeting = client
.read_response()
.await?
.context("Failed to read greeting")?;
.await
.context("failed to read greeting")??;
Ok(client)
}
@@ -253,8 +253,8 @@ impl Client {
let mut client = async_imap::Client::new(buffered_tcp_stream);
let _greeting = client
.read_response()
.await?
.context("Failed to read greeting")?;
.await
.context("failed to read greeting")??;
client
.run_command_and_check_ok("STARTTLS", None)
.await
@@ -287,8 +287,8 @@ impl Client {
let mut client = Client::new(session_stream);
let _greeting = client
.read_response()
.await?
.context("Failed to read greeting")?;
.await
.context("failed to read greeting")??;
Ok(client)
}
@@ -304,8 +304,8 @@ impl Client {
let mut client = Client::new(session_stream);
let _greeting = client
.read_response()
.await?
.context("Failed to read greeting")?;
.await
.context("failed to read greeting")??;
Ok(client)
}
@@ -325,8 +325,8 @@ impl Client {
let mut client = ImapClient::new(buffered_proxy_stream);
let _greeting = client
.read_response()
.await?
.context("Failed to read greeting")?;
.await
.context("failed to read greeting")??;
client
.run_command_and_check_ok("STARTTLS", None)
.await

View File

@@ -73,8 +73,8 @@ impl Imap {
// Don't scan folders that are watched anyway
if !watched_folders.contains(&folder.name().to_string())
&& folder_meaning != FolderMeaning::Drafts
&& folder_meaning != FolderMeaning::Trash
&& folder_meaning != FolderMeaning::Unknown
{
self.fetch_move_delete(context, session, folder.name(), folder_meaning)
.await

View File

@@ -110,16 +110,14 @@ impl Session {
Ok(list)
}
/// 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).
/// Prefetch all messages greater than or equal to `uid_next`. Returns a list of fetch results
/// in the order of ascending delivery time to the server (INTERNALDATE).
pub(crate) async fn prefetch(
&mut self,
uid_next: u32,
n_uids: u32,
) -> Result<Vec<(u32, async_imap::types::Fetch)>> {
let uid_last = uid_next.saturating_add(n_uids - 1);
// fetch messages with larger UID than the last one seen
let set = format!("{uid_next}:{uid_last}");
let set = format!("{uid_next}:*");
let mut list = self
.uid_fetch(set, PREFETCH_FLAGS)
.await
@@ -128,7 +126,16 @@ impl Session {
let mut msgs = BTreeMap::new();
while let Some(msg) = list.try_next().await? {
if let Some(msg_uid) = msg.uid {
msgs.insert((msg.internal_date(), msg_uid), msg);
// If the mailbox is not empty, results always include
// at least one UID, even if last_seen_uid+1 is past
// the last UID in the mailbox. It happens because
// uid:* is interpreted the same way as *:uid.
// See <https://tools.ietf.org/html/rfc3501#page-61> for
// standard reference. Therefore, sometimes we receive
// already seen messages and have to filter them out.
if msg_uid >= uid_next {
msgs.insert((msg.internal_date(), msg_uid), msg);
}
}
}

View File

@@ -90,7 +90,7 @@ pub async fn imex(
let cancel = context.alloc_ongoing().await?;
let res = {
let _guard = context.scheduler.pause(context).await?;
let _guard = context.scheduler.pause(context.clone()).await?;
imex_inner(context, what, path, passphrase)
.race(async {
cancel.recv().await.ok();
@@ -140,8 +140,32 @@ pub async fn has_backup(_context: &Context, dir_name: &Path) -> Result<String> {
}
async fn set_self_key(context: &Context, armored: &str) -> Result<()> {
let private_key = SignedSecretKey::from_asc(armored)?;
// try hard to only modify key-state
let (private_key, header) = SignedSecretKey::from_asc(armored)?;
let public_key = private_key.split_public_key()?;
if let Some(preferencrypt) = header.get("Autocrypt-Prefer-Encrypt") {
let e2ee_enabled = match preferencrypt.as_str() {
"nopreference" => 0,
"mutual" => 1,
_ => {
bail!("invalid Autocrypt-Prefer-Encrypt header: {:?}", header);
}
};
context
.sql
.set_raw_config_int("e2ee_enabled", e2ee_enabled)
.await?;
} else {
// `Autocrypt-Prefer-Encrypt` is not included
// in keys exported to file.
//
// `Autocrypt-Prefer-Encrypt` also SHOULD be sent
// in Autocrypt Setup Message according to Autocrypt specification,
// but K-9 6.802 does not include this header.
//
// We keep current setting in this case.
info!(context, "No Autocrypt-Prefer-Encrypt header.");
};
let keypair = pgp::KeyPair {
public: public_key,
@@ -780,7 +804,7 @@ async fn export_database(
"UPDATE backup.config SET value='0' WHERE keyname='verified_one_on_one_chats';",
[],
)
.ok(); // Deprecated 2025-07. If verified_one_on_one_chats was not set, this errors, which we ignore
.ok(); // If verified_one_on_one_chats was not set, this errors, which we ignore
conn.execute("DETACH DATABASE backup", [])
.context("failed to detach backup database")?;
res?;
@@ -928,56 +952,75 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_export_and_import_backup() -> Result<()> {
let backup_dir = tempfile::tempdir().unwrap();
for set_verified_oneonone_chats in [true, false] {
let backup_dir = tempfile::tempdir().unwrap();
let context1 = TestContext::new_alice().await;
assert!(context1.is_configured().await?);
let context1 = TestContext::new_alice().await;
assert!(context1.is_configured().await?);
if set_verified_oneonone_chats {
context1
.set_config_bool(Config::VerifiedOneOnOneChats, true)
.await?;
}
let context2 = TestContext::new().await;
assert!(!context2.is_configured().await?);
assert!(has_backup(&context2, backup_dir.path()).await.is_err());
let context2 = TestContext::new().await;
assert!(!context2.is_configured().await?);
assert!(has_backup(&context2, backup_dir.path()).await.is_err());
// export from context1
assert!(
imex(&context1, ImexMode::ExportBackup, backup_dir.path(), None)
// 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;
// import to context2
let backup = has_backup(&context2, backup_dir.path()).await?;
// Import of unencrypted backup with incorrect "foobar" backup passphrase fails.
assert!(
imex(
&context2,
ImexMode::ImportBackup,
backup.as_ref(),
Some("foobar".to_string())
)
.await
.is_ok()
);
let _event = context1
.evtracker
.get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
.await;
.is_err()
);
// import to context2
let backup = has_backup(&context2, backup_dir.path()).await?;
assert!(
imex(&context2, ImexMode::ImportBackup, backup.as_ref(), None)
.await
.is_ok()
);
let _event = context2
.evtracker
.get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
.await;
// Import of unencrypted backup with incorrect "foobar" backup passphrase fails.
assert!(
imex(
&context2,
ImexMode::ImportBackup,
backup.as_ref(),
Some("foobar".to_string())
)
.await
.is_err()
);
assert!(
imex(&context2, ImexMode::ImportBackup, backup.as_ref(), None)
.await
.is_ok()
);
let _event = context2
.evtracker
.get_matching(|evt| matches!(evt, EventType::ImexProgress(1000)))
.await;
assert!(context2.is_configured().await?);
assert_eq!(
context2.get_config(Config::Addr).await?,
Some("alice@example.org".to_string())
);
assert!(context2.is_configured().await?);
assert_eq!(
context2.get_config(Config::Addr).await?,
Some("alice@example.org".to_string())
);
assert_eq!(
context2
.get_config_bool(Config::VerifiedOneOnOneChats)
.await?,
false
);
assert_eq!(
context1
.get_config_bool(Config::VerifiedOneOnOneChats)
.await?,
set_verified_oneonone_chats
);
}
Ok(())
}

View File

@@ -93,7 +93,10 @@ pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result<St
bail!("Passphrase must be at least 2 chars long.");
};
let private_key = load_self_secret_key(context).await?;
let ac_headers = Some(("Autocrypt-Prefer-Encrypt", "mutual"));
let ac_headers = match context.get_config_bool(Config::E2eeEnabled).await? {
false => None,
true => Some(("Autocrypt-Prefer-Encrypt", "mutual")),
};
let private_key_asc = private_key.to_asc(ac_headers);
let encr = pgp::symm_encrypt(passphrase, private_key_asc.into_bytes())
.await?

View File

@@ -105,7 +105,7 @@ impl BackupProvider {
// Acquire global "ongoing" mutex.
let cancel_token = context.alloc_ongoing().await?;
let paused_guard = context.scheduler.pause(context).await?;
let paused_guard = context.scheduler.pause(context.clone()).await?;
let context_dir = context
.get_blobdir()
.parent()
@@ -242,7 +242,7 @@ impl BackupProvider {
if let Err(err) = Self::handle_connection(context.clone(), conn, auth_token, dbfile).race(
async {
cancel_token.recv().await.ok();
Err(format_err!("Backup transfer canceled"))
Err(format_err!("Backup transfer cancelled"))
}
).race(
async {
@@ -250,7 +250,7 @@ impl BackupProvider {
Err(format_err!("Backup provider dropped"))
}
).await {
error!(context, "Error while handling backup connection: {err:#}.");
warn!(context, "Error while handling backup connection: {err:#}.");
context.emit_event(EventType::ImexProgress(0));
break;
} else {
@@ -262,12 +262,12 @@ impl BackupProvider {
}
},
_ = cancel_token.recv() => {
info!(context, "Backup transfer canceled by the user, stopping accept loop.");
info!(context, "Backup transfer cancelled by the user, stopping accept loop.");
context.emit_event(EventType::ImexProgress(0));
break;
}
_ = drop_token.cancelled() => {
info!(context, "Backup transfer canceled by dropping the provider, stopping accept loop.");
info!(context, "Backup transfer cancelled by dropping the provider, stopping accept loop.");
context.emit_event(EventType::ImexProgress(0));
break;
}
@@ -364,11 +364,10 @@ pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> {
let res = get_backup2(context, node_addr, auth_token)
.race(async {
cancel_token.recv().await.ok();
Err(format_err!("Backup reception canceled"))
Err(format_err!("Backup reception cancelled"))
})
.await;
if let Err(ref res) = res {
error!(context, "{:#}", res);
if res.is_err() {
context.emit_event(EventType::ImexProgress(0));
}
context.free_ongoing().await;

View File

@@ -15,7 +15,6 @@ use rand::thread_rng;
use tokio::runtime::Handle;
use crate::context::Context;
use crate::events::EventType;
use crate::log::{LogExt, info};
use crate::pgp::KeyPair;
use crate::tools::{self, time_elapsed};
@@ -72,17 +71,31 @@ pub(crate) trait DcKey: Serialize + Deserializable + Clone {
}
/// Create a key from an ASCII-armored string.
fn from_asc(data: &str) -> Result<Self> {
///
/// Returns the key and a map of any headers which might have been set in
/// the ASCII-armored representation.
fn from_asc(data: &str) -> Result<(Self, BTreeMap<String, String>)> {
let bytes = data.as_bytes();
let res = Self::from_armor_single(Cursor::new(bytes));
let (key, _headers) = match res {
let (key, headers) = match res {
Err(pgp::errors::Error::NoMatchingPacket { .. }) => match Self::is_private() {
true => bail!("No private key packet found"),
false => bail!("No public key packet found"),
},
_ => res.context("rPGP error")?,
};
Ok(key)
let headers = headers
.into_iter()
.map(|(key, values)| {
(
key.trim().to_lowercase(),
values
.last()
.map_or_else(String::new, |s| s.trim().to_string()),
)
})
.collect();
Ok((key, headers))
}
/// Serialise the key as bytes.
@@ -415,11 +428,15 @@ pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) ->
"INSERT INTO config (keyname, value) VALUES ('key_id', ?)",
(new_key_id,),
)?;
Ok(new_key_id)
Ok(Some(new_key_id))
})
.await?;
context.emit_event(EventType::AccountsItemChanged);
config_cache_lock.insert("key_id".to_string(), Some(new_key_id.to_string()));
if let Some(new_key_id) = new_key_id {
// Update config cache if transaction succeeded and changed current default key.
config_cache_lock.insert("key_id".to_string(), Some(new_key_id.to_string()));
}
Ok(())
}
@@ -429,7 +446,7 @@ pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) ->
/// to avoid generating the key in tests.
/// Use import/export APIs instead.
pub async fn preconfigure_keypair(context: &Context, secret_data: &str) -> Result<()> {
let secret = SignedSecretKey::from_asc(secret_data)?;
let secret = SignedSecretKey::from_asc(secret_data)?.0;
let public = secret.split_public_key()?;
let keypair = KeyPair { public, secret };
store_self_keypair(context, &keypair).await?;
@@ -497,7 +514,7 @@ impl std::str::FromStr for Fingerprint {
.filter(|&c| c.is_ascii_hexdigit())
.collect();
let v: Vec<u8> = hex::decode(&hex_repr)?;
ensure!(v.len() == 20, "wrong fingerprint length: {hex_repr}");
ensure!(v.len() == 20, "wrong fingerprint length: {}", hex_repr);
let fp = Fingerprint::new(v);
Ok(fp)
}
@@ -515,7 +532,7 @@ mod tests {
#[test]
fn test_from_armored_string() {
let private_key = SignedSecretKey::from_asc(
let (private_key, _) = SignedSecretKey::from_asc(
"-----BEGIN PGP PRIVATE KEY BLOCK-----
xcLYBF0fgz4BCADnRUV52V4xhSsU56ZaAn3+3oG86MZhXy4X8w14WZZDf0VJGeTh
@@ -583,13 +600,17 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
fn test_asc_roundtrip() {
let key = KEYPAIR.public.clone();
let asc = key.to_asc(Some(("spam", "ham")));
let key2 = SignedPublicKey::from_asc(&asc).unwrap();
let (key2, hdrs) = SignedPublicKey::from_asc(&asc).unwrap();
assert_eq!(key, key2);
assert_eq!(hdrs.len(), 1);
assert_eq!(hdrs.get("spam"), Some(&String::from("ham")));
let key = KEYPAIR.secret.clone();
let asc = key.to_asc(Some(("spam", "ham")));
let key2 = SignedSecretKey::from_asc(&asc).unwrap();
let (key2, hdrs) = SignedSecretKey::from_asc(&asc).unwrap();
assert_eq!(key, key2);
assert_eq!(hdrs.len(), 1);
assert_eq!(hdrs.get("spam"), Some(&String::from("ham")));
}
#[test]

View File

@@ -53,7 +53,6 @@ pub use events::*;
mod aheader;
pub mod blob;
pub mod calls;
pub mod chat;
pub mod chatlist;
pub mod config;

View File

@@ -140,7 +140,7 @@ impl Kml {
if self.tag == KmlTag::PlacemarkTimestampWhen
|| self.tag == KmlTag::PlacemarkPointCoordinates
{
let val = event.xml_content().unwrap_or_default();
let val = event.unescape().unwrap_or_default();
let val = val.replace(['\n', '\r', '\t', ' '], "");

View File

@@ -15,7 +15,9 @@ use crate::blob::BlobObject;
use crate::chat::{Chat, ChatId, ChatIdBlocked, ChatVisibility, send_msg};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::{Blocked, Chattype, DC_CHAT_ID_TRASH, DC_MSG_ID_LAST_SPECIAL};
use crate::constants::{
Blocked, Chattype, DC_CHAT_ID_TRASH, DC_MSG_ID_LAST_SPECIAL, VideochatType,
};
use crate::contact::{self, Contact, ContactId};
use crate::context::Context;
use crate::debug_logging::set_debug_logging_xdc;
@@ -490,7 +492,8 @@ impl Message {
pub async fn load_from_db_optional(context: &Context, id: MsgId) -> Result<Option<Message>> {
ensure!(
!id.is_special(),
"Can not load special message ID {id} from DB"
"Can not load special message ID {} from DB",
id
);
let msg = context
.sql
@@ -565,7 +568,7 @@ impl Message {
timestamp_rcvd: row.get("timestamp_rcvd")?,
ephemeral_timer: row.get("ephemeral_timer")?,
ephemeral_timestamp: row.get("ephemeral_timestamp")?,
viewtype: row.get("type").unwrap_or_default(),
viewtype: row.get("type")?,
state: state.with_mdns(mdn_msg_id.is_some()),
download_state: row.get("download_state")?,
error: Some(row.get::<_, String>("error")?)
@@ -648,10 +651,8 @@ impl Message {
if self.viewtype.has_file() {
let file_param = self.param.get_file_path(context)?;
if let Some(path_and_filename) = file_param {
if matches!(
self.viewtype,
Viewtype::Image | Viewtype::Gif | Viewtype::Sticker
) && !self.param.exists(Param::Width)
if (self.viewtype == Viewtype::Image || self.viewtype == Viewtype::Gif)
&& !self.param.exists(Param::Width)
{
let buf = read_file(context, &path_and_filename).await?;
@@ -972,8 +973,6 @@ impl Message {
| SystemMessage::WebxdcStatusUpdate
| SystemMessage::WebxdcInfoMessage
| SystemMessage::IrohNodeAddr
| SystemMessage::CallAccepted
| SystemMessage::CallEnded
| SystemMessage::Unknown => Ok(None),
}
}
@@ -1014,6 +1013,85 @@ impl Message {
None
}
// add room to a webrtc_instance as defined by the corresponding config-value;
// the result may still be prefixed by the type
pub(crate) fn create_webrtc_instance(instance: &str, room: &str) -> String {
let (videochat_type, mut url) = Message::parse_webrtc_instance(instance);
// make sure, there is a scheme in the url
if !url.contains(':') {
url = format!("https://{url}");
}
// add/replace room
let url = if url.contains("$ROOM") {
url.replace("$ROOM", room)
} else if url.contains("$NOROOM") {
// there are some usecases where a separate room is not needed to use a service
// eg. if you let in people manually anyway, see discussion at
// <https://support.delta.chat/t/videochat-with-webex/1412/4>.
// hacks as hiding the room behind `#` are not reliable, therefore,
// these services are supported by adding the string `$NOROOM` to the url.
url.replace("$NOROOM", "")
} else {
// if there nothing that would separate the room, add a slash as a separator;
// this way, urls can be given as "https://meet.jit.si" as well as "https://meet.jit.si/"
let maybe_slash = if url.ends_with('/')
|| url.ends_with('?')
|| url.ends_with('#')
|| url.ends_with('=')
{
""
} else {
"/"
};
format!("{url}{maybe_slash}{room}")
};
// re-add and normalize type
match videochat_type {
VideochatType::BasicWebrtc => format!("basicwebrtc:{url}"),
VideochatType::Jitsi => format!("jitsi:{url}"),
VideochatType::Unknown => url,
}
}
/// split a webrtc_instance as defined by the corresponding config-value into a type and a url
pub fn parse_webrtc_instance(instance: &str) -> (VideochatType, String) {
let instance: String = instance.split_whitespace().collect();
let mut split = instance.splitn(2, ':');
let type_str = split.next().unwrap_or_default().to_lowercase();
let url = split.next();
match type_str.as_str() {
"basicwebrtc" => (
VideochatType::BasicWebrtc,
url.unwrap_or_default().to_string(),
),
"jitsi" => (VideochatType::Jitsi, url.unwrap_or_default().to_string()),
_ => (VideochatType::Unknown, instance.to_string()),
}
}
/// Returns videochat URL if the message is a videochat invitation.
pub fn get_videochat_url(&self) -> Option<String> {
if self.viewtype == Viewtype::VideochatInvitation {
if let Some(instance) = self.param.get(Param::WebrtcRoom) {
return Some(Message::parse_webrtc_instance(instance).1);
}
}
None
}
/// Returns videochat type if the message is a videochat invitation.
pub fn get_videochat_type(&self) -> Option<VideochatType> {
if self.viewtype == Viewtype::VideochatInvitation {
if let Some(instance) = self.param.get(Param::WebrtcRoom) {
return Some(Message::parse_webrtc_instance(instance).0);
}
}
None
}
/// Sets or unsets message text.
pub fn set_text(&mut self, text: String) {
self.text = text;
@@ -1288,6 +1366,17 @@ impl Message {
Ok(())
}
pub(crate) async fn update_subject(&self, context: &Context) -> Result<()> {
context
.sql
.execute(
"UPDATE msgs SET subject=? WHERE id=?;",
(&self.subject, self.id),
)
.await?;
Ok(())
}
/// Gets the error status of the message.
///
/// A message can have an associated error status if something went wrong when sending or
@@ -2195,8 +2284,8 @@ pub enum Viewtype {
/// and retrieved via dc_msg_get_file().
File = 60,
/// Message is an incoming or outgoing call.
Call = 71,
/// Message is an invitation to a videochat.
VideochatInvitation = 70,
/// Message is an webxdc instance.
Webxdc = 80,
@@ -2220,7 +2309,7 @@ impl Viewtype {
Viewtype::Voice => true,
Viewtype::Video => true,
Viewtype::File => true,
Viewtype::Call => false,
Viewtype::VideochatInvitation => false,
Viewtype::Webxdc => true,
Viewtype::Vcard => true,
}

View File

@@ -25,6 +25,82 @@ fn test_guess_msgtype_from_suffix() {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webrtc_instance() {
let (webrtc_type, url) = Message::parse_webrtc_instance("basicwebrtc:https://foo/bar");
assert_eq!(webrtc_type, VideochatType::BasicWebrtc);
assert_eq!(url, "https://foo/bar");
let (webrtc_type, url) = Message::parse_webrtc_instance("bAsIcwEbrTc:url");
assert_eq!(webrtc_type, VideochatType::BasicWebrtc);
assert_eq!(url, "url");
let (webrtc_type, url) = Message::parse_webrtc_instance("https://foo/bar?key=val#key=val");
assert_eq!(webrtc_type, VideochatType::Unknown);
assert_eq!(url, "https://foo/bar?key=val#key=val");
let (webrtc_type, url) = Message::parse_webrtc_instance("jitsi:https://j.si/foo");
assert_eq!(webrtc_type, VideochatType::Jitsi);
assert_eq!(url, "https://j.si/foo");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_webrtc_instance() {
// webrtc_instance may come from an input field of the ui, be pretty tolerant on input
let instance = Message::create_webrtc_instance("https://meet.jit.si/", "123");
assert_eq!(instance, "https://meet.jit.si/123");
let instance = Message::create_webrtc_instance("https://meet.jit.si", "456");
assert_eq!(instance, "https://meet.jit.si/456");
let instance = Message::create_webrtc_instance("meet.jit.si", "789");
assert_eq!(instance, "https://meet.jit.si/789");
let instance = Message::create_webrtc_instance("bla.foo?", "123");
assert_eq!(instance, "https://bla.foo?123");
let instance = Message::create_webrtc_instance("jitsi:bla.foo#", "456");
assert_eq!(instance, "jitsi:https://bla.foo#456");
let instance = Message::create_webrtc_instance("bla.foo#room=", "789");
assert_eq!(instance, "https://bla.foo#room=789");
let instance = Message::create_webrtc_instance("https://bla.foo#room", "123");
assert_eq!(instance, "https://bla.foo#room/123");
let instance = Message::create_webrtc_instance("bla.foo#room$ROOM", "123");
assert_eq!(instance, "https://bla.foo#room123");
let instance = Message::create_webrtc_instance("bla.foo#room=$ROOM&after=cont", "234");
assert_eq!(instance, "https://bla.foo#room=234&after=cont");
let instance = Message::create_webrtc_instance(" meet.jit .si ", "789");
assert_eq!(instance, "https://meet.jit.si/789");
let instance = Message::create_webrtc_instance(" basicwebrtc: basic . stuff\n ", "12345ab");
assert_eq!(instance, "basicwebrtc:https://basic.stuff/12345ab");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_webrtc_instance_noroom() {
// webrtc_instance may come from an input field of the ui, be pretty tolerant on input
let instance = Message::create_webrtc_instance("bla.foo$NOROOM", "123");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" bla . foo $NOROOM ", "456");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" $NOROOM bla . foo ", "789");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" bla.foo / $NOROOM ? a = b ", "123");
assert_eq!(instance, "https://bla.foo/?a=b");
// $ROOM has a higher precedence
let instance = Message::create_webrtc_instance("bla.foo/?$NOROOM=$ROOM", "123");
assert_eq!(instance, "https://bla.foo/?$NOROOM=123");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_width_height() {
let t = TestContext::new_alice().await;
@@ -572,6 +648,10 @@ fn test_viewtype_values() {
assert_eq!(Viewtype::Voice, Viewtype::from_i32(41).unwrap());
assert_eq!(Viewtype::Video, Viewtype::from_i32(50).unwrap());
assert_eq!(Viewtype::File, Viewtype::from_i32(60).unwrap());
assert_eq!(
Viewtype::VideochatInvitation,
Viewtype::from_i32(70).unwrap()
);
assert_eq!(Viewtype::Webxdc, Viewtype::from_i32(80).unwrap());
assert_eq!(Viewtype::Vcard, Viewtype::from_i32(90).unwrap());
}
@@ -728,40 +808,3 @@ async fn test_sanitize_filename_message() -> Result<()> {
Ok(())
}
/// Tests that empty file can be sent and received.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_empty_file() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_chat = alice.create_chat(bob).await;
let mut msg = Message::new(Viewtype::File);
msg.set_file_from_bytes(alice, "myfile", b"", None)?;
chat::send_msg(alice, alice_chat.id, &mut msg).await?;
let sent = alice.pop_sent_msg().await;
let bob_received_msg = bob.recv_msg(&sent).await;
assert_eq!(bob_received_msg.get_filename().unwrap(), "myfile");
assert_eq!(bob_received_msg.get_viewtype(), Viewtype::File);
Ok(())
}
/// Tests that viewtype 70
/// which previously corresponded to videochat invitations,
/// is loaded as unknown viewtype without errors.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_load_unknown_viewtype() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let msg_id = tcm.send_recv(alice, bob, "Hello!").await.id;
bob.sql
.execute("UPDATE msgs SET type=70 WHERE id=?", (msg_id,))
.await?;
let bob_msg = Message::load_from_db(bob, msg_id).await?;
assert_eq!(bob_msg.get_viewtype(), Viewtype::Unknown);
Ok(())
}

View File

@@ -5,9 +5,7 @@ use std::io::Cursor;
use anyhow::{Context as _, Result, bail, ensure};
use base64::Engine as _;
use data_encoding::BASE32_NOPAD;
use deltachat_contact_tools::sanitize_bidi_characters;
use iroh_gossip::proto::TopicId;
use mail_builder::headers::HeaderType;
use mail_builder::headers::address::{Address, EmailAddress};
use mail_builder::mime::MimePart;
@@ -24,14 +22,14 @@ use crate::context::Context;
use crate::e2ee::EncryptHelper;
use crate::ensure_and_debug_assert;
use crate::ephemeral::Timer as EphemeralTimer;
use crate::headerdef::HeaderDef;
use crate::key::{DcKey, SignedPublicKey, self_fingerprint};
use crate::key::self_fingerprint;
use crate::key::{DcKey, SignedPublicKey};
use crate::location;
use crate::log::{info, warn};
use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::{SystemMessage, is_hidden};
use crate::param::Param;
use crate::peer_channels::{create_iroh_header, get_iroh_topic_for_msg};
use crate::peer_channels::create_iroh_header;
use crate::simplify::escape_message_footer_marks;
use crate::stock_str;
use crate::tools::{
@@ -142,9 +140,6 @@ pub struct MimeFactory {
/// True if the avatar should be attached.
pub attach_selfavatar: bool,
/// This field is used to sustain the topic id of webxdcs needed for peer channels.
webxdc_topic: Option<TopicId>,
}
/// Result of rendering a message, ready to be submitted to a send job.
@@ -253,10 +248,6 @@ impl MimeFactory {
let mut missing_key_addresses = BTreeSet::new();
context
.sql
// Sort recipients by `add_timestamp DESC` so that if the group is large and there
// are multiple SMTP messages, a newly added member receives the member addition
// message earlier and has gossiped keys of other members (otherwise the new member
// may receive messages from other members earlier and fail to verify them).
.query_map(
"SELECT
c.authname,
@@ -270,8 +261,7 @@ impl MimeFactory {
LEFT JOIN contacts c ON cc.contact_id=c.id
LEFT JOIN public_keys k ON k.fingerprint=c.fingerprint
WHERE cc.chat_id=?
AND (cc.contact_id>9 OR (cc.contact_id=1 AND ?))
ORDER BY cc.add_timestamp DESC",
AND (cc.contact_id>9 OR (cc.contact_id=1 AND ?))",
(msg.chat_id, chat.typ == Chattype::Group),
|row| {
let authname: String = row.get(0)?;
@@ -419,7 +409,10 @@ impl MimeFactory {
None
} else {
if keys.is_empty() && !recipients.is_empty() {
bail!("No recipient keys are available, cannot encrypt to {recipients:?}.");
bail!(
"No recipient keys are available, cannot encrypt to {:?}.",
recipients
);
}
// Remove recipients for which the key is missing.
@@ -467,7 +460,7 @@ impl MimeFactory {
past_members.len(),
member_timestamps.len(),
);
let webxdc_topic = get_iroh_topic_for_msg(context, msg.id).await?;
let factory = MimeFactory {
from_addr,
from_displayname,
@@ -487,7 +480,6 @@ impl MimeFactory {
last_added_location_id: None,
sync_ids_to_delete: None,
attach_selfavatar,
webxdc_topic,
};
Ok(factory)
}
@@ -535,7 +527,6 @@ impl MimeFactory {
last_added_location_id: None,
sync_ids_to_delete: None,
attach_selfavatar: false,
webxdc_topic: None,
};
Ok(res)
@@ -1089,14 +1080,13 @@ impl MimeFactory {
continue;
}
let header = Aheader {
addr: addr.clone(),
public_key: key.clone(),
let header = Aheader::new(
addr.clone(),
key.clone(),
// Autocrypt 1.1.0 specification says that
// `prefer-encrypt` attribute SHOULD NOT be included.
prefer_encrypt: EncryptPreference::NoPreference,
verified: false,
}
EncryptPreference::NoPreference,
)
.to_string();
message = message.header(
@@ -1519,31 +1509,16 @@ impl MimeFactory {
));
}
SystemMessage::IrohNodeAddr => {
let node_addr = context
.get_or_try_init_peer_channel()
.await?
.get_node_addr()
.await?;
// We should not send `null` as relay URL
// as this is the only way to reach the node.
debug_assert!(node_addr.relay_url().is_some());
headers.push((
HeaderDef::IrohNodeAddr.into(),
mail_builder::headers::text::Text::new(serde_json::to_string(&node_addr)?)
.into(),
));
}
SystemMessage::CallAccepted => {
headers.push((
"Chat-Content",
mail_builder::headers::raw::Raw::new("call-accepted").into(),
));
}
SystemMessage::CallEnded => {
headers.push((
"Chat-Content",
mail_builder::headers::raw::Raw::new("call-ended").into(),
"Iroh-Node-Addr",
mail_builder::headers::text::Text::new(serde_json::to_string(
&context
.get_or_try_init_peer_channel()
.await?
.get_node_addr()
.await?,
)?)
.into(),
));
}
_ => {}
@@ -1565,25 +1540,20 @@ impl MimeFactory {
"Chat-Content",
mail_builder::headers::raw::Raw::new("sticker").into(),
));
} else if msg.viewtype == Viewtype::Call {
} else if msg.viewtype == Viewtype::VideochatInvitation {
headers.push((
"Chat-Content",
mail_builder::headers::raw::Raw::new("call").into(),
mail_builder::headers::raw::Raw::new("videochat-invitation").into(),
));
placeholdertext = Some(
"[This is a 'Call'. The sender uses an experiment not supported on your version yet]".to_string(),
);
}
if let Some(offer) = msg.param.get(Param::WebrtcRoom) {
headers.push((
"Chat-Webrtc-Room",
mail_builder::headers::raw::Raw::new(b_encode(offer)).into(),
));
} else if let Some(answer) = msg.param.get(Param::WebrtcAccepted) {
headers.push((
"Chat-Webrtc-Accepted",
mail_builder::headers::raw::Raw::new(b_encode(answer)).into(),
mail_builder::headers::raw::Raw::new(
msg.param
.get(Param::WebrtcRoom)
.unwrap_or_default()
.to_string(),
)
.into(),
));
}
@@ -1721,13 +1691,10 @@ impl MimeFactory {
let json = msg.param.get(Param::Arg).unwrap_or_default();
parts.push(context.build_status_update_part(json));
} else if msg.viewtype == Viewtype::Webxdc {
let topic = self
.webxdc_topic
.map(|top| BASE32_NOPAD.encode(top.as_bytes()).to_ascii_lowercase())
.unwrap_or(create_iroh_header(context, msg.id).await?);
headers.push((
HeaderDef::IrohGossipTopic.get_headername(),
mail_builder::headers::raw::Raw::new(topic).into(),
"Iroh-Gossip-Topic",
mail_builder::headers::raw::Raw::new(create_iroh_header(context, msg.id).await?)
.into(),
));
if let (Some(json), _) = context
.render_webxdc_status_update_object(
@@ -1877,17 +1844,5 @@ fn render_rfc724_mid(rfc724_mid: &str) -> String {
}
}
/// Encodes UTF-8 string as a single B-encoded-word.
///
/// We manually encode some headers because as of
/// version 0.4.4 mail-builder crate does not encode
/// newlines correctly if they appear in a text header.
fn b_encode(value: &str) -> String {
format!(
"=?utf-8?B?{}?=",
base64::engine::general_purpose::STANDARD.encode(value)
)
}
#[cfg(test)]
mod mimefactory_tests;

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