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
128 changed files with 1724 additions and 5737 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
@@ -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
@@ -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 }}
@@ -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,7 +30,7 @@ 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
@@ -54,7 +54,7 @@ jobs:
arch: [win32, win64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
@@ -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,7 +105,7 @@ 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
@@ -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: 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

@@ -19,7 +19,7 @@ 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
@@ -81,7 +81,7 @@ 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
@@ -101,7 +101,7 @@ 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

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,7 +14,7 @@ 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

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,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
@@ -50,7 +50,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
@@ -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,312 +1,5 @@
# Changelog
## [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
@@ -6802,16 +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

563
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat"
version = "2.16.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 = "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

@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "2.16.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
@@ -502,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.
@@ -1214,103 +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 "Cancelled Call",
*
* - callee is already in a call:
* in this case, UI may decide to show a notification instead of ringing.
* otherwise, this is same as timeout
*
* - 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 "Cancelled 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
*
* 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.
*
* @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.
*
* 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.
*
@@ -1428,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.
@@ -2185,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().
*
@@ -2219,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
@@ -3926,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.
@@ -3955,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().
@@ -5346,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);
@@ -5384,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.
@@ -5712,12 +5633,6 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot);
#define DC_MSG_VIDEOCHAT_INVITATION 70
/**
* Message indicating an incoming or outgoing call.
*/
#define DC_MSG_CALL 71
/**
* The message is a webxdc instance.
*
@@ -6471,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().
*
@@ -6560,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
@@ -6712,62 +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 even as #DC_EVENT_MSGS_CHANGED.
*
* 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.
*
* The event is sent unconditionally when the corresponding message is received.
* UI should only take 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().
*
* The event is sent unconditionally when the corresponding message is received.
* UI should only take 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().
* Moreover, the event is sent when the call was not accepted within 1 minute timeout.
*
* The event is sent unconditionally when the corresponding message is received.
* UI should only take 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
/**
* @}
*/
@@ -7188,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"
@@ -7732,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.

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()
@@ -1193,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,
@@ -3246,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() {

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "2.16.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>>>,
@@ -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()
@@ -1238,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,
@@ -1484,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,
@@ -1641,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(
@@ -1919,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.
@@ -2002,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
@@ -2084,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.

View File

@@ -1,95 +0,0 @@
use anyhow::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, cancelled, 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?;
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 cancelled on our side
/// before receiving a response.
///
/// Incoming calls cannot be cancelled,
/// on the receiver side cancelled calls
/// usually result in missed calls.
Cancelled,
}
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::Cancelled => JsonrpcCallState::Cancelled,
};
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

@@ -324,9 +324,6 @@ pub enum MessageViewtype {
/// Message is an invitation to a videochat.
VideochatInvitation,
/// Message is a call.
Call,
/// Message is an webxdc instance.
Webxdc,
@@ -349,7 +346,6 @@ impl From<Viewtype> for MessageViewtype {
Viewtype::Video => MessageViewtype::Video,
Viewtype::File => MessageViewtype::File,
Viewtype::VideochatInvitation => MessageViewtype::VideochatInvitation,
Viewtype::Call => MessageViewtype::Call,
Viewtype::Webxdc => MessageViewtype::Webxdc,
Viewtype::Vcard => MessageViewtype::Vcard,
}
@@ -369,7 +365,6 @@ impl From<MessageViewtype> for Viewtype {
MessageViewtype::Video => Viewtype::Video,
MessageViewtype::File => Viewtype::File,
MessageViewtype::VideochatInvitation => Viewtype::VideochatInvitation,
MessageViewtype::Call => Viewtype::Call,
MessageViewtype::Webxdc => Viewtype::Webxdc,
MessageViewtype::Vcard => Viewtype::Vcard,
}
@@ -442,9 +437,6 @@ pub enum SystemMessageType {
/// This message contains a users iroh node address.
IrohNodeAddr,
CallAccepted,
CallEnded,
}
impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
@@ -471,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

@@ -54,5 +54,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.16.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

View File

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

View File

@@ -403,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\
@@ -1220,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?;

View File

@@ -232,7 +232,7 @@ const MESSAGE_COMMANDS: [&str; 10] = [
"delmsg",
"react",
];
const CONTACT_COMMANDS: [&str; 9] = [
const CONTACT_COMMANDS: [&str; 7] = [
"listcontacts",
"addcontact",
"contactinfo",
@@ -240,8 +240,6 @@ const CONTACT_COMMANDS: [&str; 9] = [
"block",
"unblock",
"listblocked",
"import-vcard",
"make-vcard",
];
const MISC_COMMANDS: [&str; 14] = [
"getqr",

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.16.0"
version = "2.3.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",

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"

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

@@ -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.16.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.16.0"
"version": "2.3.0"
}

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

@@ -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.16.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))

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-01
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

@@ -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"),
}
}
}
@@ -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

@@ -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(),

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,645 +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
/// cancelled by the other side
/// immediately after ringing started.
const CALL_CANCELLED_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!("{} minutes", n),
};
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 cancelled it
/// explicitly before ringing stopped.
///
/// For outgoing calls this means
/// the receiver has rejected the call
/// explicitly.
pub fn is_cancelled(&self) -> bool {
self.msg.param.exists(CALL_CANCELLED_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 cancelled.
///
/// 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_cancelled(&mut self, context: &Context) -> Result<()> {
let now = time();
self.msg.param.set_i64(CALL_ENDED_TIMESTAMP, now);
self.msg.param.set_i64(CALL_CANCELLED_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 && !chat.is_self_talk());
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?;
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?;
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_cancelled(self).await?;
call.update_text(self, "Cancelled 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 mut call = context.load_call_by_id(call_id).await?;
if !call.is_accepted() && !call.is_ended() {
if call.is_incoming() {
call.mark_as_cancelled(&context).await?;
call.update_text(&context, "Missed call").await?;
} else {
call.mark_as_ended(&context).await?;
call.update_text(&context, "Cancelled 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 call = self.load_call_by_id(call_id).await?;
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 mut call = self.load_call_by_id(call_id).await?;
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 mut call = self.load_call_by_id(call_id).await?;
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_cancelled(self).await?;
call.update_text(self, "Missed call").await?;
}
} else {
// outgoing
if from_id == ContactId::SELF {
call.mark_as_cancelled(self).await?;
call.update_text(self, "Cancelled 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.
pub async fn load_call_by_id(&self, call_id: MsgId) -> Result<CallInfo> {
let call = Message::load_from_db(self, call_id).await?;
self.load_call_by_message(call)
}
fn load_call_by_message(&self, call: Message) -> Result<CallInfo> {
ensure!(call.viewtype == Viewtype::Call);
Ok(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 cancelled on our side
/// before receiving a response.
///
/// Incoming calls cannot be cancelled,
/// on the receiver side cancelled calls
/// usually result in missed calls.
Cancelled,
}
/// Returns call state given the message ID.
pub async fn call_state(context: &Context, msg_id: MsgId) -> Result<CallState> {
let call = context.load_call_by_id(msg_id).await?;
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_cancelled() {
// Call was explicitly cancelled
// 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_cancelled() {
CallState::Cancelled
} 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,495 +0,0 @@
use super::*;
use crate::config::Config;
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?;
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?;
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?;
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?;
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?;
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, "Cancelled 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::Cancelled
);
alice2.recv_msg_trash(&sent3).await;
assert_text(&alice2, alice2_call.id, "Cancelled call").await?;
alice2
.evtracker
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
.await;
assert_eq!(
call_state(&alice2, alice2_call.id).await?,
CallState::Cancelled
);
// 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);
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?;
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?;
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?;
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);
}

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")
}
@@ -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,
@@ -1679,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) {
@@ -1771,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
@@ -1786,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() {
@@ -1797,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;
@@ -1810,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);
}
@@ -1889,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 => {
@@ -1920,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
@@ -1957,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.
@@ -1966,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;
@@ -2244,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.
@@ -2497,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
}
@@ -2691,10 +2725,7 @@ impl ChatIdBlocked {
}
async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
if msg.viewtype == Viewtype::Text
|| msg.viewtype == Viewtype::VideochatInvitation
|| 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;
@@ -2916,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
@@ -2972,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
@@ -2985,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.
@@ -3000,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();
@@ -3091,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();
@@ -3169,7 +3181,6 @@ pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: Strin
original_msg.viewtype != Viewtype::VideochatInvitation,
"Cannot edit videochat invitations"
);
ensure!(original_msg.viewtype != Viewtype::Call, "Cannot edit calls");
ensure!(
!original_msg.text.is_empty(), // avoid complexity in UI element changes. focus is typos and rewordings
"Cannot add text"
@@ -3355,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;
}
@@ -3707,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(),
@@ -3738,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?
@@ -4463,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);
@@ -4526,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,
@@ -4574,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"
@@ -4585,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
@@ -4596,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": 29377,
"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, 0x613dd7);
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!(
@@ -3173,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;
@@ -4160,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;
@@ -4192,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(())
}
@@ -4793,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<()> {
@@ -4833,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.22;
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"))]
@@ -413,12 +417,12 @@ pub enum Config {
#[strum(props(default = "172800"))]
GossipPeriod,
/// Deprecated 2025-07. Feature flag for verified 1:1 chats; the UI should set it
/// 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
/// 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,
@@ -450,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 {
@@ -704,6 +705,7 @@ impl Context {
Config::Socks5Enabled
| Config::ProxyEnabled
| Config::BccSelf
| Config::E2eeEnabled
| Config::MdnsEnabled
| Config::SentboxWatch
| Config::MvboxMove
@@ -732,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

@@ -95,10 +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.
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);

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,16 +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 {
str_to_color(&self.addr.to_lowercase())
}
str_to_color(&self.addr.to_lowercase())
}
/// Gets the contact's status.
@@ -1650,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 {
@@ -1925,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| {
@@ -1952,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,7 +1,7 @@
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::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote};
@@ -759,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, 0x4947dc);
assert_eq!(color1, 0xA739FF);
let t = TestContext::new().await;
let contact_id = Contact::create(&t, "prename name", "name@example.net").await?;
@@ -1035,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();
@@ -1116,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;
@@ -1126,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(())
}
@@ -1302,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,6 +953,7 @@ 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());
@@ -1055,7 +1043,7 @@ impl Context {
self.get_config_int(Config::GossipPeriod).await?.to_string(),
);
res.insert(
"verified_one_on_one_chats", // deprecated 2025-07
"verified_one_on_one_chats",
self.get_config_bool(Config::VerifiedOneOnOneChats)
.await?
.to_string(),
@@ -1079,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));
@@ -1097,6 +1078,7 @@ impl Context {
#[derive(Default)]
struct ChatNumbers {
protected: u32,
protection_broken: u32,
opportunistic_dc: u32,
opportunistic_mua: u32,
unencrypted_dc: u32,
@@ -1132,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
@@ -1174,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;
@@ -1191,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);
@@ -1220,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

@@ -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()
});

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,

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,

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);
}
};
@@ -703,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();
@@ -825,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?;
}
@@ -1398,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);
@@ -1480,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,
@@ -1488,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!(
@@ -1559,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(());
}
@@ -1607,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 = "";
@@ -1616,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 {
@@ -1639,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(())
}
@@ -1784,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(())
@@ -2206,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",
@@ -2232,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 {
@@ -2245,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

@@ -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?;

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()
@@ -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 {
@@ -367,8 +367,7 @@ pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> {
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

@@ -71,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.
@@ -432,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?;
@@ -518,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
@@ -586,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

@@ -651,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?;
@@ -975,8 +973,6 @@ impl Message {
| SystemMessage::WebxdcStatusUpdate
| SystemMessage::WebxdcInfoMessage
| SystemMessage::IrohNodeAddr
| SystemMessage::CallAccepted
| SystemMessage::CallEnded
| SystemMessage::Unknown => Ok(None),
}
}
@@ -1370,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
@@ -2280,9 +2287,6 @@ pub enum Viewtype {
/// Message is an invitation to a videochat.
VideochatInvitation = 70,
/// Message is an incoming or outgoing call.
Call = 71,
/// Message is an webxdc instance.
Webxdc = 80,
@@ -2306,7 +2310,6 @@ impl Viewtype {
Viewtype::Video => true,
Viewtype::File => true,
Viewtype::VideochatInvitation => false,
Viewtype::Call => false,
Viewtype::Webxdc => true,
Viewtype::Vcard => true,
}

View File

@@ -808,22 +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(())
}

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)?;
@@ -470,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,
@@ -490,7 +480,6 @@ impl MimeFactory {
last_added_location_id: None,
sync_ids_to_delete: None,
attach_selfavatar,
webxdc_topic,
};
Ok(factory)
}
@@ -538,7 +527,6 @@ impl MimeFactory {
last_added_location_id: None,
sync_ids_to_delete: None,
attach_selfavatar: false,
webxdc_topic: None,
};
Ok(res)
@@ -1019,6 +1007,7 @@ impl MimeFactory {
| "in-reply-to"
| "references"
| "auto-submitted"
| "chat-version"
| "autocrypt-setup-message" => {
unprotected_headers.push(header.clone());
}
@@ -1091,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(
@@ -1522,7 +1510,7 @@ impl MimeFactory {
}
SystemMessage::IrohNodeAddr => {
headers.push((
HeaderDef::IrohNodeAddr.into(),
"Iroh-Node-Addr",
mail_builder::headers::text::Text::new(serde_json::to_string(
&context
.get_or_try_init_peer_channel()
@@ -1533,18 +1521,6 @@ impl MimeFactory {
.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(),
));
}
_ => {}
}
@@ -1569,25 +1545,15 @@ impl MimeFactory {
"Chat-Content",
mail_builder::headers::raw::Raw::new("videochat-invitation").into(),
));
} else if msg.viewtype == Viewtype::Call {
headers.push((
"Chat-Content",
mail_builder::headers::raw::Raw::new("call").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(),
));
}
@@ -1725,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(
@@ -1881,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;

View File

@@ -2,7 +2,6 @@ use deltachat_contact_tools::ContactAddress;
use mail_builder::headers::Header;
use mailparse::{MailHeaderMap, addrparse_header};
use std::str;
use std::time::Duration;
use super::*;
use crate::chat::{
@@ -17,7 +16,6 @@ use crate::message;
use crate::mimeparser::MimeMessage;
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager, get_chat_msg};
use crate::tools::SystemTime;
fn render_email_address(display_name: &str, addr: &str) -> String {
let mut output = Vec::<u8>::new();
@@ -91,11 +89,8 @@ fn test_render_rfc724_mid() {
fn render_header_text(text: &str) -> String {
let mut output = Vec::<u8>::new();
// Some non-zero length of the header name.
let bytes_written = 20;
mail_builder::headers::text::Text::new(text.to_string())
.write_header(&mut output, bytes_written)
.write_header(&mut output, 0)
.unwrap();
String::from_utf8(output).unwrap()
@@ -686,7 +681,6 @@ async fn test_selfavatar_unencrypted_signed() {
.unwrap()
.unwrap();
let alice_contact = Contact::get_by_id(&bob.ctx, alice_id).await.unwrap();
assert_eq!(alice_contact.is_key_contact(), false);
assert!(
alice_contact
.get_profile_image(&bob.ctx)
@@ -873,43 +867,6 @@ async fn test_dont_remove_self() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_new_member_is_first_recipient() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
let bob_id = alice.add_or_lookup_contact_id(bob).await;
let charlie_id = alice.add_or_lookup_contact_id(charlie).await;
let group = alice
.create_group_with_members(ProtectionStatus::Unprotected, "Group", &[bob])
.await;
alice.send_text(group, "Hi! I created a group.").await;
SystemTime::shift(Duration::from_secs(60));
add_contact_to_chat(alice, group, charlie_id).await?;
let sent_msg = alice.pop_sent_msg().await;
assert!(
sent_msg
.recipients
.starts_with(&charlie.get_config(Config::Addr).await?.unwrap())
);
remove_contact_from_chat(alice, group, bob_id).await?;
alice.pop_sent_msg().await;
SystemTime::shift(Duration::from_secs(60));
add_contact_to_chat(alice, group, bob_id).await?;
let sent_msg = alice.pop_sent_msg().await;
assert!(
sent_msg
.recipients
.starts_with(&bob.get_config(Config::Addr).await?.unwrap())
);
Ok(())
}
/// Regression test: mimefactory should never create an empty to header,
/// also not if the Selftalk parameter is missing
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View File

@@ -1,7 +1,7 @@
//! # MIME message parsing module.
use std::cmp::min;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::str;
use std::str::FromStr;
@@ -36,17 +36,6 @@ use crate::tools::{
};
use crate::{chatlist_events, location, stock_str, tools};
/// Public key extracted from `Autocrypt-Gossip`
/// header with associated information.
#[derive(Debug)]
pub struct GossipedKey {
/// Public key extracted from `keydata` attribute.
pub public_key: SignedPublicKey,
/// True if `Autocrypt-Gossip` has a `_verified` attribute.
pub verified: bool,
}
/// A parsed MIME message.
///
/// This represents the relevant information of a parsed MIME message
@@ -96,7 +85,7 @@ pub(crate) struct MimeMessage {
/// The addresses for which there was a gossip header
/// and their respective gossiped keys.
pub gossiped_keys: BTreeMap<String, GossipedKey>,
pub gossiped_keys: HashMap<String, SignedPublicKey>,
/// Fingerprint of the key in the Autocrypt header.
///
@@ -227,12 +216,6 @@ pub enum SystemMessage {
/// "Messages are end-to-end encrypted."
ChatE2ee = 50,
/// Message indicating that a call was accepted.
CallAccepted = 66,
/// Message indicating that a call was ended.
CallEnded = 67,
}
const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup";
@@ -240,12 +223,12 @@ const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup";
impl MimeMessage {
/// Parse a mime message.
///
/// If `partial` is set, it contains the full message size in bytes and an optional error text
/// for the partially downloaded message, and `body` contains the HEADER only.
/// If `partial` is set, it contains the full message size in bytes
/// and `body` contains the header only.
pub(crate) async fn from_bytes(
context: &Context,
body: &[u8],
partial: Option<(u32, Option<String>)>,
partial: Option<u32>,
) -> Result<Self> {
let mail = mailparse::parse_mail(body)?;
@@ -351,7 +334,7 @@ impl MimeMessage {
let incoming = !context.is_self_addr(&from.addr).await?;
let mut aheader_values = mail.headers.get_all_values(HeaderDef::Autocrypt.into());
let mut aheader_value: Option<String> = mail.headers.get_header_value(HeaderDef::Autocrypt);
let mail_raw; // Memory location for a possible decrypted message.
let decrypted_msg; // Decrypted signed OpenPGP message.
@@ -378,11 +361,11 @@ impl MimeMessage {
timestamp_rcvd,
);
let protected_aheader_values = decrypted_mail
if let Some(protected_aheader_value) = decrypted_mail
.headers
.get_all_values(HeaderDef::Autocrypt.into());
if !protected_aheader_values.is_empty() {
aheader_values = protected_aheader_values;
.get_header_value(HeaderDef::Autocrypt)
{
aheader_value = Some(protected_aheader_value);
}
(Ok(decrypted_mail), true)
@@ -400,27 +383,26 @@ impl MimeMessage {
}
};
let mut autocrypt_header = None;
if incoming {
// See `get_all_addresses_from_header()` for why we take the last valid header.
for val in aheader_values.iter().rev() {
autocrypt_header = match Aheader::from_str(val) {
Ok(header) if addr_cmp(&header.addr, &from.addr) => Some(header),
Ok(header) => {
warn!(
context,
"Autocrypt header address {:?} is not {:?}.", header.addr, from.addr
);
continue;
}
Err(err) => {
warn!(context, "Failed to parse Autocrypt header: {:#}.", err);
continue;
}
};
break;
let autocrypt_header = if !incoming {
None
} else if let Some(aheader_value) = aheader_value {
match Aheader::from_str(&aheader_value) {
Ok(header) if addr_cmp(&header.addr, &from.addr) => Some(header),
Ok(header) => {
warn!(
context,
"Autocrypt header address {:?} is not {:?}.", header.addr, from.addr
);
None
}
Err(err) => {
warn!(context, "Failed to parse Autocrypt header: {:#}.", err);
None
}
}
}
} else {
None
};
let autocrypt_fingerprint = if let Some(autocrypt_header) = &autocrypt_header {
let fingerprint = autocrypt_header.public_key.dc_fingerprint().hex();
@@ -445,7 +427,7 @@ impl MimeMessage {
None
};
let mut public_keyring = if incoming {
let public_keyring = if incoming {
if let Some(autocrypt_header) = autocrypt_header {
vec![autocrypt_header.public_key]
} else {
@@ -455,46 +437,8 @@ impl MimeMessage {
key::load_self_public_keyring(context).await?
};
if let Some(signature) = match &decrypted_msg {
Some(pgp::composed::Message::Literal { .. }) => None,
Some(pgp::composed::Message::Compressed { .. }) => {
// One layer of compression should already be handled by now.
// We don't decompress messages compressed multiple times.
None
}
Some(pgp::composed::Message::SignedOnePass { reader, .. }) => reader.signature(),
Some(pgp::composed::Message::Signed { reader, .. }) => Some(reader.signature()),
Some(pgp::composed::Message::Encrypted { .. }) => {
// The message is already decrypted once.
None
}
None => None,
} {
for issuer_fingerprint in signature.issuer_fingerprint() {
let issuer_fingerprint =
crate::key::Fingerprint::from(issuer_fingerprint.clone()).hex();
if let Some(public_key_bytes) = context
.sql
.query_row_optional(
"SELECT public_key
FROM public_keys
WHERE fingerprint=?",
(&issuer_fingerprint,),
|row| {
let bytes: Vec<u8> = row.get(0)?;
Ok(bytes)
},
)
.await?
{
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
public_keyring.push(public_key)
}
}
}
let mut signatures = if let Some(ref decrypted_msg) = decrypted_msg {
crate::pgp::valid_signature_fingerprints(decrypted_msg, &public_keyring)
crate::pgp::valid_signature_fingerprints(decrypted_msg, &public_keyring)?
} else {
HashSet::new()
};
@@ -612,9 +556,9 @@ impl MimeMessage {
};
match partial {
Some((org_bytes, err)) => {
Some(org_bytes) => {
parser
.create_stub_from_partial_download(context, org_bytes, err)
.create_stub_from_partial_download(context, org_bytes)
.await?;
}
None => match mail {
@@ -634,7 +578,7 @@ impl MimeMessage {
error: Some(format!("Decrypting failed: {err:#}")),
..Default::default()
};
parser.do_add_single_part(part);
parser.parts.push(part);
}
},
};
@@ -694,10 +638,6 @@ impl MimeMessage {
self.is_system_message = SystemMessage::ChatProtectionDisabled;
} else if value == "group-avatar-changed" {
self.is_system_message = SystemMessage::GroupImageChanged;
} else if value == "call-accepted" {
self.is_system_message = SystemMessage::CallAccepted;
} else if value == "call-ended" {
self.is_system_message = SystemMessage::CallEnded;
}
} else if self.get_header(HeaderDef::ChatGroupMemberRemoved).is_some() {
self.is_system_message = SystemMessage::MemberRemovedFromGroup;
@@ -720,26 +660,16 @@ impl MimeMessage {
}
fn parse_videochat_headers(&mut self) {
let content = self
.get_header(HeaderDef::ChatContent)
.unwrap_or_default()
.to_string();
let room = self
.get_header(HeaderDef::ChatWebrtcRoom)
.map(|s| s.to_string());
let accepted = self
.get_header(HeaderDef::ChatWebrtcAccepted)
.map(|s| s.to_string());
if let Some(part) = self.parts.first_mut() {
if let Some(room) = room {
if content == "videochat-invitation" {
if let Some(value) = self.get_header(HeaderDef::ChatContent) {
if value == "videochat-invitation" {
let instance = self
.get_header(HeaderDef::ChatWebrtcRoom)
.map(|s| s.to_string());
if let Some(part) = self.parts.first_mut() {
part.typ = Viewtype::VideochatInvitation;
} else if content == "call" {
part.typ = Viewtype::Call
part.param
.set(Param::WebrtcRoom, instance.unwrap_or_default());
}
part.param.set(Param::WebrtcRoom, room);
} else if let Some(accepted) = accepted {
part.param.set(Param::WebrtcAccepted, accepted);
}
}
}
@@ -765,10 +695,7 @@ impl MimeMessage {
| Viewtype::Vcard
| Viewtype::File
| Viewtype::Webxdc => true,
Viewtype::Unknown
| Viewtype::Text
| Viewtype::VideochatInvitation
| Viewtype::Call => false,
Viewtype::Unknown | Viewtype::Text | Viewtype::VideochatInvitation => false,
})
{
let mut parts = std::mem::take(&mut self.parts);
@@ -1072,61 +999,47 @@ impl MimeMessage {
)?
.0;
match (mimetype.type_(), mimetype.subtype().as_str()) {
/* Most times, multipart/alternative contains true alternatives
as text/plain and text/html. If we find a multipart/mixed
inside multipart/alternative, we use this (happens eg in
apple mail: "plaintext" as an alternative to "html+PDF attachment") */
(mime::MULTIPART, "alternative") => {
// multipart/alternative is described in
// <https://datatracker.ietf.org/doc/html/rfc2046#section-5.1.4>.
// Specification says that last part should be preferred,
// so we iterate over parts in reverse order.
// Search for plain text or multipart part.
//
// If we find a multipart inside multipart/alternative
// and it has usable subparts, we only parse multipart.
// This happens e.g. in Apple Mail:
// "plaintext" as an alternative to "html+PDF attachment".
for cur_data in mail.subparts.iter().rev() {
let (mime_type, _viewtype) = get_mime_type(
for cur_data in &mail.subparts {
let mime_type = get_mime_type(
cur_data,
&get_attachment_filename(context, cur_data)?,
self.has_chat_version(),
)?;
if mime_type == mime::TEXT_PLAIN || mime_type.type_() == mime::MULTIPART {
)?
.0;
if mime_type == "multipart/mixed" || mime_type == "multipart/related" {
any_part_added = self
.parse_mime_recursive(context, cur_data, is_related)
.await?;
break;
}
}
// Explicitly look for a `text/calendar` part.
// Messages conforming to <https://datatracker.ietf.org/doc/html/rfc6047>
// contain `text/calendar` part as an alternative
// to the text or HTML representation.
//
// While we cannot display `text/calendar` and therefore do not prefer it,
// we still make it available by presenting as an attachment
// with a generic filename.
for cur_data in mail.subparts.iter().rev() {
let mimetype = cur_data.ctype.mimetype.parse::<Mime>()?;
if mimetype.type_() == mime::TEXT && mimetype.subtype() == "calendar" {
let filename = get_attachment_filename(context, cur_data)?
.unwrap_or_else(|| "calendar.ics".to_string());
self.do_add_single_file_part(
context,
Viewtype::File,
mimetype,
&mail.ctype.mimetype.to_lowercase(),
&mail.get_body_raw()?,
&filename,
is_related,
)
.await?;
if !any_part_added {
/* search for text/plain and add this */
for cur_data in &mail.subparts {
if get_mime_type(
cur_data,
&get_attachment_filename(context, cur_data)?,
self.has_chat_version(),
)?
.0
.type_()
== mime::TEXT
{
any_part_added = self
.parse_mime_recursive(context, cur_data, is_related)
.await?;
break;
}
}
}
if !any_part_added {
for cur_part in mail.subparts.iter().rev() {
/* `text/plain` not found - use the first part */
for cur_part in &mail.subparts {
if self
.parse_mime_recursive(context, cur_part, is_related)
.await?
@@ -1409,6 +1322,10 @@ impl MimeMessage {
filename: &str,
is_related: bool,
) -> Result<()> {
if decoded_data.is_empty() {
return Ok(());
}
// Process attached PGP keys.
if mime_type.type_() == mime::APPLICATION
&& mime_type.subtype().as_str() == "pgp-keys"
@@ -1534,7 +1451,7 @@ impl MimeMessage {
);
return Ok(false);
}
Ok(key) => key,
Ok((key, _)) => key,
};
if let Err(err) = key.verify() {
warn!(context, "Attached PGP key verification failed: {err:#}.");
@@ -1557,7 +1474,7 @@ impl MimeMessage {
Ok(true)
}
pub(crate) fn do_add_single_part(&mut self, mut part: Part) {
fn do_add_single_part(&mut self, mut part: Part) {
if self.was_encrypted() {
part.param.set_int(Param::GuaranteeE2ee, 1);
}
@@ -1597,11 +1514,13 @@ impl MimeMessage {
}
}
/// Check if a message is a call.
pub(crate) fn is_call(&self) -> bool {
self.parts
.first()
.is_some_and(|part| part.typ == Viewtype::Call)
pub fn replace_msg_by_error(&mut self, error_msg: &str) {
self.is_system_message = SystemMessage::Unknown;
if let Some(part) = self.parts.first_mut() {
part.typ = Viewtype::Text;
part.msg = format!("[{error_msg}]");
self.parts.truncate(1);
}
}
pub(crate) fn get_rfc724_mid(&self) -> Option<String> {
@@ -1989,9 +1908,9 @@ async fn parse_gossip_headers(
from: &str,
recipients: &[SingleInfo],
gossip_headers: Vec<String>,
) -> Result<BTreeMap<String, GossipedKey>> {
) -> Result<HashMap<String, SignedPublicKey>> {
// XXX split the parsing from the modification part
let mut gossiped_keys: BTreeMap<String, GossipedKey> = Default::default();
let mut gossiped_keys: HashMap<String, SignedPublicKey> = Default::default();
for value in &gossip_headers {
let header = match value.parse::<Aheader>() {
@@ -2033,12 +1952,7 @@ async fn parse_gossip_headers(
)
.await?;
let gossiped_key = GossipedKey {
public_key: header.public_key,
verified: header.verified,
};
gossiped_keys.insert(header.addr.to_lowercase(), gossiped_key);
gossiped_keys.insert(header.addr.to_lowercase(), header.public_key);
}
Ok(gossiped_keys)

View File

@@ -1990,27 +1990,6 @@ async fn test_chat_edit_imf_header() -> Result<()> {
Ok(())
}
/// Tests that the last valid Autocrypt header is taken:
/// - The 3rd header is skipped because of the unknown critical attribute.
/// - The 2nd header is taken despite it has an unknown non-critical attribute.
/// - The 1st header shouldn't be looked at.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_multiple_autocrypt_hdrs() -> Result<()> {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
let msg_id = receive_imf(
bob,
include_bytes!("../../test-data/message/thunderbird_with_multiple_autocrypts.eml"),
false,
)
.await?
.unwrap()
.msg_ids[0];
let msg = Message::load_from_db(bob, msg_id).await?;
assert!(msg.get_showpadlock());
Ok(())
}
/// Tests that timestamp of signed but not encrypted message is protected.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_protected_date() -> Result<()> {
@@ -2084,48 +2063,3 @@ async fn test_4k_image_stays_image() -> Result<()> {
assert_eq!(msg.param.get_int(Param::Height).unwrap_or_default(), 2160);
Ok(())
}
/// Tests that if multiple alternatives are available in multipart/alternative,
/// the last one is preferred.
///
/// RFC 2046 says the last supported alternative should be preferred:
/// <https://datatracker.ietf.org/doc/html/rfc2046#section-5.1.4>
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn prefer_last_alternative() {
let mut tcm = TestContextManager::new();
let context = &tcm.alice().await;
let raw = br#"From: Bob <bob@example.net>
To: Alice <alice@example.org>
Subject: Alternatives
Date: Tue, 5 May 2020 01:23:45 +0000
MIME-Version: 1.0
Chat-Version: 1.0
Content-Type: multipart/alternative; boundary="boundary"
This is a multipart message in MIME format.
--boundary
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: 7bit
First alternative.
--boundary
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: 7bit
Second alternative.
--boundary
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: 7bit
Third alternative.
--boundary--
"#;
let message = MimeMessage::from_bytes(context, &raw[..], None)
.await
.unwrap();
assert_eq!(message.parts.len(), 1);
assert_eq!(message.parts[0].typ, Viewtype::Text);
assert_eq!(message.parts[0].msg, "Third alternative.");
}

View File

@@ -16,12 +16,14 @@ use crate::sql::Sql;
use crate::tools::time;
pub(crate) mod dns;
pub(crate) mod error_capturing_stream;
pub(crate) mod http;
pub(crate) mod proxy;
pub(crate) mod session;
pub(crate) mod tls;
use dns::lookup_host_with_cache;
pub(crate) use error_capturing_stream::ErrorCapturingStream;
pub use http::{Response as HttpResponse, read_url, read_url_blob};
use tls::wrap_tls;
@@ -105,7 +107,7 @@ pub(crate) async fn load_connection_timestamp(
/// to the network, which is important to reduce the latency of interactive protocols such as IMAP.
pub(crate) async fn connect_tcp_inner(
addr: SocketAddr,
) -> Result<Pin<Box<TimeoutStream<TcpStream>>>> {
) -> Result<Pin<Box<ErrorCapturingStream<TimeoutStream<TcpStream>>>>> {
let tcp_stream = timeout(TIMEOUT, TcpStream::connect(addr))
.await
.context("connection timeout")?
@@ -118,7 +120,9 @@ pub(crate) async fn connect_tcp_inner(
timeout_stream.set_write_timeout(Some(TIMEOUT));
timeout_stream.set_read_timeout(Some(TIMEOUT));
Ok(Box::pin(timeout_stream))
let error_capturing_stream = ErrorCapturingStream::new(timeout_stream);
Ok(Box::pin(error_capturing_stream))
}
/// Attempts to establish TLS connection
@@ -235,7 +239,7 @@ pub(crate) async fn connect_tcp(
host: &str,
port: u16,
load_cache: bool,
) -> Result<Pin<Box<TimeoutStream<TcpStream>>>> {
) -> Result<Pin<Box<ErrorCapturingStream<TimeoutStream<TcpStream>>>>> {
let connection_futures = lookup_host_with_cache(context, host, port, "", load_cache)
.await?
.into_iter()

View File

@@ -227,6 +227,9 @@ pub(crate) async fn update_connect_timestamp(
}
/// Preloaded DNS results that can be used in case of DNS server failures.
///
/// See <https://support.delta.chat/t/no-dns-resolution-result/2778> and
/// <https://github.com/deltachat/deltachat-core-rust/issues/4920> for reasons.
static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new(|| {
HashMap::from([
(

View File

@@ -0,0 +1,136 @@
use std::io::IoSlice;
use std::net::SocketAddr;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use tokio::io::{self, AsyncRead, AsyncWrite, ReadBuf};
use pin_project::pin_project;
use crate::net::SessionStream;
/// Stream that remembers the first error
/// and keeps returning it afterwards.
///
/// It is needed to avoid accidentally using
/// the stream after read timeout.
#[derive(Debug)]
#[pin_project]
pub(crate) struct ErrorCapturingStream<T: AsyncRead + AsyncWrite + std::fmt::Debug> {
#[pin]
inner: T,
/// If true, the stream has already returned an error once.
///
/// All read and write operations return error in this case.
is_broken: bool,
}
impl<T: AsyncRead + AsyncWrite + std::fmt::Debug> ErrorCapturingStream<T> {
pub fn new(inner: T) -> Self {
Self {
inner,
is_broken: false,
}
}
/// Gets a reference to the underlying stream.
pub fn get_ref(&self) -> &T {
&self.inner
}
/// Gets a pinned mutable reference to the underlying stream.
pub fn get_pin_mut(self: Pin<&mut Self>) -> Pin<&mut T> {
self.project().inner
}
}
impl<T: AsyncRead + AsyncWrite + std::fmt::Debug> AsyncRead for ErrorCapturingStream<T> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf,
) -> Poll<io::Result<()>> {
let this = self.project();
if *this.is_broken {
return Poll::Ready(Err(io::Error::other("Broken stream")));
}
let res = this.inner.poll_read(cx, buf);
if let Poll::Ready(Err(_)) = res {
*this.is_broken = true;
}
res
}
}
impl<T: AsyncRead + AsyncWrite + std::fmt::Debug> AsyncWrite for ErrorCapturingStream<T> {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let this = self.project();
if *this.is_broken {
return Poll::Ready(Err(io::Error::other("Broken stream")));
}
let res = this.inner.poll_write(cx, buf);
if let Poll::Ready(Err(_)) = res {
*this.is_broken = true;
}
res
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
let this = self.project();
if *this.is_broken {
return Poll::Ready(Err(io::Error::other("Broken stream")));
}
let res = this.inner.poll_flush(cx);
if let Poll::Ready(Err(_)) = res {
*this.is_broken = true;
}
res
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
let this = self.project();
if *this.is_broken {
return Poll::Ready(Err(io::Error::other("Broken stream")));
}
let res = this.inner.poll_shutdown(cx);
if let Poll::Ready(Err(_)) = res {
*this.is_broken = true;
}
res
}
fn poll_write_vectored(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[IoSlice<'_>],
) -> Poll<io::Result<usize>> {
let this = self.project();
if *this.is_broken {
return Poll::Ready(Err(io::Error::other("Broken stream")));
}
let res = this.inner.poll_write_vectored(cx, bufs);
if let Poll::Ready(Err(_)) = res {
*this.is_broken = true;
}
res
}
fn is_write_vectored(&self) -> bool {
self.inner.is_write_vectored()
}
}
impl<T: SessionStream> SessionStream for ErrorCapturingStream<T> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.inner.set_read_timeout(timeout)
}
fn peer_addr(&self) -> anyhow::Result<SocketAddr> {
self.inner.peer_addr()
}
}

View File

@@ -244,7 +244,7 @@ async fn fetch_url(context: &Context, original_url: &str) -> Result<Response> {
.clone();
let req = hyper::Request::builder()
.uri(parsed_url)
.uri(parsed_url.path())
.header(hyper::header::HOST, authority.as_str())
.body(http_body_util::Empty::<Bytes>::new())?;
let response = sender.send_request(req).await?;
@@ -378,7 +378,7 @@ pub(crate) async fn post_string(context: &Context, url: &str, body: String) -> R
.context("URL has no authority")?
.clone();
let request = hyper::Request::post(parsed_url)
let request = hyper::Request::post(parsed_url.path())
.header(hyper::header::HOST, authority.as_str())
.body(body)?;
let response = sender.send_request(request).await?;
@@ -408,7 +408,7 @@ pub(crate) async fn post_form<T: Serialize + ?Sized>(
.authority()
.context("URL has no authority")?
.clone();
let request = hyper::Request::post(parsed_url)
let request = hyper::Request::post(parsed_url.path())
.header(hyper::header::HOST, authority.as_str())
.header("content-type", "application/x-www-form-urlencoded")
.body(encoded_body)?;

View File

@@ -21,9 +21,9 @@ use url::Url;
use crate::config::Config;
use crate::constants::NON_ALPHANUMERIC_WITHOUT_DOT;
use crate::context::Context;
use crate::net::connect_tcp;
use crate::net::session::SessionStream;
use crate::net::tls::wrap_rustls;
use crate::net::{ErrorCapturingStream, connect_tcp};
use crate::sql::Sql;
/// Default SOCKS5 port according to [RFC 1928](https://tools.ietf.org/html/rfc1928).
@@ -118,7 +118,7 @@ impl Socks5Config {
target_host: &str,
target_port: u16,
load_dns_cache: bool,
) -> Result<Socks5Stream<Pin<Box<TimeoutStream<TcpStream>>>>> {
) -> Result<Socks5Stream<Pin<Box<ErrorCapturingStream<TimeoutStream<TcpStream>>>>>> {
let tcp_stream = connect_tcp(context, &self.host, self.port, load_dns_cache)
.await
.context("Failed to connect to SOCKS5 proxy")?;

View File

@@ -7,6 +7,8 @@ use tokio::io::{AsyncBufRead, AsyncRead, AsyncWrite, BufStream, BufWriter};
use tokio::net::TcpStream;
use tokio_io_timeout::TimeoutStream;
use crate::net::ErrorCapturingStream;
pub(crate) trait SessionStream:
AsyncRead + AsyncWrite + Unpin + Send + Sync + std::fmt::Debug
{
@@ -61,13 +63,13 @@ impl<T: SessionStream> SessionStream for BufWriter<T> {
self.get_ref().peer_addr()
}
}
impl SessionStream for Pin<Box<TimeoutStream<TcpStream>>> {
impl SessionStream for Pin<Box<ErrorCapturingStream<TimeoutStream<TcpStream>>>> {
fn set_read_timeout(&mut self, timeout: Option<Duration>) {
self.as_mut().set_read_timeout_pinned(timeout);
self.as_mut().get_pin_mut().set_read_timeout_pinned(timeout);
}
fn peer_addr(&self) -> Result<SocketAddr> {
Ok(self.get_ref().peer_addr()?)
Ok(self.get_ref().get_ref().peer_addr()?)
}
}
impl<T: SessionStream> SessionStream for Socks5Stream<T> {

View File

@@ -120,9 +120,6 @@ pub enum Param {
/// For Messages
WebrtcRoom = b'V',
/// For Messages
WebrtcAccepted = b'7',
/// For Messages: space-separated list of messaged IDs of forwarded copies.
///
/// This is used when a [crate::message::Message] is in the
@@ -265,7 +262,7 @@ impl str::FromStr for Params {
/// or from an upgrade (when a key is dropped but was used in the past)
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let mut inner = BTreeMap::new();
let mut lines = s.split('\n').peekable();
let mut lines = s.lines().peekable();
while let Some(line) = lines.next() {
if let [key, value] = line.splitn(2, '=').collect::<Vec<_>>()[..] {
@@ -457,7 +454,6 @@ mod tests {
let mut params = Params::new();
params.set(Param::Height, "foo\nbar=baz\nquux");
params.set(Param::Width, "\n\n\na=\n=");
params.set(Param::WebrtcRoom, "foo\r\nbar\r\n\r\nbaz\r\n");
assert_eq!(params.to_string().parse::<Params>().unwrap(), params);
}

View File

@@ -48,13 +48,13 @@ use crate::mimeparser::SystemMessage;
const PUBLIC_KEY_LENGTH: usize = 32;
const PUBLIC_KEY_STUB: &[u8] = "static_string".as_bytes();
/// Store Iroh peer channels for the context.
/// Store iroh peer channels for the context.
#[derive(Debug)]
pub struct Iroh {
/// Iroh router needed for Iroh peer channels.
/// iroh router needed for iroh peer channels.
pub(crate) router: iroh::protocol::Router,
/// [Gossip] needed for Iroh peer channels.
/// [Gossip] needed for iroh peer channels.
pub(crate) gossip: Gossip,
/// Sequence numbers for gossip channels.
@@ -109,7 +109,7 @@ impl Iroh {
info!(
ctx,
"IROH_REALTIME: Joining gossip {topic} with peers: {:?}.", node_ids,
"IROH_REALTIME: Joining gossip with peers: {:?}", node_ids,
);
// Inform iroh of potentially new node addresses
@@ -138,11 +138,17 @@ impl Iroh {
Ok(Some(join_rx))
}
/// Add gossip peer to realtime channel if it is already active.
pub async fn maybe_add_gossip_peer(&self, topic: TopicId, peer: NodeAddr) -> Result<()> {
/// Add gossip peers to realtime channel if it is already active.
pub async fn maybe_add_gossip_peers(&self, topic: TopicId, peers: Vec<NodeAddr>) -> Result<()> {
if self.iroh_channels.read().await.get(&topic).is_some() {
self.router.endpoint().add_node_addr(peer.clone())?;
self.gossip.subscribe(topic, vec![peer.node_id])?;
for peer in &peers {
self.router.endpoint().add_node_addr(peer.clone())?;
}
self.gossip.subscribe_with_opts(
topic,
JoinOptions::with_bootstrap(peers.into_iter().map(|peer| peer.node_id)),
);
}
Ok(())
}
@@ -278,24 +284,18 @@ impl Context {
})
}
/// Returns [`None`] if the peer channels has not been initialized.
pub async fn get_peer_channels(&self) -> Option<tokio::sync::RwLockReadGuard<'_, Iroh>> {
tokio::sync::RwLockReadGuard::<'_, std::option::Option<Iroh>>::try_map(
self.iroh.read().await,
|opt_iroh| opt_iroh.as_ref(),
)
.ok()
}
/// Get or initialize the iroh peer channel.
pub async fn get_or_try_init_peer_channel(
&self,
) -> Result<tokio::sync::RwLockReadGuard<'_, Iroh>> {
if !self.get_config_bool(Config::WebxdcRealtimeEnabled).await? {
bail!("Attempt to initialize Iroh when realtime is disabled");
bail!("Attempt to get Iroh when realtime is disabled");
}
if let Some(lock) = self.get_peer_channels().await {
if let Ok(lock) = tokio::sync::RwLockReadGuard::<'_, std::option::Option<Iroh>>::try_map(
self.iroh.read().await,
|opt_iroh| opt_iroh.as_ref(),
) {
return Ok(lock);
}
@@ -316,17 +316,6 @@ impl Context {
}
}
}
pub(crate) async fn maybe_add_gossip_peer(&self, topic: TopicId, peer: NodeAddr) -> Result<()> {
if let Some(iroh) = &*self.iroh.read().await {
info!(
self,
"Adding (maybe existing) peer with id {} to {topic}.", peer.node_id
);
iroh.maybe_add_gossip_peer(topic, peer).await?;
}
Ok(())
}
}
/// Cache a peers [NodeId] for one topic.
@@ -359,13 +348,12 @@ pub async fn add_gossip_peer_from_header(
return Ok(());
}
let node_addr =
serde_json::from_str::<NodeAddr>(node_addr).context("Failed to parse node address")?;
info!(
context,
"Adding iroh peer with node id {} to the topic of {instance_id}.", node_addr.node_id
"Adding iroh peer with address {node_addr:?} to the topic of {instance_id}."
);
let node_addr =
serde_json::from_str::<NodeAddr>(node_addr).context("Failed to parse node address")?;
context.emit_event(EventType::WebxdcRealtimeAdvertisementReceived {
msg_id: instance_id,
@@ -383,7 +371,8 @@ pub async fn add_gossip_peer_from_header(
let relay_server = node_addr.relay_url().map(|relay| relay.as_str());
iroh_add_peer_for_topic(context, instance_id, topic, node_id, relay_server).await?;
context.maybe_add_gossip_peer(topic, node_addr).await?;
let iroh = context.get_or_try_init_peer_channel().await?;
iroh.maybe_add_gossip_peers(topic, vec![node_addr]).await?;
Ok(())
}
@@ -485,17 +474,14 @@ pub async fn send_webxdc_realtime_data(ctx: &Context, msg_id: MsgId, data: Vec<u
}
/// Leave the gossip of the webxdc with given [MsgId].
///
/// 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 `msg_id` anymore until the app
/// is open again.
pub async fn leave_webxdc_realtime(ctx: &Context, msg_id: MsgId) -> Result<()> {
let Some(iroh) = ctx.get_peer_channels().await else {
if !ctx.get_config_bool(Config::WebxdcRealtimeEnabled).await? {
return Ok(());
};
let Some(topic) = get_iroh_topic_for_msg(ctx, msg_id).await? else {
return Ok(());
};
}
let topic = get_iroh_topic_for_msg(ctx, msg_id)
.await?
.with_context(|| format!("Message {msg_id} has no gossip topic"))?;
let iroh = ctx.get_or_try_init_peer_channel().await?;
iroh.leave_realtime(topic).await?;
info!(ctx, "IROH_REALTIME: Left gossip for message {msg_id}");
@@ -569,9 +555,9 @@ mod tests {
use super::*;
use crate::{
EventType,
chat::{self, ChatId, ProtectionStatus, add_contact_to_chat, resend_msgs, send_msg},
chat::send_msg,
message::{Message, Viewtype},
test_utils::{TestContext, TestContextManager},
test_utils::TestContextManager,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -938,30 +924,8 @@ mod tests {
let alice = &mut tcm.alice().await;
let bob = &mut tcm.bob().await;
let chat = alice.create_chat(bob).await.id;
let mut instance = Message::new(Viewtype::File);
instance
.set_file_from_bytes(
alice,
"minimal.xdc",
include_bytes!("../test-data/webxdc/minimal.xdc"),
None,
)
.unwrap();
connect_alice_bob(alice, chat, &mut instance, bob).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_webxdc_resend() {
let mut tcm = TestContextManager::new();
let alice = &mut tcm.alice().await;
let bob = &mut tcm.bob().await;
let group = chat::create_group_chat(alice, ProtectionStatus::Unprotected, "group chat")
.await
.unwrap();
// Alice sends webxdc to bob
let alice_chat = alice.create_chat(bob).await;
let mut instance = Message::new(Viewtype::File);
instance
.set_file_from_bytes(
@@ -971,78 +935,7 @@ mod tests {
None,
)
.unwrap();
add_contact_to_chat(alice, group, alice.add_or_lookup_contact_id(bob).await)
.await
.unwrap();
connect_alice_bob(alice, group, &mut instance, bob).await;
// fiona joins late
let fiona = &mut tcm.fiona().await;
add_contact_to_chat(alice, group, alice.add_or_lookup_contact_id(fiona).await)
.await
.unwrap();
resend_msgs(alice, &[instance.id]).await.unwrap();
let msg = alice.pop_sent_msg().await;
let fiona_instance = fiona.recv_msg(&msg).await;
fiona_instance.chat_id.accept(fiona).await.unwrap();
assert!(fiona.ctx.iroh.read().await.is_none());
let fiona_connect_future = send_webxdc_realtime_advertisement(fiona, fiona_instance.id)
.await
.unwrap()
.unwrap();
let fiona_advert = fiona.pop_sent_msg().await;
alice.recv_msg_trash(&fiona_advert).await;
fiona_connect_future.await.unwrap();
let realtime_send_loop = async {
// Keep sending in a loop because right after joining
// Fiona may miss messages.
loop {
send_webxdc_realtime_data(alice, instance.id, b"alice -> bob & fiona".into())
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
};
let realtime_receive_loop = async {
loop {
let event = fiona.evtracker.recv().await.unwrap();
if let EventType::WebxdcRealtimeData { data, .. } = event.typ {
if data == b"alice -> bob & fiona" {
break;
} else {
panic!(
"Unexpected status update: {}",
String::from_utf8_lossy(&data)
);
}
}
}
};
tokio::select!(
_ = realtime_send_loop => {
panic!("Send loop should never finish");
},
_ = realtime_receive_loop => {
return;
}
);
}
async fn connect_alice_bob(
alice: &mut TestContext,
alice_chat_id: ChatId,
instance: &mut Message,
bob: &mut TestContext,
) {
send_msg(alice, alice_chat_id, instance).await.unwrap();
send_msg(alice, alice_chat.id, &mut instance).await.unwrap();
let alice_webxdc = alice.get_last_msg().await;
let webxdc = alice.pop_sent_msg().await;
@@ -1059,9 +952,8 @@ mod tests {
.unwrap();
let alice_advertisement = alice.pop_sent_msg().await;
let bob_advertisement_future = send_webxdc_realtime_advertisement(bob, bob_webxdc.id)
send_webxdc_realtime_advertisement(bob, bob_webxdc.id)
.await
.unwrap()
.unwrap();
let bob_advertisement = bob.pop_sent_msg().await;
@@ -1069,9 +961,8 @@ mod tests {
bob.recv_msg_trash(&alice_advertisement).await;
alice.recv_msg_trash(&bob_advertisement).await;
eprintln!("Alice and Bob wait for connection");
eprintln!("Alice waits for connection");
alice_advertisement_future.await.unwrap();
bob_advertisement_future.await.unwrap();
// Alice sends ephemeral message
eprintln!("Sending ephemeral message");
@@ -1119,6 +1010,7 @@ mod tests {
assert!(alice.ctx.iroh.read().await.is_none());
// creates iroh endpoint as side effect
leave_webxdc_realtime(alice, MsgId::new(1)).await.unwrap();
assert!(alice.ctx.iroh.read().await.is_none());
@@ -1127,19 +1019,4 @@ mod tests {
// if accidentally called with the setting disabled.
assert!(alice.ctx.get_or_try_init_peer_channel().await.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_leave_webxdc_realtime_uninitialized() {
let mut tcm = TestContextManager::new();
let alice = &mut tcm.alice().await;
alice
.set_config_bool(Config::WebxdcRealtimeEnabled, true)
.await
.unwrap();
assert!(alice.ctx.iroh.read().await.is_none());
leave_webxdc_realtime(alice, MsgId::new(1)).await.unwrap();
assert!(alice.ctx.iroh.read().await.is_none());
}
}

View File

@@ -8,9 +8,9 @@ use chrono::SubsecRound;
use deltachat_contact_tools::EmailAddress;
use pgp::armor::BlockType;
use pgp::composed::{
ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, KeyType as PgpKeyType,
Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey,
SignedSecretKey, SubkeyParamsBuilder, TheRing,
ArmorOptions, Deserializable, KeyType as PgpKeyType, Message, MessageBuilder,
SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey, SignedSecretKey,
StandaloneSignature, SubkeyParamsBuilder, TheRing,
};
use pgp::crypto::ecc_curve::ECCCurve;
use pgp::crypto::hash::HashAlgorithm;
@@ -226,7 +226,7 @@ pub fn pk_calc_signature(
plain.as_slice(),
)?;
let sig = DetachedSignature::new(signature);
let sig = StandaloneSignature::new(signature);
Ok(sig.to_armored_string(ArmorOptions::default())?)
}
@@ -245,13 +245,12 @@ pub fn pk_decrypt(
let skeys: Vec<&SignedSecretKey> = private_keys_for_decryption.iter().collect();
let empty_pw = Password::empty();
let decrypt_options = DecryptionOptions::new();
let ring = TheRing {
secret_keys: skeys,
key_passwords: vec![&empty_pw],
message_password: vec![],
session_keys: vec![],
decrypt_options,
allow_legacy: false,
};
let (msg, ring_result) = msg.decrypt_the_ring(ring, true)?;
anyhow::ensure!(
@@ -273,7 +272,7 @@ pub fn pk_decrypt(
pub fn valid_signature_fingerprints(
msg: &pgp::composed::Message,
public_keys_for_validation: &[SignedPublicKey],
) -> HashSet<Fingerprint> {
) -> Result<HashSet<Fingerprint>> {
let mut ret_signature_fingerprints: HashSet<Fingerprint> = Default::default();
if msg.is_signed() {
for pkey in public_keys_for_validation {
@@ -283,7 +282,7 @@ pub fn valid_signature_fingerprints(
}
}
}
ret_signature_fingerprints
Ok(ret_signature_fingerprints)
}
/// Validates detached signature.
@@ -294,10 +293,10 @@ pub fn pk_validate(
) -> Result<HashSet<Fingerprint>> {
let mut ret: HashSet<Fingerprint> = Default::default();
let detached_signature = DetachedSignature::from_armor_single(Cursor::new(signature))?.0;
let standalone_signature = StandaloneSignature::from_armor_single(Cursor::new(signature))?.0;
for pkey in public_keys_for_validation {
if detached_signature.verify(pkey, content).is_ok() {
if standalone_signature.verify(pkey, content).is_ok() {
let fp = pkey.dc_fingerprint();
ret.insert(fp);
}
@@ -360,7 +359,7 @@ mod tests {
let mut msg = pk_decrypt(ctext.to_vec(), private_keys_for_decryption)?;
let content = msg.as_data_vec()?;
let ret_signature_fingerprints =
valid_signature_fingerprints(&msg, public_keys_for_validation);
valid_signature_fingerprints(&msg, public_keys_for_validation)?;
Ok((msg, ret_signature_fingerprints, content))
}

View File

@@ -13,8 +13,8 @@ use std::sync::LazyLock;
// 163.md: 163.com
static P_163: Provider = Provider {
id: "163",
status: Status::Preparation,
before_login_hint: "Enable \"POP3/SMTP/IMAP\" on the website, add a third-party auth code and use that as the login password",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/163",
server: &[
@@ -98,7 +98,7 @@ static P_ALIYUN: Provider = Provider {
static P_AOL: Provider = Provider {
id: "aol",
status: Status::Preparation,
before_login_hint: "To log in to AOL, you need to set up an app password in the AOL web interface.",
before_login_hint: "To log in to AOL with Delta Chat, you need to set up an app password in the AOL web interface.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/aol",
server: &[
@@ -432,7 +432,7 @@ static P_EXAMPLE_COM: Provider = Provider {
id: "example.com",
status: Status::Broken,
before_login_hint: "Hush this provider doesn't exist!",
after_login_hint: "This provider doesn't really exist, so you can't use it :/ If you need an email provider, take a look at providers.delta.chat!",
after_login_hint: "This provider doesn't really exist, so you can't use it :/ If you need an email provider for Delta Chat, take a look at providers.delta.chat!",
overview_page: "https://providers.delta.chat/example-com",
server: &[
Server {
@@ -459,7 +459,7 @@ static P_EXAMPLE_COM: Provider = Provider {
static P_FASTMAIL: Provider = Provider {
id: "fastmail",
status: Status::Preparation,
before_login_hint: "You must create an app-specific password before you can log in.",
before_login_hint: "You must create an app-specific password for Delta Chat before you can log in.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/fastmail",
server: &[
@@ -526,7 +526,7 @@ static P_FIVE_CHAT: Provider = Provider {
static P_FREENET_DE: Provider = Provider {
id: "freenet.de",
status: Status::Preparation,
before_login_hint: "Um deine freenet.de E-Mail-Adresse zu benutzen, musst du erst auf der freenet.de-Webseite \"POP3/IMAP/SMTP\" aktivieren.",
before_login_hint: "Um deine freenet.de E-Mail-Adresse mit Delta Chat zu benutzen, musst du erst auf der freenet.de-Webseite \"POP3/IMAP/SMTP\" aktivieren.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/freenet-de",
server: &[
@@ -647,6 +647,10 @@ static P_HERMES_RADIO: Provider = Provider {
key: Config::MdnsEnabled,
value: "0",
},
ConfigDefault {
key: Config::E2eeEnabled,
value: "0",
},
ConfigDefault {
key: Config::ShowEmails,
value: "2",
@@ -659,7 +663,7 @@ static P_HERMES_RADIO: Provider = Provider {
static P_HEY_COM: Provider = Provider {
id: "hey.com",
status: Status::Broken,
before_login_hint: "hey.com does not offer the standard IMAP e-mail protocol, so you cannot log in to hey.com.",
before_login_hint: "hey.com does not offer the standard IMAP e-mail protocol, so you cannot log in with Delta Chat to hey.com.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/hey-com",
server: &[],
@@ -698,7 +702,7 @@ static P_I3_NET: Provider = Provider {
static P_ICLOUD: Provider = Provider {
id: "icloud",
status: Status::Preparation,
before_login_hint: "You must create an app-specific password before login.",
before_login_hint: "You must create an app-specific password for Delta Chat before login.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/icloud",
server: &[
@@ -783,7 +787,7 @@ static P_KONTENT_COM: Provider = Provider {
static P_MAIL_COM: Provider = Provider {
id: "mail.com",
status: Status::Preparation,
before_login_hint: "To log in, you first need to activate POP3/IMAP in your mail.com settings. Note that this is a mail.com Premium feature only.",
before_login_hint: "To log in with Delta Chat, you first need to activate POP3/IMAP in your mail.com settings. Note that this is a mail.com Premium feature only.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/mail-com",
server: &[],
@@ -824,7 +828,7 @@ static P_MAIL_DE: Provider = Provider {
static P_MAIL_RU: Provider = Provider {
id: "mail.ru",
status: Status::Preparation,
before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru, чтобы mail.ru работал с chatmail.",
before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru, чтобы mail.ru работал с Delta Chat.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/mail-ru",
server: &[
@@ -1218,8 +1222,8 @@ static P_NUBO_COOP: Provider = Provider {
// outlook.com.md: hotmail.com, outlook.com, office365.com, outlook.com.tr, live.com, outlook.de
static P_OUTLOOK_COM: Provider = Provider {
id: "outlook.com",
status: Status::Broken,
before_login_hint: "Unfortunately, Outlook does not allow using passwords anymore, per-app-passwords are currently not working.",
status: Status::Ok,
before_login_hint: "",
after_login_hint: "",
overview_page: "https://providers.delta.chat/outlook-com",
server: &[
@@ -1317,8 +1321,8 @@ static P_POSTEO: Provider = Provider {
static P_PROTONMAIL: Provider = Provider {
id: "protonmail",
status: Status::Broken,
before_login_hint: "Protonmail does not offer the standard IMAP e-mail protocol, so you cannot log in with to Protonmail.",
after_login_hint: "To use Protonmail, the IMAP bridge must be running in the background. If you have connectivity issues, double check whether it works as expected.",
before_login_hint: "Protonmail does not offer the standard IMAP e-mail protocol, so you cannot log in with Delta Chat to Protonmail.",
after_login_hint: "To use Delta Chat with Protonmail, the IMAP bridge must be running in the background. If you have connectivity issues, double check whether it works as expected.",
overview_page: "https://providers.delta.chat/protonmail",
server: &[],
opt: ProviderOptions::new(),
@@ -1358,7 +1362,7 @@ static P_PURELYMAIL_COM: Provider = Provider {
static P_QQ: Provider = Provider {
id: "qq",
status: Status::Preparation,
before_login_hint: "Manually enabling IMAP/SMTP and creating an app-specific password are required.",
before_login_hint: "Manually enabling IMAP/SMTP and creating an app-specific password for Delta Chat are required.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/qq",
server: &[
@@ -1386,7 +1390,7 @@ static P_QQ: Provider = Provider {
static P_RAMBLER_RU: Provider = Provider {
id: "rambler.ru",
status: Status::Preparation,
before_login_hint: "Чтобы войти в Рамблер/почта, необходимо предварительно включить доступ с помощью почтовых клиентов на сайте mail.rambler.ru",
before_login_hint: "Чтобы войти в Рамблер/почта через Delta Chat, необходимо предварительно включить доступ с помощью почтовых клиентов на сайте mail.rambler.ru",
after_login_hint: "",
overview_page: "https://providers.delta.chat/rambler-ru",
server: &[
@@ -1562,7 +1566,7 @@ static P_SYSTEMLI_ORG: Provider = Provider {
static P_T_ONLINE: Provider = Provider {
id: "t-online",
status: Status::Preparation,
before_login_hint: "To use a T-Online email address, you need to create an app password in the web interface.",
before_login_hint: "To use Delta Chat with a T-Online email address, you need to create an app password in the web interface.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/t-online",
server: &[
@@ -1673,7 +1677,7 @@ static P_TISCALI_IT: Provider = Provider {
static P_TUTANOTA: Provider = Provider {
id: "tutanota",
status: Status::Broken,
before_login_hint: "Tutanota does not offer the standard IMAP e-mail protocol, so you cannot log in to Tutanota.",
before_login_hint: "Tutanota does not offer the standard IMAP e-mail protocol, so you cannot log in with Delta Chat to Tutanota.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/tutanota",
server: &[],
@@ -1783,7 +1787,7 @@ static P_VIVALDI: Provider = Provider {
static P_VK_COM: Provider = Provider {
id: "vk.com",
status: Status::Preparation,
before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru https://account.mail.ru/user/2-step-auth/passwords/ чтобы vk.com работал с chatmail.",
before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru https://account.mail.ru/user/2-step-auth/passwords/ чтобы vk.com работал с Delta Chat.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/vk-com",
server: &[
@@ -1902,7 +1906,7 @@ static P_WKPB_DE: Provider = Provider {
static P_YAHOO: Provider = Provider {
id: "yahoo",
status: Status::Preparation,
before_login_hint: "To use your Yahoo email address you have to create an \"App-Password\" in the account security screen.",
before_login_hint: "To use Delta Chat with your Yahoo email address you have to create an \"App-Password\" in the account security screen.",
after_login_hint: "",
overview_page: "https://providers.delta.chat/yahoo",
server: &[
@@ -2658,4 +2662,4 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
});
pub static _PROVIDER_UPDATED: LazyLock<chrono::NaiveDate> =
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2025, 9, 4).unwrap());
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2024, 9, 13).unwrap());

View File

@@ -74,7 +74,7 @@ fn pad_device_token(s: &str) -> String {
///
/// The result is base64-encoded and not ASCII armored to avoid dealing with newlines.
pub(crate) fn encrypt_device_token(device_token: &str) -> Result<String> {
let public_key = pgp::composed::SignedPublicKey::from_asc(NOTIFIERS_PUBLIC_KEY)?;
let public_key = pgp::composed::SignedPublicKey::from_asc(NOTIFIERS_PUBLIC_KEY)?.0;
let encryption_subkey = public_key
.public_subkeys
.first()

View File

@@ -766,18 +766,19 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
authcode,
..
} => {
token::delete(context, "").await?;
token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?;
token::delete(context, token::Namespace::Auth, &authcode).await?;
context
.sync_qr_code_token_deletion(invitenumber, authcode)
.await?;
}
Qr::WithdrawVerifyGroup {
grpid,
invitenumber,
authcode,
..
} => {
token::delete(context, &grpid).await?;
token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?;
token::delete(context, token::Namespace::Auth, &authcode).await?;
context
.sync_qr_code_token_deletion(invitenumber, authcode)
.await?;

View File

@@ -2,7 +2,7 @@ use super::*;
use crate::chat::{ProtectionStatus, create_group_chat};
use crate::config::Config;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{TestContext, TestContextManager, sync};
use crate::test_utils::{TestContext, TestContextManager};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_http() -> Result<()> {
@@ -509,77 +509,6 @@ async fn test_withdraw_verifygroup() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_withdraw_multidevice() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await;
alice.set_config_bool(Config::SyncMsgs, true).await?;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
// Alice creates two QR codes on the first device:
// group QR code and contact QR code.
let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?;
let chat2_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group 2").await?;
let contact_qr = get_securejoin_qr(alice, None).await?;
let group_qr = get_securejoin_qr(alice, Some(chat_id)).await?;
let group2_qr = get_securejoin_qr(alice, Some(chat2_id)).await?;
assert!(matches!(
check_qr(alice, &contact_qr).await?,
Qr::WithdrawVerifyContact { .. }
));
assert!(matches!(
check_qr(alice, &group_qr).await?,
Qr::WithdrawVerifyGroup { .. }
));
// Sync group QR codes.
sync(alice, alice2).await;
assert!(matches!(
check_qr(alice2, &group_qr).await?,
Qr::WithdrawVerifyGroup { .. }
));
assert!(matches!(
check_qr(alice2, &group2_qr).await?,
Qr::WithdrawVerifyGroup { .. }
));
// Alice creates a contact QR code on second device
// and withdraws it.
let contact_qr2 = get_securejoin_qr(alice2, None).await?;
set_config_from_qr(alice2, &contact_qr2).await?;
assert!(matches!(
check_qr(alice2, &contact_qr2).await?,
Qr::ReviveVerifyContact { .. }
));
// Alice also withdraws second group QR code on second device.
set_config_from_qr(alice2, &group2_qr).await?;
// Sync messages are sent from Alice's second device to first device.
sync(alice2, alice).await;
// Now first device has reset all contact QR codes
// and second group QR code,
// but first group QR code is still valid.
assert!(matches!(
check_qr(alice, &contact_qr2).await?,
Qr::ReviveVerifyContact { .. }
));
assert!(matches!(
check_qr(alice, &group_qr).await?,
Qr::WithdrawVerifyGroup { .. }
));
assert!(matches!(
check_qr(alice, &group2_qr).await?,
Qr::ReviveVerifyGroup { .. }
));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_and_apply_dclogin() -> Result<()> {
let ctx = TestContext::new().await;

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