diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b85b67d1e4..53a6945a29 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,8 @@ updates: commit-message: prefix: "chore(cargo)" open-pull-requests-limit: 50 + cooldown: + default-days: 7 # Keep GitHub Actions up to date. # @@ -14,3 +16,5 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1564c7a444..e58393e3cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ permissions: {} env: RUSTFLAGS: -Dwarnings - RUST_VERSION: 1.89.0 + RUST_VERSION: 1.90.0 # Minimum Supported Rust Version MSRV: 1.85.0 @@ -71,6 +71,8 @@ jobs: 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 @@ -137,12 +139,12 @@ jobs: - name: Tests env: RUST_BACKTRACE: 1 - run: cargo nextest run --workspace + run: cargo nextest run --workspace --locked - name: Doc-Tests env: RUST_BACKTRACE: 1 - run: cargo test --workspace --doc + run: cargo test --workspace --locked --doc - name: Test cargo vendor run: cargo vendor @@ -226,9 +228,9 @@ jobs: include: # Currently used Rust version. - os: ubuntu-latest - python: 3.13 + python: 3.14 - os: macos-latest - python: 3.13 + python: 3.14 # PyPy tests - os: ubuntu-latest @@ -256,7 +258,7 @@ jobs: path: target/debug - name: Install python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} @@ -279,11 +281,11 @@ jobs: matrix: include: - os: ubuntu-latest - python: 3.13 + python: 3.14 - os: macos-latest - python: 3.13 + python: 3.14 - os: windows-latest - python: 3.13 + python: 3.14 # PyPy tests - os: ubuntu-latest @@ -303,7 +305,7 @@ jobs: persist-credentials: false - name: Install python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} diff --git a/.github/workflows/deltachat-rpc-server.yml b/.github/workflows/deltachat-rpc-server.yml index 981c0c3918..91936a1072 100644 --- a/.github/workflows/deltachat-rpc-server.yml +++ b/.github/workflows/deltachat-rpc-server.yml @@ -34,7 +34,7 @@ jobs: with: show-progress: false persist-credentials: false - - uses: DeterminateSystems/nix-installer-action@main + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31 - name: Build deltachat-rpc-server binaries run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux @@ -58,7 +58,7 @@ jobs: with: show-progress: false persist-credentials: false - - uses: DeterminateSystems/nix-installer-action@main + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31 - name: Build deltachat-rpc-server binaries run: nix build .#deltachat-rpc-server-${{ matrix.arch }} @@ -109,7 +109,7 @@ jobs: with: show-progress: false persist-credentials: false - - uses: DeterminateSystems/nix-installer-action@main + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31 - name: Build deltachat-rpc-server binaries run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android @@ -136,7 +136,7 @@ jobs: with: show-progress: false persist-credentials: false - - uses: DeterminateSystems/nix-installer-action@main + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31 - name: Download Linux aarch64 binary uses: actions/download-artifact@v5 @@ -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@v5 + uses: actions/setup-python@v6 with: python-version: 3.12 @@ -289,7 +289,7 @@ jobs: with: show-progress: false persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.11" @@ -401,7 +401,7 @@ jobs: deltachat-rpc-server/npm-package/*.tgz # Configure Node.js for publishing. - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 20 registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/jsonrpc-client-npm-package.yml b/.github/workflows/jsonrpc-client-npm-package.yml index 0c43dbb7b4..2aecdc9d3d 100644 --- a/.github/workflows/jsonrpc-client-npm-package.yml +++ b/.github/workflows/jsonrpc-client-npm-package.yml @@ -19,7 +19,7 @@ jobs: show-progress: false persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 20 registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/jsonrpc.yml b/.github/workflows/jsonrpc.yml index edccae690f..45db64efae 100644 --- a/.github/workflows/jsonrpc.yml +++ b/.github/workflows/jsonrpc.yml @@ -21,7 +21,7 @@ jobs: show-progress: false persist-credentials: false - name: Use Node.js 18.x - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: 18.x - name: Add Rust cache diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 641d82c354..33e40c1558 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -5,10 +5,12 @@ on: paths: - flake.nix - flake.lock + - .github/workflows/nix.yml push: paths: - flake.nix - flake.lock + - .github/workflows/nix.yml branches: - main @@ -23,11 +25,8 @@ jobs: with: show-progress: false persist-credentials: false - - uses: DeterminateSystems/nix-installer-action@main - - run: nix fmt - - # Check that formatting does not change anything. - - run: git diff --exit-code + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31 + - run: nix fmt flake.nix -- --check build: name: nix build @@ -85,7 +84,7 @@ jobs: with: show-progress: false persist-credentials: false - - uses: DeterminateSystems/nix-installer-action@main + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31 - run: nix build .#${{ matrix.installable }} build-macos: @@ -105,5 +104,5 @@ jobs: with: show-progress: false persist-credentials: false - - uses: DeterminateSystems/nix-installer-action@main + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31 - run: nix build .#${{ matrix.installable }} diff --git a/.github/workflows/repl.yml b/.github/workflows/repl.yml index 25e2ba74f8..ab05c67a3b 100644 --- a/.github/workflows/repl.yml +++ b/.github/workflows/repl.yml @@ -18,7 +18,7 @@ jobs: with: show-progress: false persist-credentials: false - - uses: DeterminateSystems/nix-installer-action@main + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31 - name: Build run: nix build .#deltachat-repl-win64 - name: Upload binary diff --git a/.github/workflows/upload-docs.yml b/.github/workflows/upload-docs.yml index 2615914e09..a6223b6c07 100644 --- a/.github/workflows/upload-docs.yml +++ b/.github/workflows/upload-docs.yml @@ -36,7 +36,7 @@ jobs: show-progress: false persist-credentials: false fetch-depth: 0 # Fetch history to calculate VCS version number. - - uses: DeterminateSystems/nix-installer-action@main + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31 - name: Build Python documentation run: nix build .#python-docs - name: Upload to py.delta.chat @@ -55,7 +55,7 @@ jobs: show-progress: false persist-credentials: false fetch-depth: 0 # Fetch history to calculate VCS version number. - - uses: DeterminateSystems/nix-installer-action@main + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31 - name: Build C documentation run: nix build .#docs - name: Upload to c.delta.chat @@ -78,7 +78,7 @@ jobs: persist-credentials: false fetch-depth: 0 # Fetch history to calculate VCS version number. - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '18' - name: npm install diff --git a/.github/workflows/zizmor-scan.yml b/.github/workflows/zizmor-scan.yml index 0c0cfe516a..86f9acbe2f 100644 --- a/.github/workflows/zizmor-scan.yml +++ b/.github/workflows/zizmor-scan.yml @@ -19,13 +19,13 @@ jobs: persist-credentials: false - name: Install the latest version of uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d - name: Run zizmor run: uvx zizmor --format sarif . > results.sarif - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: results.sarif category: zizmor diff --git a/.gitignore b/.gitignore index 0be33f761e..78c482c5c1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ deltachat-ffi/xml coverage/ .DS_Store .vscode +.zed python/accounts.txt python/all-testaccounts.txt tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bf720693c..56c95063bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,316 @@ # Changelog +## [2.22.0] - 2025-10-17 + +### Fixes + +- Do not notify about incoming calls for contact requests and blocked contacts. + +### Tests + +- Accept the chat with the caller before accepting calls. + +## [2.21.0] - 2025-10-16 + +### Build system + +- nix: Remove unused dependencies. + +### Features / Changes + +- TLS 1.3 session resumption. +- REPL: Add send-sync command. +- Set `User-Agent` for tile.openstreetmap.org requests. +- Cache tile.openstreetmap.org tiles for 7 days. + +### Fixes + +- Remove Exif with non-fatal errors from images. +- jsonrpc: Use Core's logic for computing VcardContact.color ([#7294](https://github.com/chatmail/core/pull/7294)). + +### Miscellaneous Tasks + +- deps: Bump cachix/install-nix-action from 31.7.0 to 31.8.0. +- cargo: Bump async_zip from 0.0.17 to 0.0.18 ([#7257](https://github.com/chatmail/core/pull/7257)). +- deps: Bump github/codeql-action from 3 to 4 ([#7304](https://github.com/chatmail/core/pull/7304)). + +### Refactor + +- Use rustls reexported from tokio_rustls. +- Pass ALPN around as &str. +- mimeparser: Store only one signature fingerprint. + +### Tests + +- Test expiration of ephemeral messages with unknown viewtype. +- Test expiration of non-ephemeral message with unknown viewtype. + +## [2.20.0] - 2025-10-13 + +This release fixes a bug that resulted in ephemeral loop getting stuck in infinite loop +when trying to delete a message with unknown viewtype. + +### Fixes + +- Accept unknown viewtype in ephemeral loop. +- Accept unknown viewtype in delete-old-messages loop. + +## [2.19.0] - 2025-10-12 + +### Features / Changes + +- Slightly increase saturation of colors. + +### Fixes + +- Do not fail to receive call accepted/ended messages referring to non-call Message-ID. +- Do not fail to fully download previously trashed messages. +- Emit AccountsItemChanged when own key is generated/imported, use gray self-color until that ([#7296](https://github.com/chatmail/core/pull/7296)). +- Do not try to process calls from partial messages. + +### CI + +- Update to Python 3.14. + +### Refactor + +- Use variables directly in formatted strings ([#7284](https://github.com/chatmail/core/pull/7284)). +- Set_chat_profile_image(): Remove !chat.is_mailing_list() check. + +### Miscellaneous Tasks + +- cargo: Bump quick-xml from 0.37.5 to 0.38.3. +- Add nodejs to nix dev env ([#7283](https://github.com/chatmail/core/pull/7283)) + +## [2.18.0] - 2025-10-08 + +### API-Changes + +- [**breaking**] Remove APIs for video chat invitations. + +### CI + +- nix: Run the workflow when workflow file changes. +- nix: Switch from DeterminateSystems/nix-installer-action to cachix/install-nix-action. + +### Features / Changes + +- No implicit member changes from old Delta Chat clients ([#7220](https://github.com/chatmail/core/pull/7220)). + +### Fixes + +- Do not fail to load messages with unknown viewtype. +- Only omit group changes messages if SELF is really added ([#7220](https://github.com/chatmail/core/pull/7220)). + +### Refactor + +- Assert that Iroh node addresses have home relay URL. + +## [2.17.0] - 2025-10-04 + +### API-Changes + +- [**breaking**] Remove deprecated verified_one_on_one_chats config. + +### CI + +- Require that Cargo.lock is up to date. +- Fix CI checking Nix formatting. + +### Documentation + +- Comment about outdated timespan. +- Clarify CALL events ([#7188](https://github.com/chatmail/core/pull/7188)). +- Add docs for JS `BaseDeltaChat`. + +### Features / Changes + +- Make `text/calendar` alternative available as an attachment. +- Better summary for calls. +- Add strings 'You left the channel.' and 'Scan to join Channel' ([#7266](https://github.com/chatmail/core/pull/7266)). +- Stock strings for calls. +- ffi: Add DC_STR_CANT_DECRYPT_OUTGOING_MSGS define. + +### Fixes + +- Prefer last part in `multipart/alternative`. +- Prefetch messages in limited batches ([#6915](https://github.com/chatmail/core/pull/6915)). +- Forward calls as text messages. +- Consistent spelling of "canceled" with a single "l". +- Lowercase "call" in "Missed call" and similar strings. + +### Refactor + +- Return the reason when failing to place calls. + +### Tests + +- Test reception of `multipart/alternative` with `text/calendar`. + +## [2.16.0] - 2025-10-01 + +### API-Changes + +- [**breaking**] Get rid of inviter progress other than 0 and 1000. +- Add has_video attribute to incoming call events. +- Add JSON-RPC API to get ICE servers. +- Add call_info() JSON-RPC API. +- Add chat ID to SecureJoinInviterProgress. +- deltachat-rpc-client: Add Chat.resend_messages(). +- Add `chat_id` to all call events ([#7216](https://github.com/chatmail/core/pull/7216)). + +### Build system + +- Update rPGP from 0.16.0 to 0.17.0. + +### CI + +- Update Rust to 1.90.0. +- Install rustfmt before checking provider database. + +### Documentation + +- Add more `get_next_event` docs. +- SecurejoinInviterProgress never returns an error. + +### Features / Changes + +- Don't fetch messages from unknown folders ([#7190](https://github.com/chatmail/core/pull/7190)). +- Get ICE servers from IMAP METADATA. +- Don't ignore receive_imf_inner() errors, try adding partially downloaded message instead ([#7196](https://github.com/chatmail/core/pull/7196)). +- Set dimensions for outgoing Sticker messages. + +### Fixes + +- Create 1:1 chat only if auth token is for setup contact. +- Ignore vc-/vg- prefix for SecurejoinInviterProgress. +- Don't init Iroh on channel leave ([#7210](https://github.com/chatmail/core/pull/7210)). +- Take the last valid Autocrypt header ([#7167](https://github.com/chatmail/core/pull/7167)). +- Don't add "member removed" messages from nonmembers ([#7207](https://github.com/chatmail/core/pull/7207)). +- Do not consider the call stale if it is not sent out yet. +- Receive_imf: Report replaced message id in `MsgsChanged` if chat is the same. +- Allow Exif for stickers, don't recode them because of that ([#6447](https://github.com/chatmail/core/pull/6447)). + +### Refactor + +- Remove unused prop (TS, `BaseDeltaChat`). +- Remove unused FolderMeaning::Drafts. + +### Tests + +- Rename test_udpate_call_text into test_update_call_text. +- Update timestamp_sent in pop_sent_msg_opt(). +- Do not match call ID from second alice with first alice event. + +## [2.15.0] - 2025-09-15 + +### API-Changes + +- Add JSON-RPC API for calls ([#7194](https://github.com/chatmail/core/pull/7194)). + +### Build system + +- Remove unused `quoted_printable` dependency. + +## [2.14.0] - 2025-09-12 + +### API-Changes + +- Put the chattype into the SecurejoinInviterProgress event ([#7181](https://github.com/chatmail/core/pull/7181)). + +### Fixes + +- param: Split params only on \n. +- B-encode SDP offer and answer sent in headers. + +### Refactor + +- Use recv_msg_trash() instead of recv_msg_opt(). +- Prepare_msg_raw(): don't return MsgId. + +### Tests + +- Message is OutFailed if all keys are missing ([#6849](https://github.com/chatmail/core/pull/6849)). +- Test sending SDP offer and answer with newlines. + +## [2.13.0] - 2025-09-09 + +### API-Changes + +- [**breaking**] Remove `is_profile_verified` APIs. +- [**breaking**] Remove deprecated `is_protection_broken`. +- [**breaking**] Remove `e2ee_enabled` preference. + +### Features / Changes + +- Add call ringing API ([#6650](https://github.com/chatmail/core/pull/6650), [#7174](https://github.com/chatmail/core/pull/7174), [#7175](https://github.com/chatmail/core/pull/7175), [#7179](https://github.com/chatmail/core/pull/7179)) +- Warn for outdated versions after 6 months instead of 1 year ([#7144](https://github.com/chatmail/core/pull/7144)). +- Do not set "unknown sender for this chat" error. +- Do not replace messages with an error on verification failure. +- Support receiving Autocrypt-Gossip with `_verified` attribute. +- Withdraw all QR codes when one is withdrawn. + +### Fixes + +- Don't reverify contacts by SELF on receipt of a message from another device. +- Don't verify contacts by others having an unknown verifier. +- Update verifier_id if it's "unknown" and new verifier has known verifier. +- Mark message as failed if it can't be sent ([#7143](https://github.com/chatmail/core/pull/7143)). +- Add "Messages are end-to-end encrypted." to non-protected groups. + +### Documentation + +- Fix for SecurejoinInviterProgress with progress == 600. +- STYLE.md: Prefer BTreeMap and BTreeSet over hash variants. + +### Miscellaneous Tasks + +- Update provider database. +- Update dependencies. + +### Refactor + +- Check that verifier is verified in turn. +- Remove unused `EncryptPreference::Reset`. +- Remove `Aheader::new`. + +### Tests + +- Add another TimeShiftFalsePositiveNote ([#7142](https://github.com/chatmail/core/pull/7142)). +- Add TestContext.create_chat_id. + +## [2.12.0] - 2025-08-26 + +### API-Changes + +- api!(python): remove remaining broken API for reactions + +### Features / Changes + +- Use Group ID for chat color generation instead of the name for encrypted groups. +- Use key fingerprints instead of addresses for key-contacts color generation. +- Replace HSLuv colors with OKLCh. +- `wal_checkpoint()`: Do `wal_checkpoint(PASSIVE)` and `wal_checkpoint(FULL)` before `wal_checkpoint(TRUNCATE)`. +- Assign messages to key-contacts based on Issuer Fingerprint. +- Create_group_ex(): Log and replace invalid chat name with "…". + +### Fixes + +- Do not create a group if the sender includes self in the `To` field. +- Do not reverify already verified contacts via gossip. +- `get_connectivity()`: Get rid of locking SchedulerState::inner ([#7124](https://github.com/chatmail/core/pull/7124)). +- Make reaction message hidden only if there are no other parts. + +### Refactor + +- Do not return `Result` from `valid_signature_fingerprints()`. +- Make `ConnectivityStore` use a non-async lock ([#7129](https://github.com/chatmail/core/pull/7129)). + +### Documentation + +- Remove broken link from documentation comments. +- Remove the comment about Color Vision Deficiency correction. + ## [2.11.0] - 2025-08-13 ### Features / Changes @@ -1600,7 +1911,7 @@ This reverts commit 6f22ce2722b51773d7fbb0d89e4764f963cafd91.. ### Fixes - Reset quota on configured address change ([#5908](https://github.com/chatmail/core/pull/5908)). -- Do not emit progress 1000 when configuration is cancelled. +- Do not emit progress 1000 when configuration is canceled. - Assume file extensions are 32 chars max and don't contain whitespace ([#5338](https://github.com/chatmail/core/pull/5338)). - Re-add tokens.foreign_id column ([#6038](https://github.com/chatmail/core/pull/6038)). @@ -4048,7 +4359,7 @@ Bugfix release attempting to fix the [iOS build error](https://github.com/chatma - Recreate `smtp` table with AUTOINCREMENT `id` ([#4390](https://github.com/chatmail/core/pull/4390)). - Do not return an error from `send_msg_to_smtp` if retry limit is exceeded. - Make the bots automatically accept group chat contact requests ([#4377](https://github.com/chatmail/core/pull/4377)). -- Delete `smtp` rows when message sending is cancelled ([#4391](https://github.com/chatmail/core/pull/4391)). +- Delete `smtp` rows when message sending is canceled ([#4391](https://github.com/chatmail/core/pull/4391)). ### Refactor @@ -4059,7 +4370,7 @@ Bugfix release attempting to fix the [iOS build error](https://github.com/chatma ### Fixes - Fetch at most 100 existing messages even if EXISTS was not received. -- Delete `smtp` rows when message sending is cancelled. +- Delete `smtp` rows when message sending is canceled. ### Changes @@ -4146,14 +4457,14 @@ Bugfix release attempting to fix the [iOS build error](https://github.com/chatma ## [1.112.3] - 2023-03-30 ### Fixes -- `transfer::get_backup` now frees ongoing process when cancelled. #4249 +- `transfer::get_backup` now frees ongoing process when canceled. #4249 ## [1.112.2] - 2023-03-30 ### Changes - Update iroh, remove `default-net` from `[patch.crates-io]` section. - transfer backup: Connect to multiple provider addresses concurrently. This should speed up connection time significantly on the getter side. #4240 -- Make sure BackupProvider is cancelled on drop (or `dc_backup_provider_unref`). The BackupProvider will now always finish with an IMEX event of 1000 or 0, previously it would sometimes finished with 1000 (success) when it really was 0 (failure). #4242 +- Make sure BackupProvider is canceled on drop (or `dc_backup_provider_unref`). The BackupProvider will now always finish with an IMEX event of 1000 or 0, previously it would sometimes finished with 1000 (success) when it really was 0 (failure). #4242 ### Fixes - Do not return media from trashed messages in the "All media" view. #4247 @@ -6646,3 +6957,14 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed [2.9.0]: https://github.com/chatmail/core/compare/v2.8.0..v2.9.0 [2.10.0]: https://github.com/chatmail/core/compare/v2.9.0..v2.10.0 [2.11.0]: https://github.com/chatmail/core/compare/v2.10.0..v2.11.0 +[2.12.0]: https://github.com/chatmail/core/compare/v2.11.0..v2.12.0 +[2.13.0]: https://github.com/chatmail/core/compare/v2.12.0..v2.13.0 +[2.14.0]: https://github.com/chatmail/core/compare/v2.13.0..v2.14.0 +[2.15.0]: https://github.com/chatmail/core/compare/v2.14.0..v2.15.0 +[2.16.0]: https://github.com/chatmail/core/compare/v2.15.0..v2.16.0 +[2.17.0]: https://github.com/chatmail/core/compare/v2.16.0..v2.17.0 +[2.18.0]: https://github.com/chatmail/core/compare/v2.17.0..v2.18.0 +[2.19.0]: https://github.com/chatmail/core/compare/v2.18.0..v2.19.0 +[2.20.0]: https://github.com/chatmail/core/compare/v2.19.0..v2.20.0 +[2.21.0]: https://github.com/chatmail/core/compare/v2.20.0..v2.21.0 +[2.22.0]: https://github.com/chatmail/core/compare/v2.21.0..v2.22.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4385f06429..00b4b52005 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ If you want to contribute a code, follow this guide. The following prefix types are used: - `feat`: Features, e.g. "feat: Pause IO for BackupProvider". If you are unsure what's the category of your commit, you can often just use `feat`. - - `fix`: Bug fixes, e.g. "fix: delete `smtp` rows when message sending is cancelled" + - `fix`: Bug fixes, e.g. "fix: delete `smtp` rows when message sending is canceled" - `api`: API changes, e.g. "api(rust): add `get_msg_read_receipts(context, msg_id)`" - `refactor`: Refactorings, e.g. "refactor: iterate over `msg_ids` without `.iter()`" - `perf`: Performance improvements, e.g. "perf: improve SQLite performance with `PRAGMA synchronous=normal`" diff --git a/Cargo.lock b/Cargo.lock index 1884d3174a..d574d22f74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,12 +104,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -133,9 +127,9 @@ checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" dependencies = [ "backtrace", ] @@ -189,7 +183,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] @@ -201,7 +195,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -324,7 +318,7 @@ dependencies = [ "log", "nom 8.0.0", "pin-project", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", ] @@ -336,7 +330,7 @@ checksum = "644dd749086bf3771a2fbc5f256fdb982d53f011c7d5d560304eafeecebce79d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -352,15 +346,15 @@ dependencies = [ [[package]] name = "async_zip" -version = "0.0.17" +version = "0.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52" +checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6" dependencies = [ "async-compression", "crc32fast", "futures-lite", "pin-project", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tokio-util", ] @@ -440,23 +434,23 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bitfields" -version = "0.12.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d84268bbf9b487d31fe4b849edbefcd3911422d7a07de855a2da1f70ab3d1c" +checksum = "dcdbce6688e3ab66aff2ab413b762ccde9f37990e27bba0bb38a4b2ad1b5d877" dependencies = [ "bitfields-impl", ] [[package]] name = "bitfields-impl" -version = "0.9.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c93edde7bb4416c35c85048e34f78999dcb47d199bde3b1d79286156f3e2fb" +checksum = "57413e4b276d883b77fb368b7b33ae6a5eb97692852d49a5394d4f72ba961827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", - "thiserror 2.0.12", + "syn 2.0.106", + "thiserror 2.0.17", ] [[package]] @@ -595,7 +589,7 @@ dependencies = [ "proc-macro-crate 2.0.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -634,9 +628,9 @@ checksum = "102dbef1187b1893e6dfe05a774e79fd52265f49f214f6879c8ff49f52c8188b" [[package]] name = "brotli" -version = "8.0.1" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -823,11 +817,10 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "num-traits", "serde", @@ -929,6 +922,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorutils-rs" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103c2458789cd7b46e6ed7c7ba1bf969b6569c902e3732843c55962c53eac686" +dependencies = [ + "erydanos", + "half", + "num-traits", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1213,7 +1217,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1254,7 +1258,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1265,7 +1269,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1285,7 +1289,7 @@ dependencies = [ [[package]] name = "deltachat" -version = "2.11.0" +version = "2.22.0" dependencies = [ "anyhow", "async-broadcast", @@ -1299,6 +1303,7 @@ dependencies = [ "brotli", "bytes", "chrono", + "colorutils-rs", "criterion", "data-encoding", "deltachat-contact-tools", @@ -1311,7 +1316,6 @@ dependencies = [ "futures", "futures-lite", "hex", - "hickory-resolver", "http-body-util", "humansize", "hyper", @@ -1337,15 +1341,13 @@ dependencies = [ "proptest", "qrcodegen", "quick-xml", - "quoted_printable", "rand 0.8.5", "ratelimit", "regex", "rusqlite", - "rust-hsluv", - "rustls", "rustls-pki-types", "sanitize-filename", + "sdp", "serde", "serde_json", "serde_urlencoded", @@ -1359,7 +1361,7 @@ dependencies = [ "tempfile", "testdir", "textwrap", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-io-timeout", "tokio-rustls", @@ -1395,7 +1397,7 @@ dependencies = [ [[package]] name = "deltachat-jsonrpc" -version = "2.11.0" +version = "2.22.0" dependencies = [ "anyhow", "async-channel 2.5.0", @@ -1417,7 +1419,7 @@ dependencies = [ [[package]] name = "deltachat-repl" -version = "2.11.0" +version = "2.22.0" dependencies = [ "anyhow", "deltachat", @@ -1433,7 +1435,7 @@ dependencies = [ [[package]] name = "deltachat-rpc-server" -version = "2.11.0" +version = "2.22.0" dependencies = [ "anyhow", "deltachat", @@ -1457,12 +1459,12 @@ name = "deltachat_derive" version = "2.0.0" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "deltachat_ffi" -version = "2.11.0" +version = "2.22.0" dependencies = [ "anyhow", "deltachat", @@ -1472,7 +1474,7 @@ dependencies = [ "num-traits", "rand 0.8.5", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "yerpc", ] @@ -1511,7 +1513,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1541,7 +1543,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1551,7 +1553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1580,7 +1582,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "unicode-xid", ] @@ -1592,7 +1594,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "unicode-xid", ] @@ -1658,7 +1660,7 @@ checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1721,7 +1723,7 @@ checksum = "7a4102713839a8c01c77c165bc38ef2e83948f6397fa1e1dcfacec0f07b149d3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1850,7 +1852,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1870,7 +1872,7 @@ checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1895,6 +1897,15 @@ version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5d9305ccc6942a704f4335694ecd3de2ea531b114ac2d51f5f843750787a92f" +[[package]] +name = "erydanos" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cbdc4987ed8e9ece64845393c2d53596b3a4ccbfb3948d799d58f6450e89fb1" +dependencies = [ + "num-traits", +] + [[package]] name = "escaper" version = "0.1.1" @@ -2153,9 +2164,9 @@ checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ "fastrand", "futures-core", @@ -2172,7 +2183,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2332,9 +2343,9 @@ dependencies = [ [[package]] name = "half" -version = "2.4.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eceaaeec696539ddaf7b333340f1af35a5aa87ae3e4f3ead0532f72affab2e" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" dependencies = [ "cfg-if", "crunchy", @@ -2410,7 +2421,7 @@ dependencies = [ "once_cell", "rand 0.9.0", "ring", - "thiserror 2.0.12", + "thiserror 2.0.17", "tinyvec", "tokio", "tracing", @@ -2433,7 +2444,7 @@ dependencies = [ "rand 0.9.0", "resolv-conf", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tracing", ] @@ -2580,13 +2591,14 @@ dependencies = [ [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http 1.1.0", "http-body", @@ -2594,6 +2606,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -2619,9 +2632,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "bytes", "futures-channel", @@ -2776,7 +2789,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2838,15 +2851,16 @@ dependencies = [ [[package]] name = "image" -version = "0.25.6" +version = "0.25.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" dependencies = [ "bytemuck", "byteorder-lite", "color_quant", "gif", "image-webp", + "moxcms", "num-traits", "png", "zune-core", @@ -2874,9 +2888,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", "hashbrown 0.15.4", @@ -2970,7 +2984,7 @@ dependencies = [ "strum 0.26.2", "stun-rs", "surge-ping", - "thiserror 2.0.12", + "thiserror 2.0.17", "time", "tokio", "tokio-stream", @@ -2995,7 +3009,7 @@ dependencies = [ "ed25519-dalek", "rand_core 0.6.4", "serde", - "thiserror 2.0.12", + "thiserror 2.0.17", "url", ] @@ -3037,7 +3051,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "serde-error", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-util", "tracing", @@ -3065,7 +3079,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3082,7 +3096,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -3102,7 +3116,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.12", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -3157,7 +3171,7 @@ dependencies = [ "sha1", "strum 0.26.2", "stun-rs", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-rustls", "tokio-util", @@ -3237,9 +3251,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.174" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "libm" @@ -3314,9 +3328,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "loom" @@ -3371,11 +3385,11 @@ dependencies = [ [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -3452,6 +3466,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "moxcms" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "mutate_once" version = "0.1.1" @@ -3588,7 +3612,7 @@ dependencies = [ "log", "netlink-packet-core", "netlink-sys", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] @@ -3709,12 +3733,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" dependencies = [ - "overload", - "winapi", + "windows-sys 0.52.0", ] [[package]] @@ -3759,7 +3782,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3820,7 +3843,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3898,7 +3921,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3946,12 +3969,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "p256" version = "0.13.2" @@ -4057,9 +4074,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" @@ -4068,7 +4085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" dependencies = [ "memchr", - "thiserror 2.0.12", + "thiserror 2.0.17", "ucd-trie", ] @@ -4092,7 +4109,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4108,9 +4125,9 @@ dependencies = [ [[package]] name = "pgp" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91d320242d9b686612b15526fe38711afdf856e112eaa4775ce25b0d9b12b11" +checksum = "7d918d5da2ce943e4c6088d7694f33f47c19374d6f0f2080a0c5e8010afdfd29" dependencies = [ "aead", "aes", @@ -4150,7 +4167,7 @@ dependencies = [ "k256", "log", "md-5", - "nom 7.1.3", + "nom 8.0.0", "num-bigint-dig", "num-traits", "num_enum", @@ -4160,6 +4177,7 @@ dependencies = [ "p521", "rand 0.8.5", "regex", + "replace_with", "ripemd", "rsa", "sha1", @@ -4201,7 +4219,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4240,7 +4258,7 @@ dependencies = [ "serde", "sha1_smol", "simple-dns", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tracing", "url", @@ -4320,7 +4338,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4346,11 +4364,11 @@ dependencies = [ [[package]] name = "png" -version = "0.17.16" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.9.1", "crc32fast", "fdeflate", "flate2", @@ -4552,7 +4570,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4566,9 +4584,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f" +checksum = "2bb0be07becd10686a0bb407298fb425360a5c44a663774406340c59a22de4ce" dependencies = [ "bitflags 2.9.1", "lazy_static", @@ -4576,10 +4594,19 @@ dependencies = [ "rand 0.9.0", "rand_chacha 0.9.0", "rand_xorshift", - "regex-syntax 0.8.2", + "regex-syntax", "unarray", ] +[[package]] +name = "pxfm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83f9b339b02259ada5c0f4a389b7fb472f933aa17ce176fd2ad98f28bb401fde" +dependencies = [ + "num-traits", +] + [[package]] name = "qr2term" version = "0.3.3" @@ -4610,9 +4637,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" dependencies = [ "memchr", ] @@ -4630,7 +4657,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tracing", ] @@ -4649,7 +4676,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.12", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -4671,9 +4698,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] @@ -4866,7 +4893,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] @@ -4877,17 +4904,8 @@ checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.6", - "regex-syntax 0.8.2", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", + "regex-automata", + "regex-syntax", ] [[package]] @@ -4898,7 +4916,7 @@ checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.2", + "regex-syntax", ] [[package]] @@ -4909,15 +4927,15 @@ checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" [[package]] name = "regex-syntax" -version = "0.6.29" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" [[package]] -name = "regex-syntax" -version = "0.8.2" +name = "replace_with" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" [[package]] name = "reqwest" @@ -5059,12 +5077,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rust-hsluv" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efe2374f2385cdd8755a446f80b2a646de603c9d8539ca38734879b5c71e378b" - [[package]] name = "rustc-demangle" version = "0.1.24" @@ -5258,7 +5270,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5273,6 +5285,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdp" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd277015eada44a0bb810a4b84d3bf6e810573fa62fb442f457edf6a1087a69" +dependencies = [ + "rand 0.8.5", + "substring", + "thiserror 1.0.69", + "url", +] + [[package]] name = "sec1" version = "0.7.3" @@ -5344,10 +5368,11 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] @@ -5360,15 +5385,24 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5379,28 +5413,29 @@ checksum = "330f01ce65a3a5fe59a60c82f3c9a024b573b8a6e875bd233fe5f934e71d54e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "serde_json" -version = "1.0.142" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] name = "serde_spanned" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -5523,7 +5558,7 @@ dependencies = [ "shadowsocks-crypto", "socket2", "spin 0.10.0", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-tfo", "trait-variant", @@ -5644,7 +5679,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5731,7 +5766,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5743,7 +5778,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5770,6 +5805,15 @@ dependencies = [ "rand 0.9.0", ] +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5805,9 +5849,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -5831,7 +5875,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5889,9 +5933,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.3", @@ -5937,11 +5981,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.17", ] [[package]] @@ -5952,18 +5996,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6079,7 +6123,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6138,16 +6182,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.14" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", "futures-util", - "hashbrown 0.14.5", "pin-project-lite", "tokio", ] @@ -6176,17 +6219,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.4" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ae868b5a0f67631c14589f7e250c1ea2c574ee5ba21c6c8dd4b1485705a5a1" +checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" dependencies = [ "indexmap", - "serde", + "serde_core", "serde_spanned", - "toml_datetime 0.7.0", + "toml_datetime 0.7.2", "toml_parser", "toml_writer", - "winnow 0.7.11", + "winnow 0.7.13", ] [[package]] @@ -6197,11 +6240,11 @@ checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" [[package]] name = "toml_datetime" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -6224,16 +6267,16 @@ dependencies = [ "indexmap", "toml_datetime 0.6.11", "toml_write", - "winnow 0.7.11", + "winnow 0.7.13", ] [[package]] name = "toml_parser" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" dependencies = [ - "winnow 0.7.11", + "winnow 0.7.13", ] [[package]] @@ -6244,9 +6287,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" +checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" [[package]] name = "tower" @@ -6295,7 +6338,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6321,14 +6364,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -6345,7 +6388,7 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6390,7 +6433,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6501,9 +6544,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.3", "js-sys", @@ -6591,7 +6634,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-shared", ] @@ -6626,7 +6669,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -6809,7 +6852,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6820,7 +6863,7 @@ checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6831,7 +6874,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6842,14 +6885,14 @@ checksum = "cb26fd936d991781ea39e87c3a27285081e3c0da5ca0fcbc02d368cc6f52ff01" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] name = "windows-link" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" [[package]] name = "windows-registry" @@ -7122,9 +7165,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.11" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -7158,7 +7201,7 @@ dependencies = [ "futures", "log", "serde", - "thiserror 2.0.12", + "thiserror 2.0.17", "windows 0.59.0", "windows-core 0.59.0", ] @@ -7303,7 +7346,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -7326,7 +7369,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] @@ -7363,7 +7406,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -7374,7 +7417,7 @@ checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -7394,7 +7437,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] @@ -7415,7 +7458,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -7437,7 +7480,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b2ff8a88ec..43bd54326c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deltachat" -version = "2.11.0" +version = "2.22.0" edition = "2024" license = "MPL-2.0" rust-version = "1.85" @@ -47,11 +47,13 @@ async-channel = { workspace = true } async-imap = { version = "0.11.1", 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"] } +async_zip = { version = "0.0.18", 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" @@ -59,7 +61,6 @@ fd-lock = "4" futures-lite = { workspace = true } futures = { workspace = true } hex = "0.4.0" -hickory-resolver = "0.25.2" http-body-util = "0.1.3" humansize = "2" hyper = "1" @@ -77,18 +78,16 @@ num-derive = "0.4" num-traits = { workspace = true } parking_lot = "0.12.4" percent-encoding = "2.3" -pgp = { version = "0.16.0", default-features = false } +pgp = { version = "0.17.0", default-features = false } pin-project = "1" qrcodegen = "1.7.0" -quick-xml = "0.37" -quoted_printable = "0.5" +quick-xml = { version = "0.38", features = ["escape-html"] } 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"] } @@ -112,7 +111,6 @@ 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. @@ -177,16 +175,16 @@ harness = false anyhow = "1" async-channel = "2.5.0" base64 = "0.22" -chrono = { version = "0.4.41", default-features = false } +chrono = { version = "0.4.42", default-features = false } deltachat-contact-tools = { path = "deltachat-contact-tools" } deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false } deltachat = { path = ".", default-features = false } futures = "0.3.31" -futures-lite = "2.6.0" +futures-lite = "2.6.1" libc = "0.2" log = "0.4" mailparse = "0.16.1" -nu-ansi-term = "0.46" +nu-ansi-term = "0.50" num-traits = "0.2" rand = "0.8" regex = "1.10" @@ -194,10 +192,10 @@ rusqlite = "0.36" sanitize-filename = "0.5" serde = "1.0" serde_json = "1" -tempfile = "3.20.0" +tempfile = "3.23.0" thiserror = "2" tokio = "1" -tokio-util = "0.7.14" +tokio-util = "0.7.16" tracing-subscriber = "0.3" yerpc = "0.6.4" diff --git a/STYLE.md b/STYLE.md index 08a01f7922..c67f77102a 100644 --- a/STYLE.md +++ b/STYLE.md @@ -112,6 +112,18 @@ Follow 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. diff --git a/deltachat-contact-tools/src/lib.rs b/deltachat-contact-tools/src/lib.rs index d9264ebf66..8ea7c0b6db 100644 --- a/deltachat-contact-tools/src/lib.rs +++ b/deltachat-contact-tools/src/lib.rs @@ -68,7 +68,7 @@ impl ContactAddress { pub fn new(s: &str) -> Result { let addr = addr_normalize(s); if !may_be_valid_addr(&addr) { - bail!("invalid address {:?}", s); + bail!("invalid address {s:?}"); } Ok(Self(addr.to_string())) } @@ -257,16 +257,16 @@ impl EmailAddress { .chars() .any(|c| c.is_whitespace() || c == '<' || c == '>') { - bail!("Email {:?} must not contain whitespaces, '>' or '<'", input); + bail!("Email {input:?} must not contain whitespaces, '>' or '<'"); } match &parts[..] { [domain, local] => { if local.is_empty() { - bail!("empty string is not valid for local part in {:?}", input); + bail!("empty string is not valid for local part in {input:?}"); } if domain.is_empty() { - bail!("missing domain after '@' in {:?}", input); + bail!("missing domain after '@' in {input:?}"); } if domain.ends_with('.') { bail!("Domain {domain:?} should not contain the dot in the end"); @@ -276,7 +276,7 @@ impl EmailAddress { domain: (*domain).to_string(), }) } - _ => bail!("Email {:?} must contain '@' character", input), + _ => bail!("Email {input:?} must contain '@' character"), } } } diff --git a/deltachat-ffi/Cargo.toml b/deltachat-ffi/Cargo.toml index afc09173f8..67f2b4f87c 100644 --- a/deltachat-ffi/Cargo.toml +++ b/deltachat-ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deltachat_ffi" -version = "2.11.0" +version = "2.22.0" description = "Deltachat FFI" edition = "2018" readme = "README.md" diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index beb1f4a32c..a1648352c9 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -415,7 +415,6 @@ 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 @@ -459,12 +458,6 @@ char* dc_get_blobdir (const dc_context_t* context); * The library uses the `media_quality` setting to use different defaults * for recoding images sent with type #DC_MSG_IMAGE. * If needed, recoding other file types is up to the UI. - * - `webrtc_instance` = webrtc instance to use for videochats in the form - * `[basicwebrtc:|jitsi:]https://example.com/subdir#roomname=$ROOM` - * if the URL is prefixed by `basicwebrtc`, the server is assumed to be of the type - * https://github.com/cracker0dks/basicwebrtc which some UIs have native support for. - * The type `jitsi:` may be handled by external apps. - * If no type is prefixed, the videochat is handled completely in a browser. * - `bot` = Set to "1" if this is a bot. * Prevents adding the "Device messages" and "Saved messages" chats, * adds Auto-Submitted header to outgoing messages, @@ -576,11 +569,10 @@ int dc_set_stock_translation(dc_context_t* context, uint32_t stock_i /** * Set configuration values from a QR code. * Before this function is called, dc_check_qr() should confirm the type of the - * QR code is DC_QR_ACCOUNT, DC_QR_LOGIN or DC_QR_WEBRTC_INSTANCE. + * QR code is DC_QR_ACCOUNT or DC_QR_LOGIN. * * Internally, the function will call dc_set_config() with the appropriate keys, - * e.g. `addr` and `mail_pw` for DC_QR_ACCOUNT and DC_QR_LOGIN - * or `webrtc_instance` for DC_QR_WEBRTC_INSTANCE. + * e.g. `addr` and `mail_pw` for DC_QR_ACCOUNT and DC_QR_LOGIN. * * @memberof dc_context_t * @param context The context object. @@ -1053,42 +1045,6 @@ void dc_send_edit_request (dc_context_t* context, uint32_t ms void dc_send_delete_request (dc_context_t* context, const uint32_t* msg_ids, int msg_cnt); -/** - * Send invitation to a videochat. - * - * This function reads the `webrtc_instance` config value, - * may check that the server is working in some way - * and creates a unique room for this chat, if needed doing a TOKEN roundtrip for that. - * - * After that, the function sends out a message that contains information to join the room: - * - * - To allow non-delta-clients to join the chat, - * the message contains a text-area with some descriptive text - * and a URL that can be opened in a supported browser to join the videochat. - * - * - delta-clients can get all information needed from - * the message object, using e.g. - * dc_msg_get_videochat_url() and check dc_msg_get_viewtype() for #DC_MSG_VIDEOCHAT_INVITATION. - * - * dc_send_videochat_invitation() is blocking and may take a while, - * so the UIs will typically call the function from within a thread. - * Moreover, UIs will typically enter the room directly without an additional click on the message, - * for this purpose, the function returns the message id directly. - * - * As for other messages sent, this function - * sends the event #DC_EVENT_MSGS_CHANGED on success, the message has a delivery state, and so on. - * The recipient will get noticed by the call as usual by #DC_EVENT_INCOMING_MSG or #DC_EVENT_MSGS_CHANGED, - * However, UIs might some things differently, e.g. play a different sound. - * - * @memberof dc_context_t - * @param context The context object. - * @param chat_id The chat to start a videochat for. - * @return The ID of the message sent out - * or 0 for errors. - */ -uint32_t dc_send_videochat_invitation (dc_context_t* context, uint32_t chat_id); - - /** * A webxdc instance sends a status update to its other members. * @@ -1215,6 +1171,117 @@ void dc_set_webxdc_integration (dc_context_t* context, const char* f uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t chat_id); +/** + * Start an outgoing call. + * This sends a message of type #DC_MSG_CALL with all relevant information to the callee, + * who will get informed by an #DC_EVENT_INCOMING_CALL event and rings. + * + * Possible actions during ringing: + * + * - caller cancels the call using dc_end_call(): + * callee receives #DC_EVENT_CALL_ENDED and has a "Missed call" + * + * - callee accepts using dc_accept_incoming_call(): + * caller receives #DC_EVENT_OUTGOING_CALL_ACCEPTED. + * callee's devices receive #DC_EVENT_INCOMING_CALL_ACCEPTED, call starts + * + * - callee declines using dc_end_call(): + * caller receives #DC_EVENT_CALL_ENDED and has a "Declinced Call". + * callee's other devices receive #DC_EVENT_CALL_ENDED and have a "Canceled Call", + * + * - callee is already in a call: + * what to do depends on the capabilities of UI to handle calls. + * if UI cannot handle multiple calls, an easy approach would be to decline the new call automatically + * and make that visble to the user in the call, e.g. by a notification + * + * - timeout: + * after 1 minute without action, + * caller and callee receive #DC_EVENT_CALL_ENDED + * to prevent endless ringing of callee + * in case caller got offline without being able to send cancellation message. + * for caller, this is a "Canceled call"; + * for callee, this is a "Missed call" + * + * Actions during the call: + * + * - caller ends the call using dc_end_call(): + * callee receives #DC_EVENT_CALL_ENDED + * + * - callee ends the call using dc_end_call(): + * caller receives #DC_EVENT_CALL_ENDED + * + * Contact request handling: + * + * - placing or accepting calls implies accepting contact requests + * + * - ending a call does not accept a contact request; + * instead, the call will timeout on all affected devices. + * + * Note, that the events are for updating the call screen, + * possible status messages are added and updated as usual, including the known events. + * In the UI, the sorted chatlist is used as an overview about calls as well as messages. + * To place a call with a contact that has no chat yet, use dc_create_chat_by_contact_id() first. + * + * UI will usually allow only one call at the same time, + * this has to be tracked by UI across profile, the core does not track this. + * + * @memberof dc_context_t + * @param context The context object. + * @param chat_id The chat to place a call for. + * This needs to be a one-to-one chat. + * @param place_call_info any data that other devices receive + * in #DC_EVENT_INCOMING_CALL. + * @return ID of the system message announcing the call. + */ +uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info); + + +/** + * Accept incoming call. + * + * This implicitly accepts the contact request, if not yet done. + * All affected devices will receive + * either #DC_EVENT_OUTGOING_CALL_ACCEPTED or #DC_EVENT_INCOMING_CALL_ACCEPTED. + * + * If the call is already accepted or ended, nothing happens. + * If the chat is a contact request, it is accepted implicitly. + * + * @memberof dc_context_t + * @param context The context object. + * @param msg_id The ID of the call to accept. + * This is the ID reported by #DC_EVENT_INCOMING_CALL + * and equals to the ID of the corresponding info message. + * @param accept_call_info any data that other devices receive + * in #DC_EVENT_OUTGOING_CALL_ACCEPTED. + * @return 1=success, 0=error + */ + int dc_accept_incoming_call (dc_context_t* context, uint32_t msg_id, const char* accept_call_info); + + + /** + * End incoming or outgoing call. + * + * For unaccepted calls ended by the caller, this is a "cancellation". + * Unaccepted calls ended by the callee are a "decline". + * If the call was accepted, this is a "hangup". + * + * All participant devices get informed about the ended call via #DC_EVENT_CALL_ENDED unless they are contact requests. + * For contact requests, the call times out on all other affected devices. + * + * If the message ID is wrong or does not exist for whatever reasons, nothing happens. + * Therefore, and for resilience, UI should remove the call UI directly when calling + * this function and not only on the event. + * + * If the call is already ended, nothing happens. + * + * @memberof dc_context_t + * @param context The context object. + * @param msg_id the ID of the call. + * @return 1=success, 0=error + */ + int dc_end_call (dc_context_t* context, uint32_t msg_id); + + /** * Save a draft for a chat in the database. * @@ -1696,9 +1763,7 @@ dc_chat_t* dc_get_chat (dc_context_t* context, uint32_t ch * * @memberof dc_context_t * @param context The context object. - * @param protect If set to 1 the function creates group with protection initially enabled. - * Only verified members are allowed in these groups - * and end-to-end-encryption is always enabled. + * @param protect Deprecated 2025-08-31, ignored. * @param name The name of the group chat to create. * The name may be changed later using dc_set_chat_name(). * To find out the name of a group later, see dc_chat_get_name() @@ -2505,7 +2570,6 @@ void dc_stop_ongoing_process (dc_context_t* context); #define DC_QR_BACKUP 251 // deprecated #define DC_QR_BACKUP2 252 #define DC_QR_BACKUP_TOO_NEW 255 -#define DC_QR_WEBRTC_INSTANCE 260 // text1=domain, text2=instance pattern #define DC_QR_PROXY 271 // text1=address (e.g. "127.0.0.1:9050") #define DC_QR_ADDR 320 // id=contact #define DC_QR_TEXT 330 // text1=text @@ -2559,10 +2623,6 @@ void dc_stop_ongoing_process (dc_context_t* context); * show a hint to the user that this backup comes from a newer Delta Chat version * and this device needs an update * - * - DC_QR_WEBRTC_INSTANCE with dc_lot_t::text1=domain: - * ask the user if they want to use the given service for video chats; - * if so, call dc_set_config_from_qr(). - * * - DC_QR_PROXY with dc_lot_t::text1=address: * ask the user if they want to use the given proxy. * if so, call dc_set_config_from_qr() and restart I/O. @@ -3828,18 +3888,12 @@ int dc_chat_can_send (const dc_chat_t* chat); /** - * Check if a chat is protected. - * - * 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. + * Deprecated, always returns 0. * * @memberof dc_chat_t * @param chat The chat object. - * @return 1=chat protected, 0=chat is not protected. + * @return Always 0. + * @deprecated 2025-09-09 */ int dc_chat_is_protected (const dc_chat_t* chat); @@ -3859,28 +3913,6 @@ 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(). - * - * @deprecated 2025-07 chats protection cannot break any longer - * @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(). @@ -4656,22 +4688,6 @@ int dc_msg_is_setupmessage (const dc_msg_t* msg); char* dc_msg_get_setupcodebegin (const dc_msg_t* msg); -/** - * Get URL of a videochat invitation. - * - * Videochat invitations are sent out using dc_send_videochat_invitation() - * and dc_msg_get_viewtype() returns #DC_MSG_VIDEOCHAT_INVITATION for such invitations. - * - * @memberof dc_msg_t - * @param msg The message object. - * @return If the message contains a videochat invitation, - * the URL of the invitation is returned. - * If the message is no videochat invitation, NULL is returned. - * Must be released using dc_str_unref() when done. - */ -char* dc_msg_get_videochat_url (const dc_msg_t* msg); - - /** * Gets the error status of the message. * If there is no error associated with the message, NULL is returned. @@ -4694,41 +4710,6 @@ char* dc_msg_get_videochat_url (const dc_msg_t* msg); char* dc_msg_get_error (const dc_msg_t* msg); -/** - * Get type of videochat. - * - * Calling this functions only makes sense for messages of type #DC_MSG_VIDEOCHAT_INVITATION, - * in this case, if `basicwebrtc:` as of https://github.com/cracker0dks/basicwebrtc or `jitsi` - * were used to initiate the videochat, - * dc_msg_get_videochat_type() returns the corresponding type. - * - * The videochat URL can be retrieved using dc_msg_get_videochat_url(). - * To check if a message is a videochat invitation at all, check the message type for #DC_MSG_VIDEOCHAT_INVITATION. - * - * @memberof dc_msg_t - * @param msg The message object. - * @return Type of the videochat as of DC_VIDEOCHATTYPE_BASICWEBRTC, DC_VIDEOCHATTYPE_JITSI or DC_VIDEOCHATTYPE_UNKNOWN. - * - * Example: - * ~~~ - * if (dc_msg_get_viewtype(msg) == DC_MSG_VIDEOCHAT_INVITATION) { - * if (dc_msg_get_videochat_type(msg) == DC_VIDEOCHATTYPE_BASICWEBRTC) { - * // videochat invitation that we ship a client for - * } else { - * // use browser for videochat - or add an additional check for DC_VIDEOCHATTYPE_JITSI - * } - * } else { - * // not a videochat invitation - * } - * ~~~ - */ -int dc_msg_get_videochat_type (const dc_msg_t* msg); - -#define DC_VIDEOCHATTYPE_UNKNOWN 0 -#define DC_VIDEOCHATTYPE_BASICWEBRTC 1 -#define DC_VIDEOCHATTYPE_JITSI 2 - - /** * Checks if the message has a full HTML version. * @@ -5361,11 +5342,9 @@ dc_provider_t* dc_provider_new_from_email (const dc_context_t* conte /** - * Create a provider struct for the given e-mail address by local and DNS lookup. + * Create a provider struct for the given e-mail address by local lookup. * - * First lookup is done from the local database as of dc_provider_new_from_email(). - * If the first lookup fails, an additional DNS lookup is done, - * trying to figure out the provider belonging to custom domains. + * DNS lookup is not used anymore and this function is deprecated. * * @memberof dc_provider_t * @param context The context object. @@ -5373,6 +5352,7 @@ dc_provider_t* dc_provider_new_from_email (const dc_context_t* conte * @return A dc_provider_t struct which can be used with the dc_provider_get_* * accessor functions. If no provider info is found, NULL will be * returned. + * @deprecated 2025-10-17 use dc_provider_new_from_email() instead. */ dc_provider_t* dc_provider_new_from_email_with_dns (const dc_context_t* context, const char* email); @@ -5628,14 +5608,21 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot); /** - * Message indicating an incoming or outgoing videochat. - * The message was created via dc_send_videochat_invitation() on this or a remote device. + * Message indicating an incoming or outgoing call. * - * Typically, such messages are rendered differently by the UIs, - * e.g. contain a button to join the videochat. - * The URL for joining can be retrieved using dc_msg_get_videochat_url(). + * These messages are created by dc_place_outgoing_call() + * and should be rendered by UI similar to text messages, + * maybe with some "phone icon" at the side. + * + * The message text is updated as needed + * and UI will be informed via #DC_EVENT_MSGS_CHANGED as usual. + * + * Do not start ringing when seeing this message; + * the mesage may belong e.g. to an old missed call. + * + * Instead, ringing should start on the event #DC_EVENT_INCOMING_CALL */ -#define DC_MSG_VIDEOCHAT_INVITATION 70 +#define DC_MSG_CALL 71 /** @@ -6480,11 +6467,7 @@ 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 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. + * @param data2 (int) The progress, always 1000. */ #define DC_EVENT_SECUREJOIN_INVITER_PROGRESS 2060 @@ -6636,6 +6619,60 @@ void dc_event_unref(dc_event_t* event); */ #define DC_EVENT_CHANNEL_OVERFLOW 2400 + + +/** + * Incoming call. + * UI will usually start ringing, + * or show a notification if there is already a call in some profile. + * + * Together with this event, + * a message of type #DC_MSG_CALL is added to the corresponding chat; + * this message is announced and updated by the usual event as #DC_EVENT_MSGS_CHANGED, + * there is usually no need to take care of this message from any of the CALL events. + * + * If user takes action, dc_accept_incoming_call() or dc_end_call() should be called. + * + * Otherwise, ringing should end on #DC_EVENT_CALL_ENDED + * or #DC_EVENT_INCOMING_CALL_ACCEPTED + * + * @param data1 (int) msg_id ID of the message referring to the call. + * @param data2 (char*) place_call_info, text passed to dc_place_outgoing_call() + * @param data2 (int) 1 if incoming call is a video call, 0 otherwise + */ +#define DC_EVENT_INCOMING_CALL 2550 + +/** + * The callee accepted an incoming call on this or another device using dc_accept_incoming_call(). + * The caller gets the event #DC_EVENT_OUTGOING_CALL_ACCEPTED at the same time. + * + * UI usually only takes action in case call UI was opened before, otherwise the event should be ignored. + * + * @param data1 (int) msg_id ID of the message referring to the call + */ + #define DC_EVENT_INCOMING_CALL_ACCEPTED 2560 + +/** + * A call placed using dc_place_outgoing_call() was accepted by the callee using dc_accept_incoming_call(). + * + * UI usually only takes action in case call UI was opened before, otherwise the event should be ignored. + * + * @param data1 (int) msg_id ID of the message referring to the call + * @param data2 (char*) accept_call_info, text passed to dc_accept_incoming_call() + */ +#define DC_EVENT_OUTGOING_CALL_ACCEPTED 2570 + +/** + * An incoming or outgoing call was ended using dc_end_call() on this or another device, by caller or callee. + * Moreover, the event is sent when the call was not accepted within 1 minute timeout. + * + * UI usually only takes action in case call UI was opened before, otherwise the event should be ignored. + * + * @param data1 (int) msg_id ID of the message referring to the call + */ +#define DC_EVENT_CALL_ENDED 2580 + + /** * @} */ @@ -7056,6 +7093,8 @@ 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" @@ -7118,17 +7157,6 @@ void dc_event_unref(dc_event_t* event); /// @deprecated Deprecated 2021-01-30, DC_STR_EPHEMERAL_WEEKS is used instead. #define DC_STR_EPHEMERAL_FOUR_WEEKS 81 -/// "Video chat invitation" -/// -/// Used in summaries. -#define DC_STR_VIDEOCHAT_INVITATION 82 - -/// "You are invited to a video chat, click %1$s to join." -/// -/// Used as message text of outgoing video chat invitations. -/// - %1$s will be replaced by the URL of the video chat -#define DC_STR_VIDEOCHAT_INVITE_MSG_BODY 83 - /// "Error: %1$s" /// /// Used in error strings. @@ -7427,7 +7455,7 @@ void dc_event_unref(dc_event_t* event); /// Used in status messages. #define DC_STR_REMOVE_MEMBER_BY_OTHER 131 -/// "You left." +/// "You left the group." /// /// Used in status messages. #define DC_STR_GROUP_LEFT_BY_YOU 132 @@ -7650,6 +7678,12 @@ void dc_event_unref(dc_event_t* event); /// `%1$s` will be replaced by the provider's domain. #define DC_STR_INVALID_UNENCRYPTED_MAIL 174 +/// "⚠️ It seems you are using Delta Chat on multiple devices that cannot decrypt each other's outgoing messages. To fix this, on the older device use \"Settings / Add Second Device\" and follow the instructions." +/// +/// Added to the device chat if could not decrypt a new outgoing message (i.e. not when fetching +/// existing messages). But no more than once a day. +#define DC_STR_CANT_DECRYPT_OUTGOING_MSGS 175 + /// "You reacted %1$s to '%2$s'" /// /// `%1$s` will be replaced by the reaction, usually an emoji @@ -7686,8 +7720,32 @@ void dc_event_unref(dc_event_t* event); /// "❀️ Seems you're enjoying Delta Chat!"… (donation request device message) #define DC_STR_DONATION_REQUEST 193 -/// "Contact". Deprecated, currently unused. -#define DC_STR_CONTACT 200 +/// "Outgoing call" +#define DC_STR_OUTGOING_CALL 194 + +/// "Incoming call" +#define DC_STR_INCOMING_CALL 195 + +/// "Declined call" +#define DC_STR_DECLINED_CALL 196 + +/// "Canceled call" +#define DC_STR_CANCELED_CALL 197 + +/// "Missed call" +#define DC_STR_MISSED_CALL 198 + +/// "You left the channel." +/// +/// Used in status messages. +#define DC_STR_CHANNEL_LEFT_BY_YOU 200 + +/// "Scan to join channel %1$s" +/// +/// Subtitle for channel join qrcode svg image generated by the core. +/// +/// `%1$s` will be replaced with the channel name. +#define DC_STR_SECURE_JOIN_CHANNEL_QR_DESC 201 /** * @} diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 93894829a3..9c768d7366 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -22,7 +22,7 @@ use std::sync::{Arc, LazyLock}; use std::time::{Duration, SystemTime}; use anyhow::Context as _; -use deltachat::chat::{ChatId, ChatVisibility, MessageListOptions, MuteDuration, ProtectionStatus}; +use deltachat::chat::{ChatId, ChatVisibility, MessageListOptions, MuteDuration}; use deltachat::constants::DC_MSG_ID_LAST_SPECIAL; use deltachat::contact::{Contact, ContactId, Origin}; use deltachat::context::{Context, ContextBuilder}; @@ -375,7 +375,7 @@ pub unsafe extern "C" fn dc_get_connectivity(context: *const dc_context_t) -> li return 0; } let ctx = &*context; - block_on(ctx.get_connectivity()) as u32 as libc::c_int + ctx.get_connectivity() as u32 as libc::c_int } #[no_mangle] @@ -556,6 +556,10 @@ 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"), @@ -619,7 +623,11 @@ 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, .. } => msg_id.to_u32() as libc::c_int, + | 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::ChatlistItemChanged { chat_id } => { chat_id.unwrap_or_default().to_u32() as libc::c_int } @@ -671,6 +679,9 @@ 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, .. } @@ -689,6 +700,8 @@ 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"), @@ -767,8 +780,21 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut | EventType::ChatlistChanged | EventType::AccountsChanged | EventType::AccountsItemChanged - | EventType::WebxdcRealtimeAdvertisementReceived { .. } - | EventType::EventChannelOverflow { .. } => ptr::null_mut(), + | 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::ConfigureProgress { comment, .. } => { if let Some(comment) = comment { comment.to_c_string().unwrap_or_default().into_raw() @@ -1072,25 +1098,6 @@ pub unsafe extern "C" fn dc_send_delete_request( .ok(); } -#[no_mangle] -pub unsafe extern "C" fn dc_send_videochat_invitation( - context: *mut dc_context_t, - chat_id: u32, -) -> u32 { - if context.is_null() { - eprintln!("ignoring careless call to dc_send_videochat_invitation()"); - return 0; - } - let ctx = &*context; - - block_on(async move { - chat::send_videochat_invitation(ctx, ChatId::new(chat_id)) - .await - .map(|msg_id| msg_id.to_u32()) - .unwrap_or_log_default(ctx, "Failed to send video chat invitation") - }) -} - #[no_mangle] pub unsafe extern "C" fn dc_send_webxdc_status_update( context: *mut dc_context_t, @@ -1167,6 +1174,61 @@ 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, @@ -1659,7 +1721,7 @@ pub unsafe extern "C" fn dc_get_chat(context: *mut dc_context_t, chat_id: u32) - #[no_mangle] pub unsafe extern "C" fn dc_create_group_chat( context: *mut dc_context_t, - protect: libc::c_int, + _protect: libc::c_int, name: *const libc::c_char, ) -> u32 { if context.is_null() || name.is_null() { @@ -1667,22 +1729,12 @@ pub unsafe extern "C" fn dc_create_group_chat( return 0; } let ctx = &*context; - let Some(protect) = ProtectionStatus::from_i32(protect) - .context("Bad protect-value for dc_create_group_chat()") - .log_err(ctx) - .ok() - else { - return 0; - }; - block_on(async move { - chat::create_group_chat(ctx, protect, &to_string_lossy(name)) - .await - .context("Failed to create group chat") - .log_err(ctx) - .map(|id| id.to_u32()) - .unwrap_or(0) - }) + block_on(chat::create_group(ctx, &to_string_lossy(name))) + .context("Failed to create group chat") + .log_err(ctx) + .map(|id| id.to_u32()) + .unwrap_or(0) } #[no_mangle] @@ -3144,13 +3196,8 @@ pub unsafe extern "C" fn dc_chat_can_send(chat: *mut dc_chat_t) -> libc::c_int { } #[no_mangle] -pub unsafe extern "C" fn dc_chat_is_protected(chat: *mut dc_chat_t) -> libc::c_int { - if chat.is_null() { - eprintln!("ignoring careless call to dc_chat_is_protected()"); - return 0; - } - let ffi_chat = &*chat; - ffi_chat.chat.is_protected() as libc::c_int +pub extern "C" fn dc_chat_is_protected(_chat: *mut dc_chat_t) -> libc::c_int { + 0 } #[no_mangle] @@ -3165,16 +3212,6 @@ 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() { @@ -3783,31 +3820,6 @@ pub unsafe extern "C" fn dc_msg_has_html(msg: *mut dc_msg_t) -> libc::c_int { ffi_msg.message.has_html().into() } -#[no_mangle] -pub unsafe extern "C" fn dc_msg_get_videochat_url(msg: *mut dc_msg_t) -> *mut libc::c_char { - if msg.is_null() { - eprintln!("ignoring careless call to dc_msg_get_videochat_url()"); - return "".strdup(); - } - let ffi_msg = &*msg; - - ffi_msg - .message - .get_videochat_url() - .unwrap_or_default() - .strdup() -} - -#[no_mangle] -pub unsafe extern "C" fn dc_msg_get_videochat_type(msg: *mut dc_msg_t) -> libc::c_int { - if msg.is_null() { - eprintln!("ignoring careless call to dc_msg_get_videochat_type()"); - return 0; - } - let ffi_msg = &*msg; - ffi_msg.message.get_videochat_type().unwrap_or_default() as i32 -} - #[no_mangle] pub unsafe extern "C" fn dc_msg_get_setupcodebegin(msg: *mut dc_msg_t) -> *mut libc::c_char { if msg.is_null() { @@ -4634,13 +4646,9 @@ pub unsafe extern "C" fn dc_provider_new_from_email( let ctx = &*context; - match block_on(provider::get_provider_info_by_addr( - ctx, - addr.as_str(), - true, - )) - .log_err(ctx) - .unwrap_or_default() + match provider::get_provider_info_by_addr(addr.as_str()) + .log_err(ctx) + .unwrap_or_default() { Some(provider) => provider, None => ptr::null_mut(), @@ -4659,25 +4667,13 @@ pub unsafe extern "C" fn dc_provider_new_from_email_with_dns( let addr = to_string_lossy(addr); let ctx = &*context; - let proxy_enabled = block_on(ctx.get_config_bool(config::Config::ProxyEnabled)) - .context("Can't get config") - .log_err(ctx); - match proxy_enabled { - Ok(proxy_enabled) => { - match block_on(provider::get_provider_info_by_addr( - ctx, - addr.as_str(), - proxy_enabled, - )) - .log_err(ctx) - .unwrap_or_default() - { - Some(provider) => provider, - None => ptr::null_mut(), - } - } - Err(_) => ptr::null_mut(), + match provider::get_provider_info_by_addr(addr.as_str()) + .log_err(ctx) + .unwrap_or_default() + { + Some(provider) => provider, + None => ptr::null_mut(), } } diff --git a/deltachat-ffi/src/lot.rs b/deltachat-ffi/src/lot.rs index e77483ef7d..11b6cb405b 100644 --- a/deltachat-ffi/src/lot.rs +++ b/deltachat-ffi/src/lot.rs @@ -51,7 +51,6 @@ impl Lot { Qr::Account { domain } => Some(Cow::Borrowed(domain)), Qr::Backup2 { .. } => None, Qr::BackupTooNew { .. } => None, - Qr::WebrtcInstance { domain, .. } => Some(Cow::Borrowed(domain)), Qr::Proxy { host, port, .. } => Some(Cow::Owned(format!("{host}:{port}"))), Qr::Addr { draft, .. } => draft.as_deref().map(Cow::Borrowed), Qr::Url { url } => Some(Cow::Borrowed(url)), @@ -105,7 +104,6 @@ impl Lot { Qr::Account { .. } => LotState::QrAccount, Qr::Backup2 { .. } => LotState::QrBackup2, Qr::BackupTooNew { .. } => LotState::QrBackupTooNew, - Qr::WebrtcInstance { .. } => LotState::QrWebrtcInstance, Qr::Proxy { .. } => LotState::QrProxy, Qr::Addr { .. } => LotState::QrAddr, Qr::Url { .. } => LotState::QrUrl, @@ -132,7 +130,6 @@ impl Lot { Qr::Account { .. } => Default::default(), Qr::Backup2 { .. } => Default::default(), Qr::BackupTooNew { .. } => Default::default(), - Qr::WebrtcInstance { .. } => Default::default(), Qr::Proxy { .. } => Default::default(), Qr::Addr { contact_id, .. } => contact_id.to_u32(), Qr::Url { .. } => Default::default(), @@ -185,9 +182,6 @@ pub enum LotState { QrBackupTooNew = 255, - /// text1=domain, text2=instance pattern - QrWebrtcInstance = 260, - /// text1=address, text2=protocol QrProxy = 271, diff --git a/deltachat-jsonrpc/Cargo.toml b/deltachat-jsonrpc/Cargo.toml index ba91bb4f23..7e3956e59d 100644 --- a/deltachat-jsonrpc/Cargo.toml +++ b/deltachat-jsonrpc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deltachat-jsonrpc" -version = "2.11.0" +version = "2.22.0" description = "DeltaChat JSON-RPC API" edition = "2021" license = "MPL-2.0" diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index a728526fab..8b9fb648cc 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -8,10 +8,10 @@ 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, - ProtectionStatus, }; use deltachat::chatlist::Chatlist; use deltachat::config::Config; @@ -47,6 +47,7 @@ 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; @@ -91,7 +92,8 @@ pub struct CommandApi { /// Receiver side of the event channel. /// - /// Events from it can be received by calling `get_next_event` method. + /// Events from it can be received by calling + /// [`CommandApi::get_next_event`] method. event_emitter: Arc, states: Arc>>, @@ -123,7 +125,7 @@ impl CommandApi { .read() .await .get_account(id) - .ok_or_else(|| anyhow!("account with id {} not found", id))?; + .ok_or_else(|| anyhow!("account with id {id} not found"))?; Ok(sc) } @@ -173,7 +175,15 @@ impl CommandApi { get_info() } - /// Get the next event. + /// 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. async fn get_next_event(&self) -> Result { self.event_emitter .recv() @@ -297,8 +307,7 @@ impl CommandApi { Ok(Account::from_context(&ctx, account_id).await?) } else { Err(anyhow!( - "account with id {} doesn't exist anymore", - account_id + "account with id {account_id} doesn't exist anymore" )) } } @@ -326,21 +335,10 @@ impl CommandApi { /// instead of the domain. async fn get_provider_info( &self, - account_id: u32, + _account_id: u32, email: String, ) -> Result> { - let ctx = self.get_context(account_id).await?; - - let proxy_enabled = ctx - .get_config_bool(deltachat::config::Config::ProxyEnabled) - .await?; - - let provider_info = get_provider_info( - &ctx, - email.split('@').next_back().unwrap_or(""), - proxy_enabled, - ) - .await; + let provider_info = get_provider_info(email.split('@').next_back().unwrap_or("")); Ok(ProviderInfo::from_dc_type(provider_info)) } @@ -968,18 +966,9 @@ impl CommandApi { /// /// To check, if a chat is still unpromoted, you can look at the `is_unpromoted` property of `BasicChat` or `FullChat`. /// This may be useful if you want to show some help for just created groups. - /// - /// @param protect If set to 1 the function creates group with protection initially enabled. - /// Only verified members are allowed in these groups - async fn create_group_chat(&self, account_id: u32, name: String, protect: bool) -> Result { + async fn create_group_chat(&self, account_id: u32, name: String) -> Result { let ctx = self.get_context(account_id).await?; - let protect = match protect { - true => ProtectionStatus::Protected, - false => ProtectionStatus::Unprotected, - }; - chat::create_group_ex(&ctx, Some(protect), &name) - .await - .map(|id| id.to_u32()) + chat::create_group(&ctx, &name).await.map(|id| id.to_u32()) } /// Create a new unencrypted group chat. @@ -988,7 +977,7 @@ impl CommandApi { /// address-contacts. async fn create_group_chat_unencrypted(&self, account_id: u32, name: String) -> Result { let ctx = self.get_context(account_id).await?; - chat::create_group_ex(&ctx, None, &name) + chat::create_group_unencrypted(&ctx, &name) .await .map(|id| id.to_u32()) } @@ -1798,13 +1787,13 @@ impl CommandApi { /// Offers a backup for remote devices to retrieve. /// - /// Can be cancelled by stopping the ongoing process. Success or failure can be tracked + /// Can be canceled by stopping the ongoing process. Success or failure can be tracked /// via the `ImexProgress` event which should either reach `1000` for success or `0` for /// failure. /// /// This **stops IO** while it is running. /// - /// Returns once a remote device has retrieved the backup, or is cancelled. + /// Returns once a remote device has retrieved the backup, or is canceled. async fn provide_backup(&self, account_id: u32) -> Result<()> { let ctx = self.get_context(account_id).await?; @@ -1870,7 +1859,7 @@ impl CommandApi { /// This retrieves the backup from a remote device over the network and imports it into /// the current device. /// - /// Can be cancelled by stopping the ongoing process. + /// Can be canceled by stopping the ongoing process. /// /// Do not forget to call start_io on the account after a successful import, /// otherwise it will not connect to the email server. @@ -1908,7 +1897,7 @@ impl CommandApi { /// If the connectivity changes, a #DC_EVENT_CONNECTIVITY_CHANGED will be emitted. async fn get_connectivity(&self, account_id: u32) -> Result { let ctx = self.get_context(account_id).await?; - Ok(ctx.get_connectivity().await as u32) + Ok(ctx.get_connectivity() as u32) } /// Get an overview of the current connectivity, and possibly more statistics. @@ -1991,6 +1980,11 @@ 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 @@ -2068,6 +2062,53 @@ 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 { + 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 { + 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 { + 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. @@ -2219,13 +2260,6 @@ impl CommandApi { } } - async fn send_videochat_invitation(&self, account_id: u32, chat_id: u32) -> Result { - let ctx = self.get_context(account_id).await?; - chat::send_videochat_invitation(&ctx, ChatId::new(chat_id)) - .await - .map(|msg_id| msg_id.to_u32()) - } - // --------------------------------------------- // misc prototyping functions // that might get removed later again @@ -2256,8 +2290,7 @@ impl CommandApi { let message = Message::load_from_db(&ctx, MsgId::new(msg_id)).await?; ensure!( message.get_viewtype() == Viewtype::Sticker, - "message {} is not a sticker", - msg_id + "message {msg_id} is not a sticker" ); let account_folder = ctx .get_dbfile() @@ -2477,10 +2510,7 @@ impl CommandApi { .to_u32(); Ok(msg_id) } else { - Err(anyhow!( - "chat with id {} doesn't have draft message", - chat_id - )) + Err(anyhow!("chat with id {chat_id} doesn't have draft message")) } } } diff --git a/deltachat-jsonrpc/src/api/types/calls.rs b/deltachat-jsonrpc/src/api/types/calls.rs new file mode 100644 index 0000000000..e779f1c89e --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/calls.rs @@ -0,0 +1,97 @@ +use anyhow::{Context as _, Result}; + +use deltachat::calls::{call_state, sdp_has_video, CallState}; +use deltachat::context::Context; +use deltachat::message::MsgId; +use serde::Serialize; +use typescript_type_def::TypeDef; + +#[derive(Serialize, TypeDef, schemars::JsonSchema)] +#[serde(rename = "CallInfo", rename_all = "camelCase")] +pub struct JsonrpcCallInfo { + /// SDP offer. + /// + /// Can be used to manually answer the call + /// even if incoming call event was missed. + pub sdp_offer: String, + + /// True if SDP offer has a video. + pub has_video: bool, + + /// Call state. + /// + /// For example, if the call is accepted, active, canceled, declined etc. + pub state: JsonrpcCallState, +} + +impl JsonrpcCallInfo { + pub async fn from_msg_id(context: &Context, msg_id: MsgId) -> Result { + let call_info = context.load_call_by_id(msg_id).await?.with_context(|| { + format!("Attempting to get call state of non-call message {msg_id}") + })?; + let sdp_offer = call_info.place_call_info.clone(); + let has_video = sdp_has_video(&sdp_offer).unwrap_or_default(); + let state = JsonrpcCallState::from_msg_id(context, msg_id).await?; + + Ok(JsonrpcCallInfo { + sdp_offer, + has_video, + state, + }) + } +} + +#[derive(Serialize, TypeDef, schemars::JsonSchema)] +#[serde(rename = "CallState", tag = "kind")] +pub enum JsonrpcCallState { + /// Fresh incoming or outgoing call that is still ringing. + /// + /// There is no separate state for outgoing call + /// that has been dialled but not ringing on the other side yet + /// as we don't know whether the other side received our call. + Alerting, + + /// Active call. + Active, + + /// Completed call that was once active + /// and then was terminated for any reason. + Completed { + /// Call duration in seconds. + duration: i64, + }, + + /// Incoming call that was not picked up within a timeout + /// or was explicitly ended by the caller before we picked up. + Missed, + + /// Incoming call that was explicitly ended on our side + /// before picking up or outgoing call + /// that was declined before the timeout. + Declined, + + /// Outgoing call that has been canceled on our side + /// before receiving a response. + /// + /// Incoming calls cannot be canceled, + /// on the receiver side canceled calls + /// usually result in missed calls. + Canceled, +} + +impl JsonrpcCallState { + pub async fn from_msg_id(context: &Context, msg_id: MsgId) -> Result { + let call_state = call_state(context, msg_id).await?; + + let jsonrpc_call_state = match call_state { + CallState::Alerting => JsonrpcCallState::Alerting, + CallState::Active => JsonrpcCallState::Active, + CallState::Completed { duration } => JsonrpcCallState::Completed { duration }, + CallState::Missed => JsonrpcCallState::Missed, + CallState::Declined => JsonrpcCallState::Declined, + CallState::Canceled => JsonrpcCallState::Canceled, + }; + + Ok(jsonrpc_call_state) + } +} diff --git a/deltachat-jsonrpc/src/api/types/chat.rs b/deltachat-jsonrpc/src/api/types/chat.rs index 96388c27be..6b5e693043 100644 --- a/deltachat-jsonrpc/src/api/types/chat.rs +++ b/deltachat-jsonrpc/src/api/types/chat.rs @@ -19,18 +19,6 @@ pub struct FullChat { id: u32, name: String, - /// 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 - 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,8 +59,6 @@ pub struct FullChat { fresh_message_counter: usize, // is_group - please check over chat.type in frontend instead is_contact_request: bool, - /// Deprecated 2025-07. Chats protection cannot break any longer. - is_protection_broken: bool, is_device_chat: bool, self_in_group: bool, @@ -133,7 +119,6 @@ impl FullChat { Ok(FullChat { id: chat_id, name: chat.name.clone(), - is_protected: chat.is_protected(), is_encrypted: chat.is_encrypted(context).await?, profile_image, //BLOBS ? archived: chat.get_visibility() == chat::ChatVisibility::Archived, @@ -147,7 +132,6 @@ 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(), @@ -175,18 +159,6 @@ pub struct BasicChat { id: u32, name: String, - /// True if the chat is protected. - /// - /// 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", @@ -218,8 +190,6 @@ pub struct BasicChat { is_self_talk: bool, color: String, is_contact_request: bool, - /// Deprecated 2025-07. Chats protection cannot break any longer. - is_protection_broken: bool, is_device_chat: bool, is_muted: bool, @@ -239,7 +209,6 @@ impl BasicChat { Ok(BasicChat { id: chat_id, name: chat.name.clone(), - is_protected: chat.is_protected(), is_encrypted: chat.is_encrypted(context).await?, profile_image, //BLOBS ? archived: chat.get_visibility() == chat::ChatVisibility::Archived, @@ -249,7 +218,6 @@ 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(), }) diff --git a/deltachat-jsonrpc/src/api/types/chat_list.rs b/deltachat-jsonrpc/src/api/types/chat_list.rs index b5d31a7913..47e72524c0 100644 --- a/deltachat-jsonrpc/src/api/types/chat_list.rs +++ b/deltachat-jsonrpc/src/api/types/chat_list.rs @@ -30,7 +30,6 @@ pub enum ChatListItemFetchResult { summary_status: u32, /// showing preview if last chat message is image summary_preview_image: Option, - is_protected: bool, /// True if the chat is encrypted. /// This means that all messages in the chat are encrypted, @@ -161,7 +160,6 @@ pub(crate) async fn get_chat_list_item_by_id( summary_text2, summary_status: summary.state.to_u32().expect("impossible"), // idea and a function to transform the constant to strings? or return string enum summary_preview_image, - is_protected: chat.is_protected(), is_encrypted: chat.is_encrypted(ctx).await?, is_group: chat.get_type() == Chattype::Group, fresh_message_counter, diff --git a/deltachat-jsonrpc/src/api/types/contact.rs b/deltachat-jsonrpc/src/api/types/contact.rs index c4fb5663e7..bafdfb5cc6 100644 --- a/deltachat-jsonrpc/src/api/types/contact.rs +++ b/deltachat-jsonrpc/src/api/types/contact.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use deltachat::color; use deltachat::context::Context; +use deltachat::key::{DcKey, SignedPublicKey}; use serde::Serialize; use typescript_type_def::TypeDef; @@ -38,12 +38,6 @@ pub struct ContactObject { /// See [`Self::verifier_id`]/`Contact.verifierId` for a guidance how to display these information. is_verified: bool, - /// True if the contact profile title should have a green checkmark. - /// - /// This indicates whether 1:1 chat has a green checkmark - /// or will have a green checkmark if created. - is_profile_verified: bool, - /// The contact ID that verified a contact. /// /// As verifier may be unknown, @@ -87,7 +81,6 @@ 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) @@ -109,7 +102,6 @@ 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(), @@ -138,7 +130,13 @@ pub struct VcardContact { impl From for VcardContact { fn from(vc: deltachat_contact_tools::VcardContact) -> Self { let display_name = vc.display_name().to_string(); - let color = color::str_to_color(&vc.addr.to_lowercase()); + let is_self = false; + let fpr = vc.key.as_deref().and_then(|k| { + SignedPublicKey::from_base64(k) + .ok() + .map(|k| k.dc_fingerprint()) + }); + let color = deltachat::contact::get_color(is_self, &vc.addr, &fpr); Self { addr: vc.addr, display_name, diff --git a/deltachat-jsonrpc/src/api/types/events.rs b/deltachat-jsonrpc/src/api/types/events.rs index 7d9fffad67..d957f82e9f 100644 --- a/deltachat-jsonrpc/src/api/types/events.rs +++ b/deltachat-jsonrpc/src/api/types/events.rs @@ -1,4 +1,5 @@ use deltachat::{Event as CoreEvent, EventType as CoreEventType}; +use num_traits::ToPrimitive; use serde::Serialize; use typescript_type_def::TypeDef; @@ -293,8 +294,8 @@ pub enum EventType { #[serde(rename_all = "camelCase")] ImexFileWritten { path: String }, - /// Progress information of a secure-join handshake from the view of the inviter - /// (Alice, the person who shows the QR code). + /// Progress event sent when SecureJoin protocol has finished + /// 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(). @@ -303,11 +304,14 @@ pub enum EventType { /// ID of the contact that wants to join. contact_id: u32, - /// 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. + /// 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: usize, }, @@ -416,6 +420,45 @@ 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 for EventType { @@ -522,9 +565,13 @@ impl From 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 { @@ -566,6 +613,34 @@ impl From 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"), diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs index af3c45d31f..b2832db94b 100644 --- a/deltachat-jsonrpc/src/api/types/message.rs +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -84,9 +84,6 @@ pub struct MessageObject { dimensions_height: i32, dimensions_width: i32, - videochat_type: Option, - videochat_url: Option, - override_sender_name: Option, sender: ContactObject, @@ -239,15 +236,6 @@ impl MessageObject { dimensions_height: message.get_height(), dimensions_width: message.get_width(), - videochat_type: match message.get_videochat_type() { - Some(vct) => Some( - vct.to_u32() - .context("videochat type conversion to number failed")?, - ), - None => None, - }, - videochat_url: message.get_videochat_url(), - override_sender_name, sender, @@ -321,8 +309,8 @@ pub enum MessageViewtype { /// Message containing any file, eg. a PDF. File, - /// Message is an invitation to a videochat. - VideochatInvitation, + /// Message is a call. + Call, /// Message is an webxdc instance. Webxdc, @@ -345,7 +333,7 @@ impl From for MessageViewtype { Viewtype::Voice => MessageViewtype::Voice, Viewtype::Video => MessageViewtype::Video, Viewtype::File => MessageViewtype::File, - Viewtype::VideochatInvitation => MessageViewtype::VideochatInvitation, + Viewtype::Call => MessageViewtype::Call, Viewtype::Webxdc => MessageViewtype::Webxdc, Viewtype::Vcard => MessageViewtype::Vcard, } @@ -364,7 +352,7 @@ impl From for Viewtype { MessageViewtype::Voice => Viewtype::Voice, MessageViewtype::Video => Viewtype::Video, MessageViewtype::File => Viewtype::File, - MessageViewtype::VideochatInvitation => Viewtype::VideochatInvitation, + MessageViewtype::Call => Viewtype::Call, MessageViewtype::Webxdc => Viewtype::Webxdc, MessageViewtype::Vcard => Viewtype::Vcard, } @@ -437,6 +425,9 @@ pub enum SystemMessageType { /// This message contains a users iroh node address. IrohNodeAddr, + + CallAccepted, + CallEnded, } impl From for SystemMessageType { @@ -463,6 +454,8 @@ impl From for SystemMessageType { SystemMessage::IrohNodeAddr => SystemMessageType::IrohNodeAddr, SystemMessage::SecurejoinWait => SystemMessageType::SecurejoinWait, SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout, + SystemMessage::CallAccepted => SystemMessageType::CallAccepted, + SystemMessage::CallEnded => SystemMessageType::CallEnded, } } } @@ -539,7 +532,6 @@ pub struct MessageSearchResult { chat_color: String, chat_name: String, chat_type: u32, - is_chat_protected: bool, is_chat_contact_request: bool, is_chat_archived: bool, message: String, @@ -579,7 +571,6 @@ impl MessageSearchResult { chat_color, chat_type: chat.get_type().to_u32().context("unknown chat type id")?, chat_profile_image, - is_chat_protected: chat.is_protected(), is_chat_contact_request: chat.is_contact_request(), is_chat_archived: chat.get_visibility() == ChatVisibility::Archived, message: message.get_text(), diff --git a/deltachat-jsonrpc/src/api/types/mod.rs b/deltachat-jsonrpc/src/api/types/mod.rs index 995e931bff..0f49fcaa37 100644 --- a/deltachat-jsonrpc/src/api/types/mod.rs +++ b/deltachat-jsonrpc/src/api/types/mod.rs @@ -1,4 +1,5 @@ pub mod account; +pub mod calls; pub mod chat; pub mod chat_list; pub mod contact; diff --git a/deltachat-jsonrpc/src/api/types/qr.rs b/deltachat-jsonrpc/src/api/types/qr.rs index 61d8141f76..0414ec9e5d 100644 --- a/deltachat-jsonrpc/src/api/types/qr.rs +++ b/deltachat-jsonrpc/src/api/types/qr.rs @@ -225,13 +225,6 @@ impl From for QrObject { auth_token, }, Qr::BackupTooNew {} => QrObject::BackupTooNew {}, - Qr::WebrtcInstance { - domain, - instance_pattern, - } => QrObject::WebrtcInstance { - domain, - instance_pattern, - }, Qr::Proxy { url, host, port } => QrObject::Proxy { url, host, port }, Qr::Addr { contact_id, draft } => { let contact_id = contact_id.to_u32(); diff --git a/deltachat-jsonrpc/typescript/package.json b/deltachat-jsonrpc/typescript/package.json index 93c671d550..70648e4e35 100644 --- a/deltachat-jsonrpc/typescript/package.json +++ b/deltachat-jsonrpc/typescript/package.json @@ -54,5 +54,5 @@ }, "type": "module", "types": "dist/deltachat.d.ts", - "version": "2.11.0" + "version": "2.22.0" } diff --git a/deltachat-jsonrpc/typescript/src/client.ts b/deltachat-jsonrpc/typescript/src/client.ts index 03bda79a75..dd0c6e32b6 100644 --- a/deltachat-jsonrpc/typescript/src/client.ts +++ b/deltachat-jsonrpc/typescript/src/client.ts @@ -28,7 +28,6 @@ export class BaseDeltaChat< Transport extends BaseTransport, > extends TinyEmitter { rpc: RawClient; - account?: T.Account; private contextEmitters: { [key: number]: TinyEmitter } = {}; //@ts-ignore @@ -36,6 +35,10 @@ export class BaseDeltaChat< constructor( public transport: Transport, + /** + * Whether to start calling {@linkcode RawClient.getNextEvent} + * and emitting the respective events on this class. + */ startEventLoop: boolean, ) { super(); @@ -45,6 +48,9 @@ export class BaseDeltaChat< } } + /** + * @see the constructor's `startEventLoop` + */ async eventLoop(): Promise { while (true) { const event = await this.rpc.getNextEvent(); @@ -63,10 +69,17 @@ export class BaseDeltaChat< } } + /** + * @deprecated use {@linkcode BaseDeltaChat.rpc.getAllAccounts} instead. + */ async listAccounts(): Promise { return await this.rpc.getAllAccounts(); } + /** + * A convenience function to listen on events binned by `account_id` + * (see {@linkcode RawClient.getAllAccounts}). + */ getContextEvents(account_id: number) { if (this.contextEmitters[account_id]) { return this.contextEmitters[account_id]; diff --git a/deltachat-repl/Cargo.toml b/deltachat-repl/Cargo.toml index 53b8fefe8b..60d0a9669f 100644 --- a/deltachat-repl/Cargo.toml +++ b/deltachat-repl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deltachat-repl" -version = "2.11.0" +version = "2.22.0" license = "MPL-2.0" edition = "2021" repository = "https://github.com/chatmail/core" diff --git a/deltachat-repl/src/cmdline.rs b/deltachat-repl/src/cmdline.rs index 29f11c20fb..d48ad4b58e 100644 --- a/deltachat-repl/src/cmdline.rs +++ b/deltachat-repl/src/cmdline.rs @@ -6,9 +6,7 @@ use std::str::FromStr; use std::time::Duration; use anyhow::{bail, ensure, Result}; -use deltachat::chat::{ - self, Chat, ChatId, ChatItem, ChatVisibility, MuteDuration, ProtectionStatus, -}; +use deltachat::chat::{self, Chat, ChatId, ChatItem, ChatVisibility, MuteDuration}; use deltachat::chatlist::*; use deltachat::constants::*; use deltachat::contact::*; @@ -210,13 +208,7 @@ async fn log_msg(context: &Context, prefix: impl AsRef, msg: &Message) { } else { "" }, - if msg.get_viewtype() == Viewtype::VideochatInvitation { - format!( - "[VIDEOCHAT-INVITATION: {}, type={}]", - msg.get_videochat_url().unwrap_or_default(), - msg.get_videochat_type().unwrap_or_default() - ) - } else if msg.get_viewtype() == Viewtype::Webxdc { + if msg.get_viewtype() == Viewtype::Webxdc { match msg.get_webxdc_info(context).await { Ok(info) => format!( "[WEBXDC: {}, icon={}, document={}, summary={}, source_code_url={}]", @@ -353,7 +345,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu createchat \n\ creategroup \n\ createbroadcast \n\ - createprotected \n\ addmember \n\ removemember \n\ groupname \n\ @@ -364,6 +355,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu dellocations\n\ getlocations []\n\ send \n\ + send-sync \n\ sendempty\n\ sendimage []\n\ sendsticker []\n\ @@ -371,7 +363,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu sendhtml []\n\ sendsyncmsg\n\ sendupdate \n\ - videochat\n\ draft []\n\ devicemsg \n\ listmedia\n\ @@ -425,7 +416,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu Ok(setup_code) => { println!("Setup code for the transferred setup message: {setup_code}",) } - Err(err) => bail!("Failed to generate setup code: {}", err), + Err(err) => bail!("Failed to generate setup code: {err}"), }, "get-setupcodebegin" => { ensure!(!arg1.is_empty(), "Argument missing."); @@ -439,7 +430,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu setupcodebegin.unwrap_or_default(), ); } else { - bail!("{} is no setup message.", msg_id,); + bail!("{msg_id} is no setup message.",); } } "continue-key-transfer" => { @@ -534,7 +525,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu println!("Report written to: {file:#?}"); } Err(err) => { - bail!("Failed to get connectivity html: {}", err); + bail!("Failed to get connectivity html: {err}"); } } } @@ -569,7 +560,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu for i in (0..cnt).rev() { let chat = Chat::load_from_db(&context, chatlist.get_chat_id(i)?).await?; println!( - "{}#{}: {} [{} fresh] {}{}{}{}", + "{}#{}: {} [{} fresh] {}{}{}", chat_prefix(&chat), chat.get_id(), chat.get_name(), @@ -580,7 +571,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu ChatVisibility::Archived => "πŸ“¦", ChatVisibility::Pinned => "πŸ“Œ", }, - if chat.is_protected() { "πŸ›‘οΈ" } else { "" }, if chat.is_contact_request() { "πŸ†•" } else { @@ -695,7 +685,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu format!("{} member(s)", members.len()) }; println!( - "{}#{}: {} [{}]{}{}{} {}", + "{}#{}: {} [{}]{}{}{}", chat_prefix(sel_chat), sel_chat.get_id(), sel_chat.get_name(), @@ -713,11 +703,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu }, _ => "".to_string(), }, - if sel_chat.is_protected() { - "πŸ›‘οΈ" - } else { - "" - }, ); log_msglist(&context, &msglist).await?; if let Some(draft) = sel_chat.get_id().get_draft(&context).await? { @@ -746,8 +731,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu } "creategroup" => { ensure!(!arg1.is_empty(), "Argument missing."); - let chat_id = - chat::create_group_chat(&context, ProtectionStatus::Unprotected, arg1).await?; + let chat_id = chat::create_group(&context, arg1).await?; println!("Group#{chat_id} created successfully."); } @@ -757,13 +741,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu println!("Broadcast#{chat_id} created successfully."); } - "createprotected" => { - ensure!(!arg1.is_empty(), "Argument missing."); - let chat_id = - chat::create_group_chat(&context, ProtectionStatus::Protected, arg1).await?; - - println!("Group#{chat_id} created and protected successfully."); - } "addmember" => { ensure!(sel_chat.is_some(), "No chat selected"); ensure!(!arg1.is_empty(), "Argument missing."); @@ -915,6 +892,23 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu chat::send_text_msg(&context, sel_chat.as_ref().unwrap().get_id(), msg).await?; } + "send-sync" => { + ensure!(sel_chat.is_some(), "No chat selected."); + ensure!(!arg1.is_empty(), "No message text given."); + + // Send message over a dedicated SMTP connection + // and measure time. + // + // This can be used to benchmark SMTP connection establishment. + let time_start = std::time::Instant::now(); + + let msg = format!("{arg1} {arg2}"); + let mut msg = Message::new_text(msg); + chat::send_msg_sync(&context, sel_chat.as_ref().unwrap().get_id(), &mut msg).await?; + + let time_needed = time_start.elapsed(); + println!("Sent message in {time_needed:?}."); + } "sendempty" => { ensure!(sel_chat.is_some(), "No chat selected."); chat::send_text_msg(&context, sel_chat.as_ref().unwrap().get_id(), "".into()).await?; @@ -962,10 +956,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu let msg_id = MsgId::new(arg1.parse()?); context.send_webxdc_status_update(msg_id, arg2).await?; } - "videochat" => { - ensure!(sel_chat.is_some(), "No chat selected."); - chat::send_videochat_invitation(&context, sel_chat.as_ref().unwrap().get_id()).await?; - } "listmsgs" => { ensure!(!arg1.is_empty(), "Argument missing."); @@ -1259,10 +1249,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu } "providerinfo" => { ensure!(!arg1.is_empty(), "Argument missing."); - let proxy_enabled = context - .get_config_bool(config::Config::ProxyEnabled) - .await?; - match provider::get_provider_info(&context, arg1, proxy_enabled).await { + match provider::get_provider_info(arg1) { Some(info) => { println!("Information for provider belonging to {arg1}:"); println!("status: {}", info.status as u32); @@ -1298,7 +1285,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu ); } "" => (), - _ => bail!("Unknown command: \"{}\" type ? for help.", arg0), + _ => bail!("Unknown command: \"{arg0}\" type ? for help."), } Ok(()) diff --git a/deltachat-repl/src/main.rs b/deltachat-repl/src/main.rs index 8b06cc558e..3b63667fe8 100644 --- a/deltachat-repl/src/main.rs +++ b/deltachat-repl/src/main.rs @@ -199,6 +199,7 @@ const CHAT_COMMANDS: [&str; 39] = [ "dellocations", "getlocations", "send", + "send-sync", "sendempty", "sendimage", "sendsticker", @@ -206,7 +207,6 @@ const CHAT_COMMANDS: [&str; 39] = [ "sendhtml", "sendsyncmsg", "sendupdate", - "videochat", "draft", "devicemsg", "listmedia", @@ -467,7 +467,7 @@ async fn handle_cmd( println!("QR code svg written to: {file:#?}"); } Err(err) => { - bail!("Failed to get QR code svg: {}", err); + bail!("Failed to get QR code svg: {err}"); } } } diff --git a/deltachat-rpc-client/pyproject.toml b/deltachat-rpc-client/pyproject.toml index aa1e2898d9..a48210b1d7 100644 --- a/deltachat-rpc-client/pyproject.toml +++ b/deltachat-rpc-client/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deltachat-rpc-client" -version = "2.11.0" +version = "2.22.0" description = "Python client for Delta Chat core JSON-RPC interface" classifiers = [ "Development Status :: 5 - Production/Stable", @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Communications :: Chat", "Topic :: Communications :: Email" ] diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/account.py b/deltachat-rpc-client/src/deltachat_rpc_client/account.py index 6f919f71e7..53d77f7ce4 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/account.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/account.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from dataclasses import dataclass from typing import TYPE_CHECKING, Optional, Union from warnings import warn @@ -299,7 +300,7 @@ def get_chatlist( chats.append(AttrDict(item)) return chats - def create_group(self, name: str, protect: bool = False) -> Chat: + def create_group(self, name: str) -> Chat: """Create a new group chat. After creation, @@ -316,12 +317,8 @@ def create_group(self, name: str, protect: bool = False) -> Chat: To check, if a chat is still unpromoted, you can look at the `is_unpromoted` property of a chat (see `get_full_snapshot()` / `get_basic_snapshot()`). This may be useful if you want to show some help for just created groups. - - :param protect: If set to 1 the function creates group with protection initially enabled. - Only verified members are allowed in these groups - and end-to-end-encryption is always enabled. """ - return Chat(self, self._rpc.create_group_chat(self.id, name, protect)) + return Chat(self, self._rpc.create_group_chat(self.id, name)) def create_broadcast(self, name: str) -> Chat: """Create a new **broadcast channel** @@ -470,3 +467,8 @@ def import_self_keys(self, path) -> None: 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) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/chat.py b/deltachat-rpc-client/src/deltachat_rpc_client/chat.py index fa4006e0fd..82b104bd72 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/chat.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/chat.py @@ -168,6 +168,11 @@ def send_sticker(self, path: str) -> Message: 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] @@ -289,3 +294,8 @@ def send_contact(self, contact: Contact): 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) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/const.py b/deltachat-rpc-client/src/deltachat_rpc_client/const.py index 6e62142e91..c985bdfe1a 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/const.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/const.py @@ -73,6 +73,10 @@ 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" @@ -156,7 +160,6 @@ class ViewType(str, Enum): VOICE = "Voice" VIDEO = "Video" FILE = "File" - VIDEOCHAT_INVITATION = "VideochatInvitation" WEBXDC = "Webxdc" VCARD = "Vcard" @@ -275,11 +278,3 @@ class SocketSecurity(IntEnum): SSL = 1 STARTTLS = 2 PLAIN = 3 - - -class VideochatType(IntEnum): - """Video chat URL type.""" - - UNKNOWN = 0 - BASICWEBRTC = 1 - JITSI = 2 diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/message.py b/deltachat-rpc-client/src/deltachat_rpc_client/message.py index 4fbad3975c..c7cd378ba0 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/message.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/message.py @@ -102,3 +102,15 @@ def send_webxdc_realtime_advertisement(self): 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)) diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py b/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py index 89610382e0..5b7a3907a9 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/pytestplugin.py @@ -28,9 +28,7 @@ def __init__(self, deltachat: DeltaChat) -> None: def get_unconfigured_account(self) -> Account: """Create a new unconfigured account.""" - account = self.deltachat.add_account() - account.set_config("verified_one_on_one_chats", "1") - return account + return self.deltachat.add_account() def get_unconfigured_bot(self) -> Bot: """Create a new unconfigured bot.""" diff --git a/deltachat-rpc-client/tests/test_calls.py b/deltachat-rpc-client/tests/test_calls.py new file mode 100644 index 0000000000..e83d736f04 --- /dev/null +++ b/deltachat-rpc-client/tests/test_calls.py @@ -0,0 +1,109 @@ +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() + bob.create_chat(alice) # Accept the chat so incoming call causes a notification. + outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info) + 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 + # 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) + + bob.create_chat(alice) # Accept the chat so incoming call causes a notification. + alice_contact_bob = alice.create_contact(bob, "Bob") + alice_chat_bob = alice_contact_bob.create_chat() + alice_chat_bob.place_outgoing_call(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 + + +def test_no_contact_request_call(acfactory) -> None: + alice, bob = acfactory.get_online_accounts(2) + + alice_chat_bob = alice.create_chat(bob) + alice_chat_bob.place_outgoing_call("offer") + alice_chat_bob.send_text("Hello!") + + # Notification for "Hello!" message should arrive + # without the call ringing. + while True: + event = bob.wait_for_event() + + # There should be no incoming call notification. + assert event.kind != EventType.INCOMING_CALL + + if event.kind == EventType.INCOMING_MSG: + msg = bob.get_message_by_id(event.msg_id) + assert msg.get_snapshot().text == "Hello!" + break diff --git a/deltachat-rpc-client/tests/test_securejoin.py b/deltachat-rpc-client/tests/test_securejoin.py index 31f4835207..d77108c59d 100644 --- a/deltachat-rpc-client/tests/test_securejoin.py +++ b/deltachat-rpc-client/tests/test_securejoin.py @@ -58,8 +58,7 @@ def test_qr_setup_contact_svg(acfactory) -> None: assert "Alice" in svg -@pytest.mark.parametrize("protect", [True, False]) -def test_qr_securejoin(acfactory, protect): +def test_qr_securejoin(acfactory): alice, bob, fiona = acfactory.get_online_accounts(3) # Setup second device for Alice @@ -67,8 +66,7 @@ def test_qr_securejoin(acfactory, protect): alice2 = alice.clone() logging.info("Alice creates a group") - alice_chat = alice.create_group("Group", protect=protect) - assert alice_chat.get_basic_snapshot().is_protected == protect + alice_chat = alice.create_group("Group") logging.info("Bob joins the group") qr_code = alice_chat.get_qr_code() @@ -89,7 +87,6 @@ def test_qr_securejoin(acfactory, protect): snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot() assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr")) - assert snapshot.chat.get_basic_snapshot().is_protected == protect # Test that Bob verified Alice's profile. bob_contact_alice = bob.create_contact(alice) @@ -125,8 +122,8 @@ def test_qr_securejoin_contact_request(acfactory) -> None: bob_chat_alice = snapshot.chat assert bob_chat_alice.get_basic_snapshot().is_contact_request - alice_chat = alice.create_group("Verified group", protect=True) - logging.info("Bob joins verified group") + alice_chat = alice.create_group("Group") + logging.info("Bob joins the group") qr_code = alice_chat.get_qr_code() bob.secure_join(qr_code) while True: @@ -150,8 +147,8 @@ def test_qr_readreceipt(acfactory) -> None: for joiner in [bob, charlie]: joiner.wait_for_securejoin_joiner_success() - logging.info("Alice creates a verified group") - group = alice.create_group("Group", protect=True) + logging.info("Alice creates a group") + group = alice.create_group("Group") alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_charlie = alice.create_contact(charlie, "Charlie") @@ -216,11 +213,10 @@ def test_verified_group_member_added_recovery(acfactory) -> None: """Tests verified group recovery by reverifying then removing and adding a member back.""" ac1, ac2, ac3 = acfactory.get_online_accounts(3) - logging.info("ac1 creates verified group") - chat = ac1.create_group("Verified group", protect=True) - assert chat.get_basic_snapshot().is_protected + logging.info("ac1 creates a group") + chat = ac1.create_group("Group") - logging.info("ac2 joins verified group") + logging.info("ac2 joins the group") qr_code = chat.get_qr_code() ac2.secure_join(qr_code) ac2.wait_for_securejoin_joiner_success() @@ -302,8 +298,8 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory): # we first create a fully joined verified group, and then start # joining a second time but interrupt it, to create pending bob state - logging.info("ac1: create verified group that ac2 fully joins") - ch1 = ac1.create_group("Group", protect=True) + logging.info("ac1: create a group that ac2 fully joins") + ch1 = ac1.create_group("Group") qr_code = ch1.get_qr_code() ac2.secure_join(qr_code) ac1.wait_for_securejoin_inviter_success() @@ -313,7 +309,6 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory): while 1: snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot() if snapshot.text == "ac1 says hello": - assert snapshot.chat.get_basic_snapshot().is_protected break logging.info("ac1: let ac2 join again but shutoff ac1 in the middle of securejoin") @@ -327,7 +322,7 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory): assert ac2.create_contact(ac3).get_snapshot().is_verified logging.info("ac3: create a verified group VG with ac2") - vg = ac3.create_group("ac3-created", protect=True) + vg = ac3.create_group("ac3-created") vg.add_contact(ac3.create_contact(ac2)) # ensure ac2 receives message in VG @@ -335,7 +330,6 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory): while 1: msg = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot() if msg.text == "hello": - assert msg.chat.get_basic_snapshot().is_protected break logging.info("ac3: create a join-code for group VG and let ac4 join, check that ac2 got it") @@ -359,7 +353,7 @@ def test_qr_new_group_unblocked(acfactory): """ ac1, ac2 = acfactory.get_online_accounts(2) - ac1_chat = ac1.create_group("Group for joining", protect=True) + ac1_chat = ac1.create_group("Group for joining") qr_code = ac1_chat.get_qr_code() ac2.secure_join(qr_code) @@ -384,8 +378,7 @@ def test_aeap_flow_verified(acfactory): addr, password = acfactory.get_credentials() logging.info("ac1: create verified-group QR, ac2 scans and joins") - chat = ac1.create_group("hello", protect=True) - assert chat.get_basic_snapshot().is_protected + chat = ac1.create_group("hello") qr_code = chat.get_qr_code() logging.info("ac2: start QR-code based join-group protocol") ac2.secure_join(qr_code) @@ -439,7 +432,6 @@ def test_gossip_verification(acfactory) -> None: logging.info("Bob creates an Autocrypt group") bob_group_chat = bob.create_group("Autocrypt Group") - assert not bob_group_chat.get_basic_snapshot().is_protected bob_group_chat.add_contact(bob_contact_alice) bob_group_chat.add_contact(bob_contact_carol) bob_group_chat.send_message(text="Hello Autocrypt group") @@ -448,13 +440,12 @@ def test_gossip_verification(acfactory) -> None: assert snapshot.text == "Hello Autocrypt group" assert snapshot.show_padlock - # Autocrypt group does not propagate verification. + # Group propagates verification using Autocrypt-Gossip header. carol_contact_alice_snapshot = carol_contact_alice.get_snapshot() - assert not carol_contact_alice_snapshot.is_verified + assert carol_contact_alice_snapshot.is_verified logging.info("Bob creates a Securejoin group") - bob_group_chat = bob.create_group("Securejoin Group", protect=True) - assert bob_group_chat.get_basic_snapshot().is_protected + bob_group_chat = bob.create_group("Securejoin Group") bob_group_chat.add_contact(bob_contact_alice) bob_group_chat.add_contact(bob_contact_carol) bob_group_chat.send_message(text="Hello Securejoin group") @@ -477,7 +468,7 @@ def test_securejoin_after_contact_resetup(acfactory) -> None: ac1, ac2, ac3 = acfactory.get_online_accounts(3) # ac3 creates protected group with ac1. - ac3_chat = ac3.create_group("Verified group", protect=True) + ac3_chat = ac3.create_group("Group") # ac1 joins ac3 group. ac3_qr_code = ac3_chat.get_qr_code() @@ -525,7 +516,6 @@ def test_securejoin_after_contact_resetup(acfactory) -> None: snapshot = ac2.get_message_by_id(ac2.wait_for_incoming_msg_event().msg_id).get_snapshot() assert snapshot.is_info ac2_chat = snapshot.chat - assert ac2_chat.get_basic_snapshot().is_protected assert len(ac2_chat.get_contacts()) == 3 # ac1 is still "not verified" for ac2 due to inconsistent state. @@ -535,9 +525,8 @@ def test_securejoin_after_contact_resetup(acfactory) -> None: def test_withdraw_securejoin_qr(acfactory): alice, bob = acfactory.get_online_accounts(2) - logging.info("Alice creates a verified group") - alice_chat = alice.create_group("Verified group", protect=True) - assert alice_chat.get_basic_snapshot().is_protected + logging.info("Alice creates a group") + alice_chat = alice.create_group("Group") logging.info("Bob joins verified group") qr_code = alice_chat.get_qr_code() @@ -548,7 +537,6 @@ def test_withdraw_securejoin_qr(acfactory): snapshot = bob.get_message_by_id(bob.wait_for_incoming_msg_event().msg_id).get_snapshot() assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr")) - assert snapshot.chat.get_basic_snapshot().is_protected bob_chat.leave() snapshot = alice.get_message_by_id(alice.wait_for_msgs_changed_event().msg_id).get_snapshot() diff --git a/deltachat-rpc-client/tests/test_something.py b/deltachat-rpc-client/tests/test_something.py index bd49c0eb82..7f437f14ab 100644 --- a/deltachat-rpc-client/tests/test_something.py +++ b/deltachat-rpc-client/tests/test_something.py @@ -252,6 +252,7 @@ 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() @@ -263,6 +264,7 @@ 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") @@ -329,6 +331,52 @@ 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() @@ -522,8 +570,11 @@ def test_provider_info(rpc) -> None: assert provider_info is None # Test MX record resolution. + # This previously resulted in Gmail provider + # because MX record pointed to google.com domain, + # but MX record resolution has been removed. provider_info = rpc.get_provider_info(account_id, "github.com") - assert provider_info["id"] == "gmail" + assert provider_info is None # Disable MX record resolution. rpc.set_config(account_id, "proxy_enabled", "1") diff --git a/deltachat-rpc-client/tests/test_vcard.py b/deltachat-rpc-client/tests/test_vcard.py index 53e8348b33..c0fdb2c94c 100644 --- a/deltachat-rpc-client/tests/test_vcard.py +++ b/deltachat-rpc-client/tests/test_vcard.py @@ -1,8 +1,11 @@ def test_vcard(acfactory) -> None: - alice, bob = acfactory.get_online_accounts(2) + alice, bob, fiona = acfactory.get_online_accounts(3) alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_charlie = alice.create_contact("charlie@example.org", "Charlie") + alice_contact_charlie_snapshot = alice_contact_charlie.get_snapshot() + alice_contact_fiona = alice.create_contact(fiona, "Fiona") + alice_contact_fiona_snapshot = alice_contact_fiona.get_snapshot() alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob.send_contact(alice_contact_charlie) @@ -12,3 +15,12 @@ def test_vcard(acfactory) -> None: snapshot = message.get_snapshot() assert snapshot.vcard_contact assert snapshot.vcard_contact.addr == "charlie@example.org" + assert snapshot.vcard_contact.color == alice_contact_charlie_snapshot.color + + alice_chat_bob.send_contact(alice_contact_fiona) + event = bob.wait_for_incoming_msg_event() + message = bob.get_message_by_id(event.msg_id) + snapshot = message.get_snapshot() + assert snapshot.vcard_contact + assert snapshot.vcard_contact.key + assert snapshot.vcard_contact.color == alice_contact_fiona_snapshot.color diff --git a/deltachat-rpc-server/Cargo.toml b/deltachat-rpc-server/Cargo.toml index fed4a8da7c..9e74f072c7 100644 --- a/deltachat-rpc-server/Cargo.toml +++ b/deltachat-rpc-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deltachat-rpc-server" -version = "2.11.0" +version = "2.22.0" description = "DeltaChat JSON-RPC server" edition = "2021" readme = "README.md" diff --git a/deltachat-rpc-server/npm-package/package.json b/deltachat-rpc-server/npm-package/package.json index f361d4b7d2..7e321f1f86 100644 --- a/deltachat-rpc-server/npm-package/package.json +++ b/deltachat-rpc-server/npm-package/package.json @@ -15,5 +15,5 @@ }, "type": "module", "types": "index.d.ts", - "version": "2.11.0" + "version": "2.22.0" } diff --git a/deltachat-rpc-server/src/main.rs b/deltachat-rpc-server/src/main.rs index 3381f2c8db..bb3c83b84d 100644 --- a/deltachat-rpc-server/src/main.rs +++ b/deltachat-rpc-server/src/main.rs @@ -41,22 +41,22 @@ async fn main_impl() -> Result<()> { if let Some(first_arg) = args.next() { if first_arg.to_str() == Some("--version") { if let Some(arg) = args.next() { - return Err(anyhow!("Unrecognized argument {:?}", arg)); + return Err(anyhow!("Unrecognized argument {arg:?}")); } eprintln!("{}", &*DC_VERSION_STR); return Ok(()); } else if first_arg.to_str() == Some("--openrpc") { if let Some(arg) = args.next() { - return Err(anyhow!("Unrecognized argument {:?}", arg)); + return Err(anyhow!("Unrecognized argument {arg:?}")); } println!("{}", CommandApi::openrpc_specification()?); return Ok(()); } else { - return Err(anyhow!("Unrecognized option {:?}", first_arg)); + return Err(anyhow!("Unrecognized option {first_arg:?}")); } } if let Some(arg) = args.next() { - return Err(anyhow!("Unrecognized argument {:?}", arg)); + return Err(anyhow!("Unrecognized argument {arg:?}")); } // Install signal handlers early so that the shutdown is graceful starting from here. diff --git a/deny.toml b/deny.toml index ef5fd50434..42d5ab5c52 100644 --- a/deny.toml +++ b/deny.toml @@ -38,8 +38,6 @@ skip = [ { 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" }, diff --git a/flake.nix b/flake.nix index 527c463a65..624e875ca3 100644 --- a/flake.nix +++ b/flake.nix @@ -98,9 +98,6 @@ nativeBuildInputs = [ pkgs.perl # Needed to build vendored OpenSSL. ]; - buildInputs = pkgs.lib.optionals isDarwin [ - pkgs.darwin.apple_sdk.frameworks.SystemConfiguration - ]; auditable = false; # Avoid cargo-auditable failures. doCheck = false; # Disable test as it requires network access. }; @@ -483,12 +480,6 @@ pkgs.rustPlatform.cargoSetupHook pkgs.cargo ]; - buildInputs = pkgs.lib.optionals isDarwin [ - pkgs.darwin.apple_sdk.frameworks.CoreFoundation - pkgs.darwin.apple_sdk.frameworks.Security - pkgs.darwin.apple_sdk.frameworks.SystemConfiguration - pkgs.libiconv - ]; postInstall = '' substituteInPlace $out/include/deltachat.h \ @@ -587,6 +578,7 @@ (python3.withPackages (pypkgs: with pypkgs; [ tox ])) + nodejs ]; }; } diff --git a/python/pyproject.toml b/python/pyproject.toml index f15bf2e49e..a2b3ea8a6d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deltachat" -version = "2.11.0" +version = "2.22.0" description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat" readme = "README.rst" requires-python = ">=3.8" diff --git a/python/src/deltachat/account.py b/python/src/deltachat/account.py index 647226fe2a..a75bcf4a07 100644 --- a/python/src/deltachat/account.py +++ b/python/src/deltachat/account.py @@ -404,18 +404,16 @@ def create_group_chat( self, name: str, contacts: Optional[List[Contact]] = None, - verified: bool = False, ) -> Chat: """create a new group chat object. Chats are unpromoted until the first message is sent. :param contacts: list of contacts to add - :param verified: if true only verified contacts can be added. :returns: a :class:`deltachat.chat.Chat` object. """ bytes_name = name.encode("utf8") - chat_id = lib.dc_create_group_chat(self._dc_context, int(verified), bytes_name) + chat_id = lib.dc_create_group_chat(self._dc_context, 0, bytes_name) chat = Chat(self, chat_id) if contacts is not None: for contact in contacts: diff --git a/python/src/deltachat/chat.py b/python/src/deltachat/chat.py index edc2d5ba7d..a11bc8074d 100644 --- a/python/src/deltachat/chat.py +++ b/python/src/deltachat/chat.py @@ -142,13 +142,6 @@ def can_send(self) -> bool: """ return bool(lib.dc_chat_can_send(self._dc_chat)) - def is_protected(self) -> bool: - """return True if this chat is a protected chat. - - :returns: True if chat is protected, False otherwise. - """ - return bool(lib.dc_chat_is_protected(self._dc_chat)) - def get_name(self) -> Optional[str]: """return name of this chat. diff --git a/python/src/deltachat/message.py b/python/src/deltachat/message.py index d15c1696ed..12813eba84 100644 --- a/python/src/deltachat/message.py +++ b/python/src/deltachat/message.py @@ -8,7 +8,6 @@ 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: @@ -164,17 +163,6 @@ def send_status_update(self, json_data: Union[str, dict], description: str) -> b ), ) - 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)) @@ -447,10 +435,6 @@ def is_video(self): """return True if it's a video message.""" return self._view_type == const.DC_MSG_VIDEO - def is_videochat_invitation(self): - """return True if it's a videochat invitation message.""" - return self._view_type == const.DC_MSG_VIDEOCHAT_INVITATION - def is_webxdc(self): """return True if it's a Webxdc message.""" return self._view_type == const.DC_MSG_WEBXDC @@ -491,7 +475,6 @@ def download_full(self) -> None: "video": const.DC_MSG_VIDEO, "file": const.DC_MSG_FILE, "sticker": const.DC_MSG_STICKER, - "videochat": const.DC_MSG_VIDEOCHAT_INVITATION, "webxdc": const.DC_MSG_WEBXDC, } diff --git a/python/src/deltachat/reactions.py b/python/src/deltachat/reactions.py deleted file mode 100644 index 9838174ce7..0000000000 --- a/python/src/deltachat/reactions.py +++ /dev/null @@ -1,43 +0,0 @@ -"""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"" - - @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)) diff --git a/python/src/deltachat/testplugin.py b/python/src/deltachat/testplugin.py index 15bdb6035b..77a2195f0f 100644 --- a/python/src/deltachat/testplugin.py +++ b/python/src/deltachat/testplugin.py @@ -604,20 +604,6 @@ def get_accepted_chat(self, ac1: Account, ac2: Account): ac2.create_chat(ac1) return ac1.create_chat(ac2) - def get_protected_chat(self, ac1: Account, ac2: Account): - chat = ac1.create_group_chat("Protected Group", verified=True) - qr = chat.get_join_qr() - ac2.qr_join_chat(qr) - ac2._evtracker.wait_securejoin_joiner_progress(1000) - ev = ac2._evtracker.get_matching("DC_EVENT_MSGS_CHANGED") - msg = ac2.get_message_by_id(ev.data2) - assert msg is not None - assert msg.text == "Messages are end-to-end encrypted." - msg = ac2._evtracker.wait_next_incoming_message() - assert msg is not None - assert "Member Me " in msg.text and " added by " in msg.text - return chat - def introduce_each_other(self, accounts, sending=True): to_wait = [] for i, acc in enumerate(accounts): diff --git a/python/tests/test_0_complex_or_slow.py b/python/tests/test_0_complex_or_slow.py index d92dbcacca..3915e3653d 100644 --- a/python/tests/test_0_complex_or_slow.py +++ b/python/tests/test_0_complex_or_slow.py @@ -118,8 +118,7 @@ def test_qr_verified_group_and_chatting(acfactory, lp): ac1, ac2, ac3 = acfactory.get_online_accounts(3) ac1_addr = ac1.get_self_contact().addr lp.sec("ac1: create verified-group QR, ac2 scans and joins") - chat1 = ac1.create_group_chat("hello", verified=True) - assert chat1.is_protected() + chat1 = ac1.create_group_chat("hello") qr = chat1.get_join_qr() lp.sec("ac2: start QR-code based join-group protocol") chat2 = ac2.qr_join_chat(qr) @@ -142,7 +141,6 @@ def test_qr_verified_group_and_chatting(acfactory, lp): lp.sec("ac2: read message and check that it's a verified chat") msg = ac2._evtracker.wait_next_incoming_message() assert msg.text == "hello" - assert msg.chat.is_protected() assert msg.is_encrypted() lp.sec("ac2: Check that ac2 verified ac1") @@ -173,8 +171,10 @@ def test_qr_verified_group_and_chatting(acfactory, lp): lp.sec("ac2: Check that ac1 verified ac3 for ac2") ac2_ac1_contact = ac2.get_contacts()[0] assert ac2.get_self_contact().get_verifier(ac2_ac1_contact).id == dc.const.DC_CONTACT_ID_SELF - ac2_ac3_contact = ac2.get_contacts()[1] - assert ac2.get_self_contact().get_verifier(ac2_ac3_contact).addr == ac1_addr + for ac2_contact in chat2.get_contacts(): + if ac2_contact == ac2_ac1_contact or ac2_contact.id == dc.const.DC_CONTACT_ID_SELF: + continue + assert ac2.get_self_contact().get_verifier(ac2_contact).addr == ac1_addr lp.sec("ac2: send message and let ac3 read it") chat2.send_text("hi") @@ -266,8 +266,7 @@ def test_see_new_verified_member_after_going_online(acfactory, tmp_path, lp): ac1_offl.stop_io() lp.sec("ac1: create verified-group QR, ac2 scans and joins") - chat = ac1.create_group_chat("hello", verified=True) - assert chat.is_protected() + chat = ac1.create_group_chat("hello") qr = chat.get_join_qr() lp.sec("ac2: start QR-code based join-group protocol") chat2 = ac2.qr_join_chat(qr) @@ -321,8 +320,7 @@ def test_use_new_verified_group_after_going_online(acfactory, data, tmp_path, lp ac1.set_avatar(avatar_path) lp.sec("ac1: create verified-group QR, ac2 scans and joins") - chat = ac1.create_group_chat("hello", verified=True) - assert chat.is_protected() + chat = ac1.create_group_chat("hello") qr = chat.get_join_qr() lp.sec("ac2: start QR-code based join-group protocol") ac2.qr_join_chat(qr) @@ -336,7 +334,6 @@ def test_use_new_verified_group_after_going_online(acfactory, data, tmp_path, lp assert msg_in.is_system_message() assert contact.addr == ac1.get_config("addr") chat2 = msg_in.chat - assert chat2.is_protected() assert chat2.get_messages()[0].text == "Messages are end-to-end encrypted." assert open(contact.get_profile_image(), "rb").read() == open(avatar_path, "rb").read() @@ -376,8 +373,7 @@ def test_verified_group_vs_delete_server_after(acfactory, tmp_path, lp): ac2_offl.stop_io() lp.sec("ac1: create verified-group QR, ac2 scans and joins") - chat1 = ac1.create_group_chat("hello", verified=True) - assert chat1.is_protected() + chat1 = ac1.create_group_chat("hello") qr = chat1.get_join_qr() lp.sec("ac2: start QR-code based join-group protocol") chat2 = ac2.qr_join_chat(qr) @@ -402,28 +398,17 @@ def test_verified_group_vs_delete_server_after(acfactory, tmp_path, lp): assert ac2_offl_ac1_contact.addr == ac1.get_config("addr") assert not ac2_offl_ac1_contact.is_verified() chat2_offl = msg_in.chat - assert not chat2_offl.is_protected() lp.sec("ac2: sending message re-gossiping Autocrypt keys") chat2.send_text("hi2") lp.sec("ac2_offl: receiving message") - ev = ac2_offl._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED") - msg_in = ac2_offl.get_message_by_id(ev.data2) - assert msg_in.is_system_message() - assert msg_in.text == "Messages are end-to-end encrypted." - - # We need to consume one event that has data2=0 - ev = ac2_offl._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED") - assert ev.data2 == 0 - ev = ac2_offl._evtracker.get_matching("DC_EVENT_INCOMING_MSG|DC_EVENT_MSGS_CHANGED") msg_in = ac2_offl.get_message_by_id(ev.data2) assert not msg_in.is_system_message() assert msg_in.text == "hi2" assert msg_in.chat == chat2_offl assert msg_in.get_sender_contact().addr == ac2.get_config("addr") - assert msg_in.chat.is_protected() assert ac2_offl_ac1_contact.is_verified() diff --git a/python/tests/test_1_online.py b/python/tests/test_1_online.py index 9ae861eb9a..ee306275d0 100644 --- a/python/tests/test_1_online.py +++ b/python/tests/test_1_online.py @@ -160,32 +160,6 @@ 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) @@ -432,7 +406,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) == 2 + assert len(messages) == 3 msg = messages[-1] assert msg.is_forwarded() ac2.delete_messages(messages) @@ -1230,7 +1204,7 @@ def assert_account_is_proper(ac): def test_qr_email_capitalization(acfactory, lp): """Regression test for a bug - that resulted in failure to propagate verification via gossip in a verified group + that resulted in failure to propagate verification when the database already contained the contact with a different email address capitalization. """ @@ -1241,17 +1215,17 @@ def test_qr_email_capitalization(acfactory, lp): lp.sec(f"ac1 creates a contact for ac2 ({ac2_addr_uppercase})") ac1.create_contact(ac2_addr_uppercase) - lp.sec("ac3 creates a verified group with a QR code") - chat = ac3.create_group_chat("hello", verified=True) + lp.sec("ac3 creates a group with a QR code") + chat = ac3.create_group_chat("hello") qr = chat.get_join_qr() - lp.sec("ac1 joins a verified group via a QR code") + lp.sec("ac1 joins a group via a QR code") ac1_chat = ac1.qr_join_chat(qr) msg = ac1._evtracker.wait_next_incoming_message() assert msg.text == "Member Me added by {}.".format(ac3.get_config("addr")) assert len(ac1_chat.get_contacts()) == 2 - lp.sec("ac2 joins a verified group via a QR code") + lp.sec("ac2 joins a group via a QR code") ac2.qr_join_chat(qr) ac1._evtracker.wait_next_incoming_message() @@ -1781,12 +1755,12 @@ def test_group_quote(acfactory, lp): "xyz", False, "xyz", - ), # Test that emails are recognized in a random folder but not moved + ), # Test that emails aren't found in a random folder ( - "xyz", + "Spam", True, "DeltaChat", - ), # ...emails are found in a random folder and moved to DeltaChat + ), # ...emails are moved from the spam folder to "DeltaChat" ( "Spam", False, @@ -1811,7 +1785,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 the mvbox") + lp.sec("Send a message to from ac2 to ac1 and manually move it to `folder`") ac1.direct_imap.select_config_folder("inbox") with ac1.direct_imap.idle() as idle1: acfactory.get_accepted_chat(ac2, ac1).send_text("hello") @@ -1821,10 +1795,17 @@ 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() - msg = ac1._evtracker.wait_next_incoming_message() - assert msg.text == "hello" - - # The message has been downloaded, which means it has reached its destination. + 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 + + # The message has reached its destination. ac1.direct_imap.select_folder(expected_destination) assert len(ac1.direct_imap.get_all_messages()) == 1 if folder != expected_destination: diff --git a/python/tests/test_3_offline.py b/python/tests/test_3_offline.py index 850baba71f..8701143210 100644 --- a/python/tests/test_3_offline.py +++ b/python/tests/test_3_offline.py @@ -271,10 +271,9 @@ def test_group_chat_creation_with_translation(self, ac1): chat.set_name("Homework") assert chat.get_messages()[-1].text == "abc homework xyz Homework" - @pytest.mark.parametrize("verified", [True, False]) - def test_group_chat_qr(self, acfactory, ac1, verified): + def test_group_chat_qr(self, acfactory, ac1): ac2 = acfactory.get_pseudo_configured_account() - chat = ac1.create_group_chat(name="title1", verified=verified) + chat = ac1.create_group_chat(name="title1") assert chat.is_group() qr = chat.get_join_qr() assert ac2.check_qr(qr).is_ask_verifygroup @@ -663,4 +662,4 @@ def test_audit_log_view_without_daymarker(self, acfactory, lp): 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) == 3 + assert len(sysmessages) == 4 diff --git a/release-date.in b/release-date.in index 133aaaa64c..2fd82012f1 100644 --- a/release-date.in +++ b/release-date.in @@ -1 +1 @@ -2025-08-13 \ No newline at end of file +2025-10-17 \ No newline at end of file diff --git a/scripts/coredeps/install-rust.sh b/scripts/coredeps/install-rust.sh index 5f2af81bd4..74fe1cc7a5 100755 --- a/scripts/coredeps/install-rust.sh +++ b/scripts/coredeps/install-rust.sh @@ -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.89.0 +RUST_VERSION=1.90.0 ARCH="$(uname -m)" test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu diff --git a/scripts/update-provider-database.sh b/scripts/update-provider-database.sh index a9d4e72680..eaed2d97ff 100755 --- a/scripts/update-provider-database.sh +++ b/scripts/update-provider-database.sh @@ -6,7 +6,7 @@ set -euo pipefail export TZ=UTC # Provider database revision. -REV=77cbf92a8565fdf1bcaba10fa93c1455c750a1e9 +REV=1cce91c1f1065b47e4f307d6fe2f4cca68c74d2e CORE_ROOT="$PWD" TMP="$(mktemp -d)" diff --git a/spec.md b/spec.md index 2d8a8fc8f6..995cd30273 100644 --- a/spec.md +++ b/spec.md @@ -1,6 +1,6 @@ # Chatmail Specification -Version: 0.36.0 +Version: 0.37.0 Status: In-progress Format: [Semantic Line Breaks](https://sembr.org/) @@ -582,6 +582,24 @@ and e.g. simply search for the line starting with `EMAIL` in order to get the email address. +# Verifications + +Keys obtained using [SecureJoin](https://securejoin.readthedocs.io) protocol +and corresponding contacts +are considered "verified". + +As an extension to `Autocrypt-Gossip` header, +chatmail clients can add `_verified=1` attribute +(underscore marks the attribute as non-critical) +to indicate that they have the gossiped key +and the corresponding contact marked as verified. + +When receiving such `Autocrypt-Gossip` header +in a message signed by a verified key, +chatmail clients mark the gossiped key +as indirectly verified. + + # Transitioning to a new e-mail address (AEAP) When receiving a message: diff --git a/src/accounts.rs b/src/accounts.rs index ef36ffe967..d10c7a58c4 100644 --- a/src/accounts.rs +++ b/src/accounts.rs @@ -78,7 +78,7 @@ impl Accounts { ensure!(dir.exists(), "directory does not exist"); let config_file = dir.join(CONFIG_NAME); - ensure!(config_file.exists(), "{:?} does not exist", config_file); + ensure!(config_file.exists(), "{config_file:?} does not exist"); let config = Config::from_file(config_file, writable).await?; let events = Events::new(); @@ -724,8 +724,7 @@ impl Config { { ensure!( self.inner.accounts.iter().any(|e| e.id == id), - "invalid account id: {}", - id + "invalid account id: {id}" ); self.inner.selected_account = id; diff --git a/src/aheader.rs b/src/aheader.rs index c0e26a3ecc..db3ee389b5 100644 --- a/src/aheader.rs +++ b/src/aheader.rs @@ -17,7 +17,6 @@ pub enum EncryptPreference { #[default] NoPreference = 0, Mutual = 1, - Reset = 20, } impl fmt::Display for EncryptPreference { @@ -25,7 +24,6 @@ impl fmt::Display for EncryptPreference { match *self { EncryptPreference::Mutual => write!(fmt, "mutual"), EncryptPreference::NoPreference => write!(fmt, "nopreference"), - EncryptPreference::Reset => write!(fmt, "reset"), } } } @@ -37,7 +35,7 @@ impl FromStr for EncryptPreference { match s { "mutual" => Ok(EncryptPreference::Mutual), "nopreference" => Ok(EncryptPreference::NoPreference), - _ => bail!("Cannot parse encryption preference {}", s), + _ => bail!("Cannot parse encryption preference {s}"), } } } @@ -48,21 +46,13 @@ pub struct Aheader { pub addr: String, pub public_key: SignedPublicKey, pub prefer_encrypt: EncryptPreference, -} -impl Aheader { - /// Creates new autocrypt header - pub fn new( - addr: String, - public_key: SignedPublicKey, - prefer_encrypt: EncryptPreference, - ) -> Self { - Aheader { - addr, - public_key, - prefer_encrypt, - } - } + // 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 fmt::Display for Aheader { @@ -71,6 +61,9 @@ 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 @@ -125,6 +118,8 @@ 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('_')) { @@ -135,6 +130,7 @@ impl FromStr for Aheader { addr, public_key, prefer_encrypt, + verified, }) } } @@ -152,10 +148,11 @@ mod tests { assert_eq!(h.addr, "me@mail.com"); assert_eq!(h.prefer_encrypt, EncryptPreference::Mutual); + assert_eq!(h.verified, false); Ok(()) } - // EncryptPreference::Reset is an internal value, parser should never return it + // Non-standard values of prefer-encrypt such as `reset` are treated as no preference. #[test] fn test_from_str_reset() -> Result<()> { let raw = format!("addr=reset@example.com; prefer-encrypt=reset; keydata={RAWKEY}"); @@ -245,11 +242,12 @@ mod tests { assert!( format!( "{}", - Aheader::new( - "test@example.com".to_string(), - SignedPublicKey::from_base64(RAWKEY).unwrap(), - EncryptPreference::Mutual - ) + Aheader { + addr: "test@example.com".to_string(), + public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(), + prefer_encrypt: EncryptPreference::Mutual, + verified: false + } ) .contains("prefer-encrypt=mutual;") ); @@ -260,11 +258,12 @@ mod tests { assert!( !format!( "{}", - Aheader::new( - "test@example.com".to_string(), - SignedPublicKey::from_base64(RAWKEY).unwrap(), - EncryptPreference::NoPreference - ) + Aheader { + addr: "test@example.com".to_string(), + public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(), + prefer_encrypt: EncryptPreference::NoPreference, + verified: false + } ) .contains("prefer-encrypt") ); @@ -273,13 +272,27 @@ mod tests { assert!( format!( "{}", - Aheader::new( - "TeSt@eXaMpLe.cOm".to_string(), - SignedPublicKey::from_base64(RAWKEY).unwrap(), - EncryptPreference::Mutual - ) + Aheader { + addr: "TeSt@eXaMpLe.cOm".to_string(), + public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(), + prefer_encrypt: EncryptPreference::Mutual, + verified: false + } ) .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") + ); } } diff --git a/src/authres.rs b/src/authres.rs index eac4a64778..ae26b3efae 100644 --- a/src/authres.rs +++ b/src/authres.rs @@ -32,7 +32,7 @@ pub(crate) async fn handle_authres( let from_domain = match EmailAddress::new(from) { Ok(email) => email.domain, Err(e) => { - return Err(anyhow::format_err!("invalid email {}: {:#}", from, e)); + return Err(anyhow::format_err!("invalid email {from}: {e:#}")); } }; diff --git a/src/blob.rs b/src/blob.rs index 0c0cf3072b..ca47c65a4d 100644 --- a/src/blob.rs +++ b/src/blob.rs @@ -170,7 +170,7 @@ impl<'a> BlobObject<'a> { false => name, }; if !BlobObject::is_acceptible_blob_name(name) { - return Err(format_err!("not an acceptable blob name: {}", name)); + return Err(format_err!("not an acceptable blob name: {name}")); } Ok(BlobObject { blobdir: context.get_blobdir(), @@ -367,11 +367,12 @@ 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(), @@ -457,8 +458,7 @@ impl<'a> BlobObject<'a> { { if img_wh < 20 { return Err(format_err!( - "Failed to scale image to below {}B.", - max_bytes, + "Failed to scale image to below {max_bytes}B.", )); } @@ -537,7 +537,11 @@ fn file_hash(src: &Path) -> Result { fn image_metadata(file: &std::fs::File) -> Result<(u64, Option)> { let len = file.metadata()?.len(); let mut bufreader = std::io::BufReader::new(file); - let exif = exif::Reader::new().read_from_container(&mut bufreader).ok(); + let exif = exif::Reader::new() + .continue_on_error(true) + .read_from_container(&mut bufreader) + .or_else(|e| e.distill_partial_result(|_errors| {})) + .ok(); Ok((len, exif)) } diff --git a/src/blob/blob_tests.rs b/src/blob/blob_tests.rs index d4170bcc2b..a63c086027 100644 --- a/src/blob/blob_tests.rs +++ b/src/blob/blob_tests.rs @@ -334,6 +334,28 @@ async fn test_recode_image_2() { assert_correct_rotation(&img_rotated); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_recode_image_bad_exif() { + // `exiftool` reports for this file "Bad offset for IFD0 XResolution", still Exif must be + // detected and removed. + let bytes = include_bytes!("../../test-data/image/1000x1000-bad-exif.jpg"); + SendImageCheckMediaquality { + viewtype: Viewtype::Image, + media_quality_config: "0", + bytes, + extension: "jpg", + has_exif: true, + original_width: 1000, + original_height: 1000, + compressed_width: 1000, + compressed_height: 1000, + ..Default::default() + } + .test() + .await + .unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_recode_image_balanced_png() { let bytes = include_bytes!("../../test-data/image/screenshot.png"); @@ -416,6 +438,28 @@ 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-exif.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)] @@ -485,6 +529,7 @@ struct SendImageCheckMediaquality<'a> { pub(crate) original_width: u32, pub(crate) original_height: u32, pub(crate) orientation: i32, + pub(crate) res_viewtype: Option, pub(crate) compressed_width: u32, pub(crate) compressed_height: u32, pub(crate) set_draft: bool, @@ -500,6 +545,7 @@ 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; @@ -550,7 +596,7 @@ impl SendImageCheckMediaquality<'_> { } let bob_msg = bob.recv_msg(&sent).await; - assert_eq!(bob_msg.get_viewtype(), Viewtype::Image); + assert_eq!(bob_msg.get_viewtype(), res_viewtype); assert_eq!(bob_msg.get_width() as u32, compressed_width); assert_eq!(bob_msg.get_height() as u32, compressed_height); let file_saved = bob @@ -564,7 +610,7 @@ impl SendImageCheckMediaquality<'_> { } let (_, exif) = image_metadata(&std::fs::File::open(&file_saved)?)?; - assert!(exif.is_none()); + assert!(res_viewtype != Viewtype::Image || exif.is_none()); let img = check_image_size(file_saved, compressed_width, compressed_height); diff --git a/src/calls.rs b/src/calls.rs new file mode 100644 index 0000000000..88cefb8e15 --- /dev/null +++ b/src/calls.rs @@ -0,0 +1,702 @@ +//! # 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::ChatIdBlocked; +use crate::chat::{Chat, ChatId, send_msg}; +use crate::constants::{Blocked, Chattype}; +use crate::contact::ContactId; +use crate::context::Context; +use crate::events::EventType; +use crate::headerdef::HeaderDef; +use crate::log::{info, warn}; +use crate::message::{self, Message, MsgId, Viewtype}; +use crate::mimeparser::{MimeMessage, SystemMessage}; +use crate::net::dns::lookup_host_with_cache; +use crate::param::Param; +use crate::tools::time; +use anyhow::{Context as _, Result, ensure}; +use sdp::SessionDescription; +use serde::Serialize; +use std::io::Cursor; +use std::str::FromStr; +use std::time::Duration; +use tokio::task; +use tokio::time::sleep; + +/// How long callee's or caller's phone ring. +/// +/// For the callee, this is to prevent endless ringing +/// in case the initial "call" is received, but then the caller went offline. +/// Moreover, this prevents outdated calls to ring +/// in case the initial "call" message arrives delayed. +/// +/// For the caller, this means they should also not wait longer, +/// as the callee won't start the call afterwards. +const RINGING_SECONDS: i64 = 60; + +// For persisting parameters in the call, we use Param::Arg* + +const CALL_ACCEPTED_TIMESTAMP: Param = Param::Arg; +const CALL_ENDED_TIMESTAMP: Param = Param::Arg4; + +const STUN_PORT: u16 = 3478; + +/// Set if incoming call was ended explicitly +/// by the other side before we accepted it. +/// +/// It is used to distinguish "ended" calls +/// that are rejected by us from the calls +/// canceled by the other side +/// immediately after ringing started. +const CALL_CANCELED_TIMESTAMP: Param = Param::Arg2; + +/// Information about the status of a call. +#[derive(Debug, Default)] +pub struct CallInfo { + /// User-defined text as given to place_outgoing_call() + pub place_call_info: String, + + /// User-defined text as given to accept_incoming_call() + pub accept_call_info: String, + + /// Message referring to the call. + /// Data are persisted along with the message using Param::Arg* + pub msg: Message, +} + +impl CallInfo { + /// Returns true if the call is an incoming call. + pub fn is_incoming(&self) -> bool { + self.msg.from_id != ContactId::SELF + } + + /// Returns true if the call should not ring anymore. + pub fn is_stale(&self) -> bool { + (self.is_incoming() || self.msg.timestamp_sent != 0) && self.remaining_ring_seconds() <= 0 + } + + fn remaining_ring_seconds(&self) -> i64 { + let remaining_seconds = self.msg.timestamp_sent + RINGING_SECONDS - time(); + remaining_seconds.clamp(0, RINGING_SECONDS) + } + + async fn update_text(&self, context: &Context, text: &str) -> Result<()> { + context + .sql + .execute( + "UPDATE msgs SET txt=?, txt_normalized=? WHERE id=?", + (text, message::normalize_text(text), self.msg.id), + ) + .await?; + Ok(()) + } + + async fn update_text_duration(&self, context: &Context) -> Result<()> { + let minutes = self.duration_seconds() / 60; + let duration = match minutes { + 0 => "<1 minute".to_string(), + 1 => "1 minute".to_string(), + n => format!("{n} minutes"), + }; + + if self.is_incoming() { + self.update_text(context, &format!("Incoming call\n{duration}")) + .await?; + } else { + self.update_text(context, &format!("Outgoing call\n{duration}")) + .await?; + } + Ok(()) + } + + /// Mark calls as accepted. + /// This is needed for all devices where a stale-timer runs, to prevent accepted calls being terminated as stale. + async fn mark_as_accepted(&mut self, context: &Context) -> Result<()> { + self.msg.param.set_i64(CALL_ACCEPTED_TIMESTAMP, time()); + self.msg.update_param(context).await?; + Ok(()) + } + + /// Returns true if the call is accepted. + pub fn is_accepted(&self) -> bool { + self.msg.param.exists(CALL_ACCEPTED_TIMESTAMP) + } + + /// Returns true if the call is missed + /// because the caller canceled it + /// explicitly before ringing stopped. + /// + /// For outgoing calls this means + /// the receiver has rejected the call + /// explicitly. + pub fn is_canceled(&self) -> bool { + self.msg.param.exists(CALL_CANCELED_TIMESTAMP) + } + + async fn mark_as_ended(&mut self, context: &Context) -> Result<()> { + self.msg.param.set_i64(CALL_ENDED_TIMESTAMP, time()); + self.msg.update_param(context).await?; + Ok(()) + } + + /// Explicitly mark the call as canceled. + /// + /// For incoming calls this should be called + /// when "call ended" message is received + /// from the caller before we picked up the call. + /// In this case the call becomes "missed" early + /// before the ringing timeout. + async fn mark_as_canceled(&mut self, context: &Context) -> Result<()> { + let now = time(); + self.msg.param.set_i64(CALL_ENDED_TIMESTAMP, now); + self.msg.param.set_i64(CALL_CANCELED_TIMESTAMP, now); + self.msg.update_param(context).await?; + Ok(()) + } + + /// Returns true if the call is ended. + pub fn is_ended(&self) -> bool { + self.msg.param.exists(CALL_ENDED_TIMESTAMP) + } + + /// Returns call duration in seconds. + pub fn duration_seconds(&self) -> i64 { + if let (Some(start), Some(end)) = ( + self.msg.param.get_i64(CALL_ACCEPTED_TIMESTAMP), + self.msg.param.get_i64(CALL_ENDED_TIMESTAMP), + ) { + let seconds = end - start; + if seconds <= 0 { + return 1; + } + return seconds; + } + 0 + } +} + +impl Context { + /// Start an outgoing call. + pub async fn place_outgoing_call( + &self, + chat_id: ChatId, + place_call_info: String, + ) -> Result { + let chat = Chat::load_from_db(self, chat_id).await?; + ensure!( + chat.typ == Chattype::Single, + "Can only place calls in 1:1 chats" + ); + ensure!(!chat.is_self_talk(), "Cannot call self"); + + let mut call = Message { + viewtype: Viewtype::Call, + text: "Outgoing call".into(), + ..Default::default() + }; + call.param.set(Param::WebrtcRoom, &place_call_info); + call.id = send_msg(self, chat_id, &mut call).await?; + + let wait = RINGING_SECONDS; + task::spawn(Context::emit_end_call_if_unaccepted( + self.clone(), + wait.try_into()?, + call.id, + )); + + Ok(call.id) + } + + /// Accept an incoming call. + pub async fn accept_incoming_call( + &self, + call_id: MsgId, + accept_call_info: String, + ) -> Result<()> { + let mut call: CallInfo = self.load_call_by_id(call_id).await?.with_context(|| { + format!("accept_incoming_call is called with {call_id} which does not refer to a call") + })?; + ensure!(call.is_incoming()); + if call.is_accepted() || call.is_ended() { + info!(self, "Call already accepted/ended"); + return Ok(()); + } + + call.mark_as_accepted(self).await?; + let chat = Chat::load_from_db(self, call.msg.chat_id).await?; + if chat.is_contact_request() { + chat.id.accept(self).await?; + } + + // send an acceptance message around: to the caller as well as to the other devices of the callee + let mut msg = Message { + viewtype: Viewtype::Text, + text: "[Call accepted]".into(), + ..Default::default() + }; + msg.param.set_cmd(SystemMessage::CallAccepted); + msg.hidden = true; + msg.param + .set(Param::WebrtcAccepted, accept_call_info.to_string()); + msg.set_quote(self, Some(&call.msg)).await?; + msg.id = send_msg(self, call.msg.chat_id, &mut msg).await?; + self.emit_event(EventType::IncomingCallAccepted { + msg_id: call.msg.id, + chat_id: call.msg.chat_id, + }); + self.emit_msgs_changed(call.msg.chat_id, call_id); + Ok(()) + } + + /// Cancel, decline or hangup an incoming or outgoing call. + pub async fn end_call(&self, call_id: MsgId) -> Result<()> { + let mut call: CallInfo = self.load_call_by_id(call_id).await?.with_context(|| { + format!("end_call is called with {call_id} which does not refer to a call") + })?; + if call.is_ended() { + info!(self, "Call already ended"); + return Ok(()); + } + + if !call.is_accepted() { + if call.is_incoming() { + call.mark_as_ended(self).await?; + call.update_text(self, "Declined call").await?; + } else { + call.mark_as_canceled(self).await?; + call.update_text(self, "Canceled call").await?; + } + } else { + call.mark_as_ended(self).await?; + call.update_text_duration(self).await?; + } + + let mut msg = Message { + viewtype: Viewtype::Text, + text: "[Call ended]".into(), + ..Default::default() + }; + msg.param.set_cmd(SystemMessage::CallEnded); + msg.hidden = true; + msg.set_quote(self, Some(&call.msg)).await?; + msg.id = send_msg(self, call.msg.chat_id, &mut msg).await?; + + self.emit_event(EventType::CallEnded { + msg_id: call.msg.id, + chat_id: call.msg.chat_id, + }); + self.emit_msgs_changed(call.msg.chat_id, call_id); + Ok(()) + } + + async fn emit_end_call_if_unaccepted( + context: Context, + wait: u64, + call_id: MsgId, + ) -> Result<()> { + sleep(Duration::from_secs(wait)).await; + let Some(mut call) = context.load_call_by_id(call_id).await? else { + warn!( + context, + "emit_end_call_if_unaccepted is called with {call_id} which does not refer to a call." + ); + return Ok(()); + }; + if !call.is_accepted() && !call.is_ended() { + if call.is_incoming() { + call.mark_as_canceled(&context).await?; + call.update_text(&context, "Missed call").await?; + } else { + call.mark_as_ended(&context).await?; + call.update_text(&context, "Canceled call").await?; + } + context.emit_msgs_changed(call.msg.chat_id, call_id); + context.emit_event(EventType::CallEnded { + msg_id: call.msg.id, + chat_id: call.msg.chat_id, + }); + } + Ok(()) + } + + pub(crate) async fn handle_call_msg( + &self, + call_id: MsgId, + mime_message: &MimeMessage, + from_id: ContactId, + ) -> Result<()> { + if mime_message.is_call() { + let Some(call) = self.load_call_by_id(call_id).await? else { + warn!(self, "{call_id} does not refer to a call message"); + return Ok(()); + }; + + if call.is_incoming() { + if call.is_stale() { + call.update_text(self, "Missed call").await?; + self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call + } else { + call.update_text(self, "Incoming call").await?; + self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified + let has_video = match sdp_has_video(&call.place_call_info) { + Ok(has_video) => has_video, + Err(err) => { + warn!(self, "Failed to determine if SDP offer has video: {err:#}."); + false + } + }; + if let Some(chat_id_blocked) = + ChatIdBlocked::lookup_by_contact(self, from_id).await? + { + match chat_id_blocked.blocked { + Blocked::Not => { + 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, + }); + } + Blocked::Yes | Blocked::Request => { + // Do not notify about incoming calls + // from contact requests and blocked contacts. + // + // User can still access the call and accept it + // via the chat in case of contact requests. + } + } + } + let wait = call.remaining_ring_seconds(); + task::spawn(Context::emit_end_call_if_unaccepted( + self.clone(), + wait.try_into()?, + call.msg.id, + )); + } + } else { + call.update_text(self, "Outgoing call").await?; + self.emit_msgs_changed(call.msg.chat_id, call_id); + } + } else { + match mime_message.is_system_message { + SystemMessage::CallAccepted => { + let Some(mut call) = self.load_call_by_id(call_id).await? else { + warn!(self, "{call_id} does not refer to a call message"); + return Ok(()); + }; + + if call.is_ended() || call.is_accepted() { + info!(self, "CallAccepted received for accepted/ended call"); + return Ok(()); + } + + call.mark_as_accepted(self).await?; + self.emit_msgs_changed(call.msg.chat_id, call_id); + if call.is_incoming() { + self.emit_event(EventType::IncomingCallAccepted { + msg_id: call.msg.id, + chat_id: call.msg.chat_id, + }); + } else { + let accept_call_info = mime_message + .get_header(HeaderDef::ChatWebrtcAccepted) + .unwrap_or_default(); + self.emit_event(EventType::OutgoingCallAccepted { + msg_id: call.msg.id, + chat_id: call.msg.chat_id, + accept_call_info: accept_call_info.to_string(), + }); + } + } + SystemMessage::CallEnded => { + let Some(mut call) = self.load_call_by_id(call_id).await? else { + warn!(self, "{call_id} does not refer to a call message"); + return Ok(()); + }; + + if call.is_ended() { + // may happen eg. if a a message is missed + info!(self, "CallEnded received for ended call"); + return Ok(()); + } + + if !call.is_accepted() { + if call.is_incoming() { + if from_id == ContactId::SELF { + call.mark_as_ended(self).await?; + call.update_text(self, "Declined call").await?; + } else { + call.mark_as_canceled(self).await?; + call.update_text(self, "Missed call").await?; + } + } else { + // outgoing + if from_id == ContactId::SELF { + call.mark_as_canceled(self).await?; + call.update_text(self, "Canceled call").await?; + } else { + call.mark_as_ended(self).await?; + call.update_text(self, "Declined call").await?; + } + } + } else { + call.mark_as_ended(self).await?; + call.update_text_duration(self).await?; + } + + self.emit_msgs_changed(call.msg.chat_id, call_id); + self.emit_event(EventType::CallEnded { + msg_id: call.msg.id, + chat_id: call.msg.chat_id, + }); + } + _ => {} + } + } + Ok(()) + } + + /// Loads information about the call given its ID. + /// + /// If the message referred to by ID is + /// not a call message, returns `None`. + pub async fn load_call_by_id(&self, call_id: MsgId) -> Result> { + let call = Message::load_from_db(self, call_id).await?; + Ok(self.load_call_by_message(call)) + } + + // Loads information about the call given the `Message`. + // + // If the `Message` is not a call message, returns `None` + fn load_call_by_message(&self, call: Message) -> Option { + if call.viewtype != Viewtype::Call { + // This can happen e.g. if a "call accepted" + // or "call ended" message is received + // with `In-Reply-To` referring to non-call message. + return None; + } + + Some(CallInfo { + place_call_info: call + .param + .get(Param::WebrtcRoom) + .unwrap_or_default() + .to_string(), + accept_call_info: call + .param + .get(Param::WebrtcAccepted) + .unwrap_or_default() + .to_string(), + msg: call, + }) + } +} + +/// Returns true if SDP offer has a video. +pub fn sdp_has_video(sdp: &str) -> Result { + let mut cursor = Cursor::new(sdp); + let session_description = + SessionDescription::unmarshal(&mut cursor).context("Failed to parse SDP")?; + for media_description in &session_description.media_descriptions { + if media_description.media_name.media == "video" { + return Ok(true); + } + } + Ok(false) +} + +/// State of the call for display in the message bubble. +#[derive(Debug, PartialEq, Eq)] +pub enum CallState { + /// Fresh incoming or outgoing call that is still ringing. + /// + /// There is no separate state for outgoing call + /// that has been dialled but not ringing on the other side yet + /// as we don't know whether the other side received our call. + Alerting, + + /// Active call. + Active, + + /// Completed call that was once active + /// and then was terminated for any reason. + Completed { + /// Call duration in seconds. + duration: i64, + }, + + /// Incoming call that was not picked up within a timeout + /// or was explicitly ended by the caller before we picked up. + Missed, + + /// Incoming call that was explicitly ended on our side + /// before picking up or outgoing call + /// that was declined before the timeout. + Declined, + + /// Outgoing call that has been canceled on our side + /// before receiving a response. + /// + /// Incoming calls cannot be canceled, + /// on the receiver side canceled calls + /// usually result in missed calls. + Canceled, +} + +/// Returns call state given the message ID. +/// +/// Returns an error if the message is not a call message. +pub async fn call_state(context: &Context, msg_id: MsgId) -> Result { + let call = context + .load_call_by_id(msg_id) + .await? + .with_context(|| format!("{msg_id} is not a call message"))?; + let state = if call.is_incoming() { + if call.is_accepted() { + if call.is_ended() { + CallState::Completed { + duration: call.duration_seconds(), + } + } else { + CallState::Active + } + } else if call.is_canceled() { + // Call was explicitly canceled + // by the caller before we picked it up. + CallState::Missed + } else if call.is_ended() { + CallState::Declined + } else if call.is_stale() { + CallState::Missed + } else { + CallState::Alerting + } + } else if call.is_accepted() { + if call.is_ended() { + CallState::Completed { + duration: call.duration_seconds(), + } + } else { + CallState::Active + } + } else if call.is_canceled() { + CallState::Canceled + } else if call.is_ended() || call.is_stale() { + CallState::Declined + } else { + CallState::Alerting + }; + Ok(state) +} + +/// ICE server for JSON serialization. +#[derive(Serialize, Debug, Clone, PartialEq)] +struct IceServer { + /// STUN or TURN URLs. + pub urls: Vec, + + /// Username for TURN server authentication. + pub username: Option, + + /// Password for logging into the server. + pub credential: Option, +} + +/// Creates JSON with ICE servers. +async fn create_ice_servers( + context: &Context, + hostname: &str, + port: u16, + username: &str, + password: &str, +) -> Result { + // Do not use cache because there is no TLS. + let load_cache = false; + let urls: Vec = 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 { + // 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: + // + + // 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 = 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. +/// +/// +/// +/// 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 +/// . +pub async fn ice_servers(context: &Context) -> Result { + if let Some(ref metadata) = *context.metadata.read().await { + Ok(metadata.ice_servers.clone()) + } else { + Ok("[]".to_string()) + } +} + +#[cfg(test)] +mod calls_tests; diff --git a/src/calls/calls_tests.rs b/src/calls/calls_tests.rs new file mode 100644 index 0000000000..3f983d8438 --- /dev/null +++ b/src/calls/calls_tests.rs @@ -0,0 +1,674 @@ +use super::*; +use crate::chat::forward_msgs; +use crate::config::Config; +use crate::constants::DC_CHAT_ID_TRASH; +use crate::receive_imf::{receive_imf, receive_imf_from_inbox}; +use crate::test_utils::{TestContext, TestContextManager}; + +struct CallSetup { + pub alice: TestContext, + pub alice2: TestContext, + pub alice_call: Message, + pub alice2_call: Message, + pub bob: TestContext, + pub bob2: TestContext, + pub bob_call: Message, + pub bob2_call: Message, +} + +async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()> { + assert_eq!(Message::load_from_db(t, call_id).await?.text, text); + Ok(()) +} + +// Offer and answer examples from +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 +/// 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 { + 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; + + // Create chat on Bob's side + // so incoming call causes a notification. + bob.create_chat(&alice).await; + bob2.create_chat(&alice).await; + + let test_msg_id = alice + .place_outgoing_call(alice_chat.id, PLACE_INFO.to_string()) + .await?; + let sent1 = alice.pop_sent_msg().await; + assert_eq!(sent1.sender_msg_id, test_msg_id); + let alice_call = Message::load_from_db(&alice, sent1.sender_msg_id).await?; + let alice2_call = alice2.recv_msg(&sent1).await; + for (t, m) in [(&alice, &alice_call), (&alice2, &alice2_call)] { + assert!(!m.is_info()); + assert_eq!(m.viewtype, Viewtype::Call); + let info = t + .load_call_by_id(m.id) + .await? + .expect("m should be a call message"); + assert!(!info.is_incoming()); + assert!(!info.is_accepted()); + assert_eq!(info.place_call_info, PLACE_INFO); + assert_text(t, m.id, "Outgoing call").await?; + assert_eq!(call_state(t, m.id).await?, CallState::Alerting); + } + + // Bob receives the message referring to the call on two devices; + // it is an incoming call from the view of Bob + let bob_call = bob.recv_msg(&sent1).await; + let bob2_call = bob2.recv_msg(&sent1).await; + for (t, m) in [(&bob, &bob_call), (&bob2, &bob2_call)] { + assert!(!m.is_info()); + assert_eq!(m.viewtype, Viewtype::Call); + t.evtracker + .get_matching(|evt| matches!(evt, EventType::IncomingCall { .. })) + .await; + let info = t + .load_call_by_id(m.id) + .await? + .expect("IncomingCall event should refer to a call message"); + assert!(info.is_incoming()); + assert!(!info.is_accepted()); + assert_eq!(info.place_call_info, PLACE_INFO); + assert_text(t, m.id, "Incoming call").await?; + assert_eq!(call_state(t, m.id).await?, CallState::Alerting); + } + + Ok(CallSetup { + alice, + alice2, + alice_call, + alice2_call, + bob, + bob2, + bob_call, + bob2_call, + }) +} + +async fn accept_call() -> Result { + let CallSetup { + alice, + alice2, + alice_call, + alice2_call, + bob, + bob2, + bob_call, + bob2_call, + } = setup_call().await?; + + // Bob accepts the incoming call + bob.accept_incoming_call(bob_call.id, ACCEPT_INFO.to_string()) + .await?; + assert_text(&bob, bob_call.id, "Incoming call").await?; + bob.evtracker + .get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. })) + .await; + let sent2 = bob.pop_sent_msg().await; + let info = bob + .load_call_by_id(bob_call.id) + .await? + .expect("bob_call should be a call message"); + assert!(info.is_accepted()); + assert_eq!(info.place_call_info, PLACE_INFO); + assert_eq!(call_state(&bob, bob_call.id).await?, CallState::Active); + + bob2.recv_msg_trash(&sent2).await; + assert_text(&bob, bob_call.id, "Incoming call").await?; + bob2.evtracker + .get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. })) + .await; + let info = bob2 + .load_call_by_id(bob2_call.id) + .await? + .expect("bob2_call should be a call message"); + assert!(info.is_accepted()); + assert_eq!(call_state(&bob2, bob2_call.id).await?, CallState::Active); + + // Alice receives the acceptance message + alice.recv_msg_trash(&sent2).await; + assert_text(&alice, alice_call.id, "Outgoing call").await?; + let ev = alice + .evtracker + .get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. })) + .await; + assert_eq!( + ev, + EventType::OutgoingCallAccepted { + msg_id: alice_call.id, + chat_id: alice_call.chat_id, + accept_call_info: ACCEPT_INFO.to_string() + } + ); + let info = alice + .load_call_by_id(alice_call.id) + .await? + .expect("alice_call should be a call message"); + assert!(info.is_accepted()); + assert_eq!(info.place_call_info, PLACE_INFO); + assert_eq!(call_state(&alice, alice_call.id).await?, CallState::Active); + + alice2.recv_msg_trash(&sent2).await; + assert_text(&alice2, alice2_call.id, "Outgoing call").await?; + alice2 + .evtracker + .get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. })) + .await; + assert_eq!( + call_state(&alice2, alice2_call.id).await?, + CallState::Active + ); + + Ok(CallSetup { + alice, + alice2, + alice_call, + alice2_call, + bob, + bob2, + bob_call, + bob2_call, + }) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_accept_call_callee_ends() -> Result<()> { + // Alice calls Bob, Bob accepts + let CallSetup { + alice, + alice_call, + alice2, + alice2_call, + bob, + bob2, + bob_call, + bob2_call, + .. + } = accept_call().await?; + + // Bob has accepted the call and also ends it + bob.end_call(bob_call.id).await?; + assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?; + bob.evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + let sent3 = bob.pop_sent_msg().await; + assert!(matches!( + call_state(&bob, bob_call.id).await?, + CallState::Completed { .. } + )); + + bob2.recv_msg_trash(&sent3).await; + assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?; + bob2.evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + assert!(matches!( + call_state(&bob2, bob2_call.id).await?, + CallState::Completed { .. } + )); + + // Alice receives the ending message + alice.recv_msg_trash(&sent3).await; + assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?; + alice + .evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + assert!(matches!( + call_state(&alice, alice_call.id).await?, + CallState::Completed { .. } + )); + + alice2.recv_msg_trash(&sent3).await; + assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?; + alice2 + .evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + assert!(matches!( + call_state(&alice2, alice2_call.id).await?, + CallState::Completed { .. } + )); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_accept_call_caller_ends() -> Result<()> { + // Alice calls Bob, Bob accepts + let CallSetup { + alice, + alice_call, + alice2, + alice2_call, + bob, + bob2, + bob_call, + bob2_call, + .. + } = accept_call().await?; + + // Bob has accepted the call but Alice ends it + alice.end_call(alice_call.id).await?; + assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?; + alice + .evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + let sent3 = alice.pop_sent_msg().await; + assert!(matches!( + call_state(&alice, alice_call.id).await?, + CallState::Completed { .. } + )); + + alice2.recv_msg_trash(&sent3).await; + assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?; + alice2 + .evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + assert!(matches!( + call_state(&alice2, alice2_call.id).await?, + CallState::Completed { .. } + )); + + // Bob receives the ending message + bob.recv_msg_trash(&sent3).await; + assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?; + bob.evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + assert!(matches!( + call_state(&bob, bob_call.id).await?, + CallState::Completed { .. } + )); + + bob2.recv_msg_trash(&sent3).await; + assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?; + bob2.evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + assert!(matches!( + call_state(&bob2, bob2_call.id).await?, + CallState::Completed { .. } + )); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_callee_rejects_call() -> Result<()> { + // Alice calls Bob + let CallSetup { + alice, + alice2, + alice_call, + alice2_call, + bob, + bob2, + bob_call, + bob2_call, + .. + } = setup_call().await?; + + // Bob has accepted Alice before, but does not want to talk with Alice + bob_call.chat_id.accept(&bob).await?; + bob.end_call(bob_call.id).await?; + assert_text(&bob, bob_call.id, "Declined call").await?; + bob.evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + let sent3 = bob.pop_sent_msg().await; + assert_eq!(call_state(&bob, bob_call.id).await?, CallState::Declined); + + bob2.recv_msg_trash(&sent3).await; + assert_text(&bob2, bob2_call.id, "Declined call").await?; + bob2.evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + assert_eq!(call_state(&bob2, bob2_call.id).await?, CallState::Declined); + + // Alice receives decline message + alice.recv_msg_trash(&sent3).await; + assert_text(&alice, alice_call.id, "Declined call").await?; + alice + .evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + assert_eq!( + call_state(&alice, alice_call.id).await?, + CallState::Declined + ); + + alice2.recv_msg_trash(&sent3).await; + assert_text(&alice2, alice2_call.id, "Declined call").await?; + alice2 + .evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + assert_eq!( + call_state(&alice2, alice2_call.id).await?, + CallState::Declined + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_caller_cancels_call() -> Result<()> { + // Alice calls Bob + let CallSetup { + alice, + alice2, + alice_call, + alice2_call, + bob, + bob2, + bob_call, + bob2_call, + .. + } = setup_call().await?; + + // Alice changes their mind before Bob picks up + alice.end_call(alice_call.id).await?; + assert_text(&alice, alice_call.id, "Canceled call").await?; + alice + .evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + let sent3 = alice.pop_sent_msg().await; + assert_eq!( + call_state(&alice, alice_call.id).await?, + CallState::Canceled + ); + + alice2.recv_msg_trash(&sent3).await; + assert_text(&alice2, alice2_call.id, "Canceled call").await?; + alice2 + .evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + assert_eq!( + call_state(&alice2, alice2_call.id).await?, + CallState::Canceled + ); + + // Bob receives the ending message + bob.recv_msg_trash(&sent3).await; + assert_text(&bob, bob_call.id, "Missed call").await?; + bob.evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + assert_eq!(call_state(&bob, bob_call.id).await?, CallState::Missed); + + // Test that message summary says it is a missed call. + let bob_call_msg = Message::load_from_db(&bob, bob_call.id).await?; + let summary = bob_call_msg.get_summary(&bob, None).await?; + assert_eq!(summary.text, "πŸ“ž Missed call"); + + bob2.recv_msg_trash(&sent3).await; + assert_text(&bob2, bob2_call.id, "Missed call").await?; + bob2.evtracker + .get_matching(|evt| matches!(evt, EventType::CallEnded { .. })) + .await; + assert_eq!(call_state(&bob2, bob2_call.id).await?, CallState::Missed); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_is_stale_call() -> Result<()> { + // a call started now is not stale + let call_info = CallInfo { + msg: Message { + timestamp_sent: time(), + ..Default::default() + }, + ..Default::default() + }; + assert!(!call_info.is_stale()); + let remaining_seconds = call_info.remaining_ring_seconds(); + assert!(remaining_seconds == RINGING_SECONDS || remaining_seconds == RINGING_SECONDS - 1); + + // call started 5 seconds ago, this is not stale as well + let call_info = CallInfo { + msg: Message { + timestamp_sent: time() - 5, + ..Default::default() + }, + ..Default::default() + }; + assert!(!call_info.is_stale()); + let remaining_seconds = call_info.remaining_ring_seconds(); + assert!(remaining_seconds == RINGING_SECONDS - 5 || remaining_seconds == RINGING_SECONDS - 6); + + // a call started one hour ago is clearly stale + let call_info = CallInfo { + msg: Message { + timestamp_sent: time() - 3600, + ..Default::default() + }, + ..Default::default() + }; + assert!(call_info.is_stale()); + assert_eq!(call_info.remaining_ring_seconds(), 0); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_mark_calls() -> Result<()> { + let CallSetup { + alice, alice_call, .. + } = setup_call().await?; + + let mut call_info: CallInfo = alice + .load_call_by_id(alice_call.id) + .await? + .expect("alice_call should be a call message"); + assert!(!call_info.is_accepted()); + assert!(!call_info.is_ended()); + call_info.mark_as_accepted(&alice).await?; + assert!(call_info.is_accepted()); + assert!(!call_info.is_ended()); + + let mut call_info: CallInfo = alice + .load_call_by_id(alice_call.id) + .await? + .expect("alice_call should be a call message"); + assert!(call_info.is_accepted()); + assert!(!call_info.is_ended()); + + call_info.mark_as_ended(&alice).await?; + assert!(call_info.is_accepted()); + assert!(call_info.is_ended()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_update_call_text() -> Result<()> { + let CallSetup { + alice, alice_call, .. + } = setup_call().await?; + + let call_info = alice + .load_call_by_id(alice_call.id) + .await? + .expect("alice_call should be a call message"); + call_info.update_text(&alice, "foo bar").await?; + + let alice_call = Message::load_from_db(&alice, alice_call.id).await?; + assert_eq!(alice_call.get_text(), "foo bar"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_sdp_has_video() { + assert!(sdp_has_video("foobar").is_err()); + assert_eq!(sdp_has_video(PLACE_INFO).unwrap(), false); + assert_eq!(sdp_has_video(PLACE_INFO_VIDEO).unwrap(), true); +} + +/// Tests that calls are forwarded as text messages. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_forward_call() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + let charlie = &tcm.charlie().await; + + let alice_bob_chat = alice.create_chat(bob).await; + let alice_msg_id = alice + .place_outgoing_call(alice_bob_chat.id, PLACE_INFO.to_string()) + .await + .context("Failed to place a call")?; + let alice_call = Message::load_from_db(alice, alice_msg_id).await?; + + let _alice_sent_call = alice.pop_sent_msg().await; + assert_eq!(alice_call.viewtype, Viewtype::Call); + + let alice_charlie_chat = alice.create_chat(charlie).await; + forward_msgs(alice, &[alice_call.id], alice_charlie_chat.id).await?; + let alice_forwarded_call = alice.pop_sent_msg().await; + let alice_forwarded_call_msg = alice_forwarded_call.load_from_db().await; + assert_eq!(alice_forwarded_call_msg.viewtype, Viewtype::Text); + + let charlie_forwarded_call = charlie.recv_msg(&alice_forwarded_call).await; + assert_eq!(charlie_forwarded_call.viewtype, Viewtype::Text); + + Ok(()) +} + +/// Tests that "end call" message referring +/// to a text message does not make receive_imf fail. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_end_text_call() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + + let received1 = receive_imf( + alice, + b"From: bob@example.net\n\ + To: alice@example.org\n\ + Message-ID: \n\ + Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ + Chat-Version: 1.0\n\ + \n\ + Hello\n", + false, + ) + .await? + .unwrap(); + assert_eq!(received1.msg_ids.len(), 1); + let msg = Message::load_from_db(alice, received1.msg_ids[0]) + .await + .unwrap(); + assert_eq!(msg.viewtype, Viewtype::Text); + + // Receiving "Call ended" message that refers + // to the text message does not result in an error. + let received2 = receive_imf( + alice, + b"From: bob@example.net\n\ + To: alice@example.org\n\ + Message-ID: \n\ + Date: Sun, 22 Mar 2020 23:37:57 +0000\n\ + In-Reply-To: \n\ + Chat-Version: 1.0\n\ + Chat-Content: call-ended\n\ + \n\ + Call ended\n", + false, + ) + .await? + .unwrap(); + assert_eq!(received2.msg_ids.len(), 1); + assert_eq!(received2.chat_id, DC_CHAT_ID_TRASH); + + Ok(()) +} + +/// Tests that partially downloaded "call ended" +/// messages are not processed. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_no_partial_calls() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + + let seen = false; + + // The messages in the test + // have no `Date` on purpose, + // so they are treated as new. + let received_call = receive_imf( + alice, + b"From: bob@example.net\n\ + To: alice@example.org\n\ + Message-ID: \n\ + Chat-Version: 1.0\n\ + Chat-Content: call\n\ + Chat-Webrtc-Room: YWFhYWFhYWFhCg==\n\ + \n\ + Hello, this is a call\n", + seen, + ) + .await? + .unwrap(); + assert_eq!(received_call.msg_ids.len(), 1); + let call_msg = Message::load_from_db(alice, received_call.msg_ids[0]) + .await + .unwrap(); + assert_eq!(call_msg.viewtype, Viewtype::Call); + assert_eq!(call_state(alice, call_msg.id).await?, CallState::Alerting); + + let imf_raw = b"From: bob@example.net\n\ + To: alice@example.org\n\ + Message-ID: \n\ + In-Reply-To: \n\ + Chat-Version: 1.0\n\ + Chat-Content: call-ended\n\ + \n\ + Call ended\n"; + receive_imf_from_inbox( + alice, + "second@example.net", + imf_raw, + seen, + Some(imf_raw.len().try_into().unwrap()), + ) + .await?; + + // The call is still not ended. + assert_eq!(call_state(alice, call_msg.id).await?, CallState::Alerting); + + // Fully downloading the message ends the call. + receive_imf_from_inbox(alice, "second@example.net", imf_raw, seen, None) + .await + .context("Failed to fully download end call message")?; + assert_eq!(call_state(alice, call_msg.id).await?, CallState::Missed); + + Ok(()) +} diff --git a/src/chat.rs b/src/chat.rs index 7f49a4ace6..28767e3966 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -12,7 +12,6 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail, ensure}; use chrono::TimeZone; use deltachat_contact_tools::{ContactAddress, sanitize_bidi_characters, sanitize_single_line}; -use deltachat_derive::{FromSql, ToSql}; use mail_builder::mime::MimePart; use serde::{Deserialize, Serialize}; use strum_macros::EnumIter; @@ -31,6 +30,7 @@ use crate::debug_logging::maybe_set_logging_xdc; use crate::download::DownloadState; use crate::ephemeral::{Timer as EphemeralTimer, start_chat_ephemeral_timers}; use crate::events::EventType; +use crate::key::self_fingerprint; use crate::location; use crate::log::{LogExt, error, info, warn}; use crate::logged_debug_assert; @@ -67,41 +67,6 @@ pub enum ChatItem { }, } -/// Chat protection status. -#[derive( - Debug, - Default, - Display, - Clone, - Copy, - PartialEq, - Eq, - FromPrimitive, - ToPrimitive, - FromSql, - ToSql, - IntoStaticStr, - Serialize, - Deserialize, -)] -#[repr(u32)] -pub enum ProtectionStatus { - /// Chat is not protected. - #[default] - Unprotected = 0, - - /// Chat is protected. - /// - /// 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 reason why messages cannot be sent to the chat. /// /// The reason is mainly for logging and displaying in debug REPL, thus not translated. @@ -306,14 +271,12 @@ impl ChatId { /// Create a group or mailinglist raw database record with the given parameters. /// The function does not add SELF nor checks if the record already exists. - #[expect(clippy::too_many_arguments)] pub(crate) async fn create_multiuser_record( context: &Context, chattype: Chattype, grpid: &str, grpname: &str, create_blocked: Blocked, - create_protected: ProtectionStatus, param: Option, timestamp: i64, ) -> Result { @@ -321,31 +284,27 @@ impl ChatId { let timestamp = cmp::min(timestamp, smeared_time(context)); let row_id = context.sql.insert( - "INSERT INTO chats (type, name, grpid, blocked, created_timestamp, protected, param) VALUES(?, ?, ?, ?, ?, ?, ?);", + "INSERT INTO chats (type, name, grpid, blocked, created_timestamp, protected, param) VALUES(?, ?, ?, ?, ?, 0, ?);", ( chattype, &grpname, grpid, create_blocked, timestamp, - create_protected, param.unwrap_or_default(), ), ).await?; let chat_id = ChatId::new(u32::try_from(row_id)?); + let chat = Chat::load_from_db(context, chat_id).await?; - if create_protected == ProtectionStatus::Protected { - chat_id - .add_protection_msg(context, ProtectionStatus::Protected, None, timestamp) - .await?; - } else { - chat_id.maybe_add_encrypted_msg(context, timestamp).await?; + if chat.is_encrypted(context).await? { + chat_id.add_encrypted_msg(context, timestamp).await?; } info!( context, - "Created group/mailinglist '{}' grpid={} as {}, blocked={}, protected={create_protected}.", + "Created group/mailinglist '{}' grpid={} as {}, blocked={}.", &grpname, grpid, chat_id, @@ -373,7 +332,7 @@ impl ChatId { /// Returns true if the value was modified. pub(crate) async fn set_blocked(self, context: &Context, new_blocked: Blocked) -> Result { if self.is_special() { - bail!("ignoring setting of Block-status for {}", self); + bail!("ignoring setting of Block-status for {self}"); } let count = context .sql @@ -500,111 +459,8 @@ impl ChatId { Ok(()) } - /// Sets protection without sending a message. - /// - /// Returns whether the protection status was actually modified. - pub(crate) async fn inner_set_protection( - self, - context: &Context, - protect: ProtectionStatus, - ) -> Result { - ensure!(!self.is_special(), "Invalid chat-id {self}."); - - let chat = Chat::load_from_db(context, self).await?; - - if protect == chat.protected { - info!(context, "Protection status unchanged for {}.", self); - return Ok(false); - } - - match protect { - ProtectionStatus::Protected => match chat.typ { - Chattype::Single - | Chattype::Group - | Chattype::OutBroadcast - | Chattype::InBroadcast => {} - Chattype::Mailinglist => bail!("Cannot protect mailing lists"), - }, - ProtectionStatus::Unprotected => {} - }; - - context - .sql - .execute("UPDATE chats SET protected=? WHERE id=?;", (protect, self)) - .await?; - - context.emit_event(EventType::ChatModified(self)); - chatlist_events::emit_chatlist_item_changed(context, self); - - // make sure, the receivers will get all keys - self.reset_gossiped_timestamp(context).await?; - - Ok(true) - } - - /// Adds an info message to the chat, telling the user that the protection status changed. - /// - /// Params: - /// - /// * `contact_id`: In a 1:1 chat, pass the chat partner's contact id. - /// * `timestamp_sort` is used as the timestamp of the added message - /// and should be the timestamp of the change happening. - pub(crate) async fn add_protection_msg( - self, - context: &Context, - protect: ProtectionStatus, - contact_id: Option, - timestamp_sort: i64, - ) -> Result<()> { - if contact_id == Some(ContactId::SELF) { - // Do not add protection messages to Saved Messages chat. - // This chat never gets protected and unprotected, - // we do not want the first message - // to be a protection message with an arbitrary timestamp. - return Ok(()); - } - - let text = context.stock_protection_msg(protect, contact_id).await; - let cmd = match protect { - ProtectionStatus::Protected => SystemMessage::ChatProtectionEnabled, - ProtectionStatus::Unprotected => SystemMessage::ChatProtectionDisabled, - }; - add_info_msg_with_cmd( - context, - self, - &text, - cmd, - timestamp_sort, - None, - None, - None, - None, - ) - .await?; - - Ok(()) - } - - /// Adds message "Messages are end-to-end encrypted" if appropriate. - /// - /// This function is rather slow because it does a lot of database queries, - /// but this is fine because it is only called on chat creation. - async fn maybe_add_encrypted_msg(self, context: &Context, timestamp_sort: i64) -> Result<()> { - let chat = Chat::load_from_db(context, self).await?; - - // as secure-join adds its own message on success (after some other messasges), - // we do not want to add "Messages are end-to-end encrypted" on chat creation. - // we detect secure join by `can_send` (for Bob, scanner side) and by `blocked` (for Alice, inviter side) below. - if !chat.is_encrypted(context).await? - || self <= DC_CHAT_ID_LAST_SPECIAL - || chat.is_device_talk() - || chat.is_self_talk() - || (!chat.can_send(context).await? && !chat.is_contact_request()) - || chat.blocked == Blocked::Yes - { - return Ok(()); - } - + /// Adds message "Messages are end-to-end encrypted". + async fn add_encrypted_msg(self, context: &Context, timestamp_sort: i64) -> Result<()> { let text = stock_str::messages_e2e_encrypted(context).await; add_info_msg_with_cmd( context, @@ -621,74 +477,6 @@ impl ChatId { Ok(()) } - /// Sets protection and adds a message. - /// - /// `timestamp_sort` is used as the timestamp of the added message - /// and should be the timestamp of the change happening. - async fn set_protection_for_timestamp_sort( - self, - context: &Context, - protect: ProtectionStatus, - timestamp_sort: i64, - contact_id: Option, - ) -> Result<()> { - let protection_status_modified = self - .inner_set_protection(context, protect) - .await - .with_context(|| format!("Cannot set protection for {self}"))?; - if protection_status_modified { - self.add_protection_msg(context, protect, contact_id, timestamp_sort) - .await?; - chatlist_events::emit_chatlist_item_changed(context, self); - } - Ok(()) - } - - /// Sets protection and sends or adds a message. - /// - /// `timestamp_sent` is the "sent" timestamp of a message caused the protection state change. - pub(crate) async fn set_protection( - self, - context: &Context, - protect: ProtectionStatus, - timestamp_sent: i64, - contact_id: Option, - ) -> Result<()> { - let sort_to_bottom = true; - let (received, incoming) = (false, false); - let ts = self - .calc_sort_timestamp(context, timestamp_sent, sort_to_bottom, received, incoming) - .await? - // Always sort protection messages below `SystemMessage::SecurejoinWait{,Timeout}` ones - // in case of race conditions. - .saturating_add(1); - self.set_protection_for_timestamp_sort(context, protect, ts, contact_id) - .await - } - - /// Sets the 1:1 chat with the given address to ProtectionStatus::Protected, - /// and posts a `SystemMessage::ChatProtectionEnabled` into it. - /// - /// If necessary, creates a hidden chat for this. - pub(crate) async fn set_protection_for_contact( - context: &Context, - contact_id: ContactId, - timestamp: i64, - ) -> Result<()> { - let chat_id = ChatId::create_for_contact_with_blocked(context, contact_id, Blocked::Yes) - .await - .with_context(|| format!("can't create chat for {contact_id}"))?; - chat_id - .set_protection( - context, - ProtectionStatus::Protected, - timestamp, - Some(contact_id), - ) - .await?; - Ok(()) - } - /// Archives or unarchives a chat. pub async fn set_visibility(self, context: &Context, visibility: ChatVisibility) -> Result<()> { self.set_visibility_ex(context, Sync, visibility).await @@ -702,8 +490,7 @@ impl ChatId { ) -> Result<()> { ensure!( !self.is_special(), - "bad chat_id, can not be special chat: {}", - self + "bad chat_id, can not be special chat: {self}" ); context @@ -813,8 +600,7 @@ impl ChatId { pub(crate) async fn delete_ex(self, context: &Context, sync: sync::Sync) -> Result<()> { ensure!( !self.is_special(), - "bad chat_id, can not be a special chat: {}", - self + "bad chat_id, can not be a special chat: {self}" ); let chat = Chat::load_from_db(context, self).await?; @@ -1398,16 +1184,6 @@ impl ChatId { Ok(()) } - /// Returns true if the chat is protected. - pub async fn is_protected(self, context: &Context) -> Result { - let protection_status = context - .sql - .query_get_value("SELECT protected FROM chats WHERE id=?", (self,)) - .await? - .unwrap_or_default(); - Ok(protection_status) - } - /// Returns the sort timestamp for a new message in the chat. /// /// `message_timestamp` should be either the message "sent" timestamp or a timestamp of the @@ -1562,9 +1338,6 @@ pub struct Chat { /// Duration of the chat being muted. pub mute_duration: MuteDuration, - - /// If the chat is protected (verified). - pub(crate) protected: ProtectionStatus, } impl Chat { @@ -1574,7 +1347,7 @@ impl Chat { .sql .query_row( "SELECT c.type, c.name, c.grpid, c.param, c.archived, - c.blocked, c.locations_send_until, c.muted_until, c.protected + c.blocked, c.locations_send_until, c.muted_until FROM chats c WHERE c.id=?;", (chat_id,), @@ -1589,7 +1362,6 @@ impl Chat { blocked: row.get::<_, Option<_>>(5)?.unwrap_or_default(), is_sending_locations: row.get(6)?, mute_duration: row.get(7)?, - protected: row.get(8)?, }; Ok(c) }, @@ -1797,8 +1569,9 @@ impl Chat { /// Returns chat avatar color. /// - /// For 1:1 chats, the color is calculated from the contact's address. - /// For group chats the color is calculated from the chat name. + /// 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. pub async fn get_color(&self, context: &Context) -> Result { let mut color = 0; @@ -1809,6 +1582,8 @@ 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); } @@ -1867,61 +1642,41 @@ impl Chat { !self.is_unpromoted() } - /// Returns true if chat protection is enabled. - /// - /// 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. - pub fn is_protected(&self) -> bool { - self.protected == ProtectionStatus::Protected - } - /// Returns true if the chat is encrypted. pub async fn is_encrypted(&self, context: &Context) -> Result { - let is_encrypted = self.is_protected() - || match self.typ { - Chattype::Single => { - match context - .sql - .query_row_optional( - "SELECT cc.contact_id, c.fingerprint<>'' + let is_encrypted = 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, - } - } - Chattype::Group => { - // Do not encrypt ad-hoc groups. - !self.grpid.is_empty() + (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, } - Chattype::Mailinglist => false, - Chattype::OutBroadcast | Chattype::InBroadcast => true, - }; + } + Chattype::Group => { + // Do not encrypt ad-hoc groups. + !self.grpid.is_empty() + } + Chattype::Mailinglist => false, + Chattype::OutBroadcast | Chattype::InBroadcast => true, + }; Ok(is_encrypted) } - /// Deprecated 2025-07. Returns false. - pub fn is_protection_broken(&self) -> bool { - false - } - /// Returns true if location streaming is enabled in the chat. pub fn is_sending_locations(&self) -> bool { self.is_sending_locations @@ -1959,7 +1714,7 @@ impl Chat { } /// Adds missing values to the msg object, - /// writes the record to the database and returns its msg_id. + /// writes the record to the database. /// /// If `update_msg_id` is set, that record is reused; /// if `update_msg_id` is None, a new record is created. @@ -1968,7 +1723,7 @@ impl Chat { context: &Context, msg: &mut Message, update_msg_id: Option, - ) -> Result { + ) -> Result<()> { let mut to_id = 0; let mut location_id = 0; @@ -2246,23 +2001,27 @@ impl Chat { .await?; } context.scheduler.interrupt_ephemeral_task().await; - Ok(msg.id) + Ok(()) } /// Sends a `SyncAction` synchronising chat contacts to other devices. pub(crate) async fn sync_contacts(&self, context: &Context) -> Result<()> { if self.is_encrypted(context).await? { + let self_fp = self_fingerprint(context).await?; let fingerprint_addrs = context .sql .query_map( - "SELECT c.fingerprint, c.addr + "SELECT c.id, c.fingerprint, c.addr FROM contacts c INNER JOIN chats_contacts cc ON c.id=cc.contact_id WHERE cc.chat_id=? AND cc.add_timestamp >= cc.remove_timestamp", (self.id,), |row| { - let fingerprint = row.get(0)?; - let addr = row.get(1)?; + if row.get::<_, ContactId>(0)? == ContactId::SELF { + return Ok((self_fp.to_string(), String::new())); + } + let fingerprint = row.get(1)?; + let addr = row.get(2)?; Ok((fingerprint, addr)) }, |addrs| addrs.collect::, _>>().map_err(Into::into), @@ -2629,7 +2388,6 @@ impl ChatIdBlocked { _ => (), } - let protected = contact_id == ContactId::SELF || contact.is_verified(context).await?; let smeared_time = create_smeared_timestamp(context); let chat_id = context @@ -2637,19 +2395,14 @@ impl ChatIdBlocked { .transaction(move |transaction| { transaction.execute( "INSERT INTO chats - (type, name, param, blocked, created_timestamp, protected) - VALUES(?, ?, ?, ?, ?, ?)", + (type, name, param, blocked, created_timestamp) + VALUES(?, ?, ?, ?, ?)", ( Chattype::Single, chat_name, params.to_string(), create_blocked as u8, smeared_time, - if protected { - ProtectionStatus::Protected - } else { - ProtectionStatus::Unprotected - }, ), )?; let chat_id = ChatId::new( @@ -2670,19 +2423,12 @@ impl ChatIdBlocked { }) .await?; - if protected { - chat_id - .add_protection_msg( - context, - ProtectionStatus::Protected, - Some(contact_id), - smeared_time, - ) - .await?; - } else { - chat_id - .maybe_add_encrypted_msg(context, smeared_time) - .await?; + let chat = Chat::load_from_db(context, chat_id).await?; + if chat.is_encrypted(context).await? + && !chat.param.exists(Param::Devicetalk) + && !chat.param.exists(Param::Selftalk) + { + chat_id.add_encrypted_msg(context, smeared_time).await?; } Ok(Self { @@ -2693,7 +2439,7 @@ impl ChatIdBlocked { } async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> { - if msg.viewtype == Viewtype::Text || msg.viewtype == Viewtype::VideochatInvitation { + if msg.viewtype == Viewtype::Text || msg.viewtype == Viewtype::Call { // the caller should check if the message text is empty } else if msg.viewtype.has_file() { let viewtype_orig = msg.viewtype; @@ -2971,8 +2717,7 @@ async fn prepare_send_msg( if !msg.hidden { chat_id.unarchive_if_not_muted(context, msg.state).await?; } - msg.id = chat.prepare_msg_raw(context, msg, update_msg_id).await?; - msg.chat_id = chat_id; + chat.prepare_msg_raw(context, msg, update_msg_id).await?; let row_ids = create_send_msg_jobs(context, msg) .await @@ -3000,7 +2745,16 @@ 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 = MimeFactory::from_msg(context, msg.clone()).await?; + 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 attach_selfavatar = mimefactory.attach_selfavatar; let mut recipients = mimefactory.recipients(); @@ -3139,8 +2893,7 @@ pub async fn send_text_msg( ) -> Result { ensure!( !chat_id.is_special(), - "bad chat_id, can not be a special chat: {}", - chat_id + "bad chat_id, can not be a special chat: {chat_id}" ); let mut msg = Message::new_text(text_to_send); @@ -3156,10 +2909,7 @@ pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: Strin ); ensure!(!original_msg.is_info(), "Cannot edit info messages"); ensure!(!original_msg.has_html(), "Cannot edit HTML messages"); - ensure!( - original_msg.viewtype != Viewtype::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" @@ -3207,34 +2957,6 @@ pub(crate) async fn save_text_edit_to_db( Ok(()) } -/// Sends invitation to a videochat. -pub async fn send_videochat_invitation(context: &Context, chat_id: ChatId) -> Result { - ensure!( - !chat_id.is_special(), - "video chat invitation cannot be sent to special chat: {}", - chat_id - ); - - let instance = if let Some(instance) = context.get_config(Config::WebrtcInstance).await? { - if !instance.is_empty() { - instance - } else { - bail!("webrtc_instance is empty"); - } - } else { - bail!("webrtc_instance not set"); - }; - - let instance = Message::create_webrtc_instance(&instance, &create_id()); - - let mut msg = Message::new(Viewtype::VideochatInvitation); - msg.param.set(Param::WebrtcRoom, &instance); - msg.text = - stock_str::videochat_invite_msg_body(context, &Message::parse_webrtc_instance(&instance).1) - .await; - send_msg(context, chat_id, &mut msg).await -} - async fn donation_request_maybe(context: &Context) -> Result<()> { let secs_between_checks = 30 * 24 * 60 * 60; let now = time(); @@ -3678,32 +3400,35 @@ pub async fn get_past_chat_contacts(context: &Context, chat_id: ChatId) -> Resul Ok(list) } -/// Creates a group chat with a given `name`. -/// Deprecated on 2025-06-21, use `create_group_ex()`. -pub async fn create_group_chat( - context: &Context, - protect: ProtectionStatus, - name: &str, -) -> Result { - create_group_ex(context, Some(protect), name).await +/// Creates an encrypted group chat. +pub async fn create_group(context: &Context, name: &str) -> Result { + create_group_ex(context, Sync, create_id(), name).await +} + +/// Creates an unencrypted group chat. +pub async fn create_group_unencrypted(context: &Context, name: &str) -> Result { + create_group_ex(context, Sync, String::new(), name).await } /// Creates a group chat. /// -/// * `encryption` - If `Some`, the chat is encrypted (with key-contacts) and can be protected. +/// * `sync` - Whether a multi-device synchronization message should be sent. Ignored for +/// unencrypted chats currently. +/// * `grpid` - Group ID. Iff nonempty, the chat is encrypted (with key-contacts). /// * `name` - Chat name. -pub async fn create_group_ex( +pub(crate) async fn create_group_ex( context: &Context, - encryption: Option, + sync: sync::Sync, + grpid: String, name: &str, ) -> Result { - let chat_name = sanitize_single_line(name); - ensure!(!chat_name.is_empty(), "Invalid chat name"); - - let grpid = match encryption { - Some(_) => create_id(), - None => String::new(), - }; + 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 timestamp = create_smeared_timestamp(context); let row_id = context @@ -3712,7 +3437,7 @@ pub async fn create_group_ex( "INSERT INTO chats (type, name, grpid, param, created_timestamp) VALUES(?, ?, ?, \'U=1\', ?);", - (Chattype::Group, chat_name, grpid, timestamp), + (Chattype::Group, &chat_name, &grpid, timestamp), ) .await?; @@ -3723,11 +3448,9 @@ pub async fn create_group_ex( chatlist_events::emit_chatlist_changed(context); chatlist_events::emit_chatlist_item_changed(context, chat_id); - if encryption == Some(ProtectionStatus::Protected) { - let protect = ProtectionStatus::Protected; - chat_id - .set_protection_for_timestamp_sort(context, protect, timestamp, None) - .await?; + if !grpid.is_empty() { + // Add "Messages are end-to-end encrypted." message. + chat_id.add_encrypted_msg(context, timestamp).await?; } if !context.get_config_bool(Config::Bot).await? @@ -3736,7 +3459,11 @@ pub async fn create_group_ex( let text = stock_str::new_group_send_first_message(context).await; add_info_msg(context, chat_id, &text, create_smeared_timestamp(context)).await?; } - + if let (true, true) = (sync.into(), !grpid.is_empty()) { + let id = SyncId::Grpid(grpid); + let action = SyncAction::CreateGroupEncrypted(chat_name); + self::sync(context, id, action).await.log_err(context).ok(); + } Ok(chat_id) } @@ -3752,7 +3479,7 @@ pub async fn create_group_ex( /// which would make it hard to grep for it. /// /// After creation, the chat contains no recipients and is in _unpromoted_ state; -/// see [`create_group_chat`] for more information on the unpromoted state. +/// see [`create_group`] for more information on the unpromoted state. /// /// Returns the created chat's id. pub async fn create_broadcast(context: &Context, chat_name: String) -> Result { @@ -3926,13 +3653,11 @@ pub(crate) async fn add_contact_to_chat_ex( let mut chat = Chat::load_from_db(context, chat_id).await?; ensure!( chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast, - "{} is not a group/broadcast where one can add members", - chat_id + "{chat_id} is not a group/broadcast where one can add members" ); ensure!( Contact::real_exists_by_id(context, contact_id).await? || contact_id == ContactId::SELF, - "invalid contact_id {} for adding to group", - contact_id + "invalid contact_id {contact_id} for adding to group" ); ensure!(!chat.is_mailing_list(), "Mailing lists can't be changed"); ensure!( @@ -3978,13 +3703,6 @@ pub(crate) async fn add_contact_to_chat_ex( } } else { // else continue and send status mail - if chat.is_protected() && !contact.is_verified(context).await? { - error!( - context, - "Cannot add non-bidirectionally verified contact {contact_id} to protected chat {chat_id}." - ); - return Ok(false); - } if is_contact_in_chat(context, chat_id, contact_id).await? { return Ok(false); } @@ -4145,8 +3863,7 @@ pub async fn remove_contact_from_chat( ) -> Result<()> { ensure!( !chat_id.is_special(), - "bad chat_id, can not be special chat: {}", - chat_id + "bad chat_id, can not be special chat: {chat_id}" ); ensure!( !contact_id.is_special() || contact_id == ContactId::SELF, @@ -4160,7 +3877,7 @@ pub async fn remove_contact_from_chat( "Cannot remove contact {contact_id} from chat {chat_id}: self not in group." ); context.emit_event(EventType::ErrorSelfNotInGroup(err_msg.clone())); - bail!("{}", err_msg); + bail!("{err_msg}"); } else { let mut sync = Nosync; @@ -4184,7 +3901,7 @@ pub async fn remove_contact_from_chat( if chat.typ == Chattype::Group && chat.is_promoted() { let addr = contact.get_addr(); - let res = send_member_removal_msg(context, chat_id, contact_id, addr).await; + let res = send_member_removal_msg(context, &chat, contact_id, addr).await; if contact_id == ContactId::SELF { res?; @@ -4208,7 +3925,7 @@ pub async fn remove_contact_from_chat( // For incoming broadcast channels, it's not possible to remove members, // but it's possible to leave: let self_addr = context.get_primary_self_addr().await?; - send_member_removal_msg(context, chat_id, contact_id, &self_addr).await?; + send_member_removal_msg(context, &chat, contact_id, &self_addr).await?; } else { bail!("Cannot remove members from non-group chats."); } @@ -4218,14 +3935,18 @@ pub async fn remove_contact_from_chat( async fn send_member_removal_msg( context: &Context, - chat_id: ChatId, + chat: &Chat, contact_id: ContactId, addr: &str, ) -> Result { let mut msg = Message::new(Viewtype::Text); if contact_id == ContactId::SELF { - msg.text = stock_str::msg_group_left_local(context, ContactId::SELF).await; + if chat.typ == Chattype::InBroadcast { + msg.text = stock_str::msg_you_left_broadcast(context).await; + } else { + msg.text = stock_str::msg_group_left_local(context, ContactId::SELF).await; + } } else { msg.text = stock_str::msg_del_member_local(context, contact_id, ContactId::SELF).await; } @@ -4235,7 +3956,7 @@ async fn send_member_removal_msg( msg.param .set(Param::ContactAddedRemoved, contact_id.to_u32()); - send_msg(context, chat_id, &mut msg).await + send_msg(context, chat.id, &mut msg).await } async fn set_group_explicitly_left(context: &Context, grpid: &str) -> Result<()> { @@ -4376,7 +4097,7 @@ pub async fn set_chat_profile_image( msg.text = stock_str::msg_grp_img_changed(context, ContactId::SELF).await; } chat.update_param(context).await?; - if chat.is_promoted() && !chat.is_mailing_list() { + if chat.is_promoted() { msg.id = send_msg(context, chat_id, &mut msg).await?; context.emit_msgs_changed(chat_id, msg.id); } @@ -4398,7 +4119,7 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId) .await?; let mut chat = Chat::load_from_db(context, chat_id).await?; if let Some(reason) = chat.why_cant_send(context).await? { - bail!("cannot send to {}: {}", chat_id, reason); + bail!("cannot send to {chat_id}: {reason}"); } curr_timestamp = create_smeared_timestamps(context, msg_ids.len()); let mut msgs = Vec::with_capacity(msg_ids.len()); @@ -4423,6 +4144,10 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId) .set_int(Param::Forwarded, src_msg_id.to_u32() as i32); } + if msg.get_viewtype() == Viewtype::Call { + msg.viewtype = Viewtype::Text; + } + msg.param.remove(Param::GuaranteeE2ee); msg.param.remove(Param::ForcePlaintext); msg.param.remove(Param::Cmd); @@ -4432,6 +4157,8 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId) msg.param.remove(Param::WebxdcSummary); msg.param.remove(Param::WebxdcSummaryTimestamp); msg.param.remove(Param::IsEdited); + msg.param.remove(Param::WebrtcRoom); + msg.param.remove(Param::WebrtcAccepted); msg.in_reply_to = None; // do not leak data as group names; a default subject is generated by mimefactory @@ -4440,13 +4167,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; - let new_msg_id = chat.prepare_msg_raw(context, &mut msg, None).await?; + 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(new_msg_id); + created_msgs.push(msg.id); } for msg_id in created_msgs { context.emit_msgs_changed(chat_id, msg_id); @@ -4635,24 +4362,21 @@ pub(crate) async fn get_chat_cnt(context: &Context) -> Result { } } -/// Returns a tuple of `(chatid, is_protected, blocked)`. +/// Returns a tuple of `(chatid, blocked)`. pub(crate) async fn get_chat_id_by_grpid( context: &Context, grpid: &str, -) -> Result> { +) -> Result> { context .sql .query_row_optional( - "SELECT id, blocked, protected FROM chats WHERE grpid=?;", + "SELECT id, blocked FROM chats WHERE grpid=?;", (grpid,), |row| { let chat_id = row.get::<_, ChatId>(0)?; let b = row.get::<_, Option>(1)?.unwrap_or_default(); - let p = row - .get::<_, Option>(2)? - .unwrap_or_default(); - Ok((chat_id, p == ProtectionStatus::Protected, b)) + Ok((chat_id, b)) }, ) .await @@ -4955,16 +4679,14 @@ async fn set_contacts_by_fingerprints( "Cannot add key-contacts to unencrypted chat {id}" ); ensure!( - chat.typ == Chattype::OutBroadcast, - "{id} is not a broadcast list", + matches!(chat.typ, Chattype::Group | Chattype::OutBroadcast), + "{id} is not a group or broadcast", ); let mut contacts = HashSet::new(); for (fingerprint, addr) in fingerprint_addrs { - let contact_addr = ContactAddress::new(addr)?; - let contact = - Contact::add_or_lookup_ex(context, "", &contact_addr, fingerprint, Origin::Hidden) - .await? - .0; + let contact = Contact::add_or_lookup_ex(context, "", addr, fingerprint, Origin::Hidden) + .await? + .0; contacts.insert(contact); } let contacts_old = HashSet::::from_iter(get_chat_contacts(context, id).await?); @@ -5003,7 +4725,7 @@ pub(crate) enum SyncId { /// "Message-ID"-s, from oldest to latest. Used for ad-hoc groups. Msgids(Vec), - // Special id for device chat. + /// Special id for device chat. Device, } @@ -5017,6 +4739,8 @@ pub(crate) enum SyncAction { SetMuted(MuteDuration), /// Create broadcast channel with the given name. CreateBroadcast(String), + /// Create encrypted group chat with the given name. + CreateGroupEncrypted(String), Rename(String), /// Set chat contacts by their addresses. SetContacts(Vec), @@ -5082,6 +4806,9 @@ impl Context { if let SyncAction::CreateBroadcast(name) = action { create_broadcast_ex(self, Nosync, grpid.clone(), name.clone()).await?; return Ok(()); + } else if let SyncAction::CreateGroupEncrypted(name) = action { + create_group_ex(self, Nosync, grpid.clone(), name).await?; + return Ok(()); } get_chat_id_by_grpid(self, grpid) .await? @@ -5103,7 +4830,7 @@ impl Context { SyncAction::Accept => chat_id.accept_ex(self, Nosync).await, SyncAction::SetVisibility(v) => chat_id.set_visibility_ex(self, Nosync, *v).await, SyncAction::SetMuted(duration) => set_muted_ex(self, Nosync, chat_id, *duration).await, - SyncAction::CreateBroadcast(_) => { + SyncAction::CreateBroadcast(_) | SyncAction::CreateGroupEncrypted(..) => { Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request.")) } SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await, diff --git a/src/chat/chat_tests.rs b/src/chat/chat_tests.rs index e9980f97e9..45c228834a 100644 --- a/src/chat/chat_tests.rs +++ b/src/chat/chat_tests.rs @@ -11,7 +11,9 @@ 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; @@ -32,7 +34,7 @@ async fn test_chat_info() { "archived": false, "param": "", "is_sending_locations": false, - "color": 35391, + "color": 29381, "profile_image": {}, "draft": "", "is_muted": false, @@ -94,7 +96,7 @@ async fn test_get_draft() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_delete_draft() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "abc").await?; + let chat_id = create_group(&t, "abc").await?; let mut msg = Message::new_text("hi!".to_string()); chat_id.set_draft(&t, Some(&mut msg)).await?; @@ -118,7 +120,7 @@ async fn test_forwarding_draft_failing() -> Result<()> { chat_id.set_draft(&t, Some(&mut msg)).await?; assert_eq!(msg.id, chat_id.get_draft(&t).await?.unwrap().id); - let chat_id2 = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id2 = create_group(&t, "foo").await?; assert!(forward_msgs(&t, &[msg.id], chat_id2).await.is_err()); Ok(()) } @@ -167,7 +169,7 @@ async fn test_draft_stable_ids() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_only_one_draft_per_chat() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "abc").await?; + let chat_id = create_group(&t, "abc").await?; let msgs: Vec = (1..=1000) .map(|i| Message::new_text(i.to_string())) @@ -194,7 +196,7 @@ async fn test_only_one_draft_per_chat() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_change_quotes_on_reused_message_object() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "chat").await?; + let chat_id = create_group(&t, "chat").await?; let quote1 = Message::load_from_db(&t, send_text_msg(&t, chat_id, "quote1".to_string()).await?).await?; let quote2 = @@ -245,7 +247,7 @@ async fn test_quote_replies() -> Result<()> { let alice = TestContext::new_alice().await; let bob = TestContext::new_bob().await; - let grp_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "grp").await?; + let grp_chat_id = create_group(&alice, "grp").await?; let grp_msg_id = send_text_msg(&alice, grp_chat_id, "bar".to_string()).await?; let grp_msg = Message::load_from_db(&alice, grp_msg_id).await?; @@ -293,9 +295,7 @@ async fn test_quote_replies() -> Result<()> { async fn test_add_contact_to_chat_ex_add_self() { // Adding self to a contact should succeed, even though it's pointless. let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo") - .await - .unwrap(); + let chat_id = create_group(&t, "foo").await.unwrap(); let added = add_contact_to_chat_ex(&t, Nosync, chat_id, ContactId::SELF, false) .await .unwrap(); @@ -334,8 +334,7 @@ async fn test_member_add_remove() -> Result<()> { } tcm.section("Create and promote a group."); - let alice_chat_id = - create_group_chat(&alice, ProtectionStatus::Unprotected, "Group chat").await?; + let alice_chat_id = create_group(&alice, "Group chat").await?; let alice_fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await; add_contact_to_chat(&alice, alice_chat_id, alice_fiona_contact_id).await?; let sent = alice @@ -397,8 +396,7 @@ async fn test_parallel_member_remove() -> Result<()> { let alice_charlie_contact_id = alice.add_or_lookup_contact_id(&charlie).await; tcm.section("Alice creates and promotes a group"); - let alice_chat_id = - create_group_chat(&alice, ProtectionStatus::Unprotected, "Group chat").await?; + let alice_chat_id = create_group(&alice, "Group chat").await?; add_contact_to_chat(&alice, alice_chat_id, alice_bob_contact_id).await?; add_contact_to_chat(&alice, alice_chat_id, alice_fiona_contact_id).await?; let alice_sent_msg = alice @@ -455,8 +453,7 @@ async fn test_msg_with_implicit_member_removed() -> Result<()> { let alice_bob_contact_id = alice.add_or_lookup_contact_id(&bob).await; let alice_fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await; let bob_fiona_contact_id = bob.add_or_lookup_contact_id(&fiona).await; - let alice_chat_id = - create_group_chat(&alice, ProtectionStatus::Unprotected, "Group chat").await?; + let alice_chat_id = create_group(&alice, "Group chat").await?; add_contact_to_chat(&alice, alice_chat_id, alice_bob_contact_id).await?; let sent_msg = alice.send_text(alice_chat_id, "I created a group").await; let bob_received_msg = bob.recv_msg(&sent_msg).await; @@ -486,7 +483,7 @@ async fn test_msg_with_implicit_member_removed() -> Result<()> { // If Bob sends a message to Alice now, Fiona is removed. assert_eq!(get_chat_contacts(&alice, alice_chat_id).await?.len(), 3); let sent_msg = bob - .send_text(alice_chat_id, "I have removed Fiona some time ago.") + .send_text(bob_chat_id, "I have removed Fiona some time ago.") .await; alice.recv_msg(&sent_msg).await; assert_eq!(get_chat_contacts(&alice, alice_chat_id).await?.len(), 2); @@ -502,7 +499,7 @@ async fn test_modify_chat_multi_device() -> Result<()> { a1.set_config_bool(Config::BccSelf, true).await?; // create group and sync it to the second device - let a1_chat_id = create_group_chat(&a1, ProtectionStatus::Unprotected, "foo").await?; + let a1_chat_id = create_group(&a1, "foo").await?; let sent = a1.send_text(a1_chat_id, "ho!").await; let a1_msg = a1.get_last_msg().await; let a1_chat = Chat::load_from_db(&a1, a1_chat_id).await?; @@ -600,7 +597,7 @@ async fn test_modify_chat_disordered() -> Result<()> { let fiona = tcm.fiona().await; let fiona_id = alice.add_or_lookup_contact_id(&fiona).await; - let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?; + let alice_chat_id = create_group(&alice, "foo").await?; send_text_msg(&alice, alice_chat_id, "populate".to_string()).await?; add_contact_to_chat(&alice, alice_chat_id, bob_id).await?; @@ -647,9 +644,7 @@ async fn test_lost_member_added() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; let charlie = &tcm.charlie().await; - let alice_chat_id = alice - .create_group_with_members(ProtectionStatus::Unprotected, "Group", &[bob]) - .await; + let alice_chat_id = alice.create_group_with_members("Group", &[bob]).await; let alice_sent = alice.send_text(alice_chat_id, "Hi!").await; let bob_chat_id = bob.recv_msg(&alice_sent).await.chat_id; assert_eq!(get_chat_contacts(bob, bob_chat_id).await?.len(), 2); @@ -679,7 +674,7 @@ async fn test_modify_chat_lost() -> Result<()> { let fiona = tcm.fiona().await; let fiona_id = alice.add_or_lookup_contact_id(&fiona).await; - let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?; + let alice_chat_id = create_group(&alice, "foo").await?; add_contact_to_chat(&alice, alice_chat_id, bob_id).await?; add_contact_to_chat(&alice, alice_chat_id, charlie_id).await?; add_contact_to_chat(&alice, alice_chat_id, fiona_id).await?; @@ -720,7 +715,7 @@ async fn test_leave_group() -> Result<()> { let bob = tcm.bob().await; tcm.section("Alice creates group chat with Bob."); - let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?; + let alice_chat_id = create_group(&alice, "foo").await?; let bob_contact = alice.add_or_lookup_contact(&bob).await.id; add_contact_to_chat(&alice, alice_chat_id, bob_contact).await?; @@ -1379,9 +1374,7 @@ async fn test_pinned() { tokio::time::sleep(std::time::Duration::from_millis(1000)).await; let chat_id2 = t.get_self_chat().await.id; tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - let chat_id3 = create_group_chat(&t, ProtectionStatus::Unprotected, "foo") - .await - .unwrap(); + let chat_id3 = create_group(&t, "foo").await.unwrap(); let chatlist = get_chats_from_chat_list(&t, DC_GCL_NO_SPECIALS).await; assert_eq!(chatlist, vec![chat_id3, chat_id2, chat_id1]); @@ -1471,9 +1464,7 @@ async fn test_set_chat_name() { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; - let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "foo") - .await - .unwrap(); + let chat_id = create_group(alice, "foo").await.unwrap(); assert_eq!( Chat::load_from_db(alice, chat_id).await.unwrap().get_name(), "foo" @@ -1545,7 +1536,7 @@ async fn test_shall_attach_selfavatar() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(alice, "foo").await?; assert!(!shall_attach_selfavatar(alice, chat_id).await?); let contact_id = alice.add_or_lookup_contact_id(bob).await; @@ -1567,7 +1558,7 @@ async fn test_profile_data_on_group_leave() -> Result<()> { let mut tcm = TestContextManager::new(); let t = &tcm.alice().await; let bob = &tcm.bob().await; - let chat_id = create_group_chat(t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(t, "foo").await?; let contact_id = t.add_or_lookup_contact_id(bob).await; add_contact_to_chat(t, chat_id, contact_id).await?; @@ -1592,9 +1583,7 @@ async fn test_profile_data_on_group_leave() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_set_mute_duration() { let t = TestContext::new().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo") - .await - .unwrap(); + let chat_id = create_group(&t, "foo").await.unwrap(); // Initial assert_eq!( Chat::load_from_db(&t, chat_id).await.unwrap().is_muted(), @@ -1643,8 +1632,8 @@ async fn test_set_mute_duration() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] 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", 200000).await?; + let chat_id = create_group(&t, "foo").await?; + add_info_msg(&t, chat_id, "foo info", time()).await?; let msg = t.get_last_msg_in(chat_id).await; assert_eq!(msg.get_chat_id(), chat_id); @@ -1660,13 +1649,13 @@ async fn test_add_info_msg() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_add_info_msg_with_cmd() -> Result<()> { let t = TestContext::new().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let msg_id = add_info_msg_with_cmd( &t, chat_id, "foo bar info", SystemMessage::EphemeralTimerChanged, - 10000, + time(), None, None, None, @@ -1929,19 +1918,31 @@ 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_chat(&t, ProtectionStatus::Unprotected, "a chat").await?; + let chat_id = create_group_unencrypted(&t, "a chat").await?; let color1 = Chat::load_from_db(&t, chat_id).await?.get_color(&t).await?; - assert_eq!(color1, 0x008772); + assert_eq!(color1, 0x6239dc); // 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_chat(&t, ProtectionStatus::Unprotected, "A CHAT").await?; + let chat_id = create_group_unencrypted(&t, "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(t, "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], @@ -2123,7 +2124,7 @@ async fn test_forward_info_msg() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let chat_id1 = create_group_chat(alice, ProtectionStatus::Unprotected, "a").await?; + let chat_id1 = create_group(alice, "a").await?; send_text_msg(alice, chat_id1, "msg one".to_string()).await?; let bob_id = alice.add_or_lookup_contact_id(bob).await; add_contact_to_chat(alice, chat_id1, bob_id).await?; @@ -2190,8 +2191,7 @@ async fn test_forward_group() -> Result<()> { let bob_chat = bob.create_chat(&alice).await; // Alice creates a group with Bob. - let alice_group_chat_id = - create_group_chat(&alice, ProtectionStatus::Unprotected, "Group").await?; + let alice_group_chat_id = create_group(&alice, "Group").await?; let bob_id = alice.add_or_lookup_contact_id(&bob).await; let charlie_id = alice.add_or_lookup_contact_id(&charlie).await; add_contact_to_chat(&alice, alice_group_chat_id, bob_id).await?; @@ -2243,8 +2243,7 @@ async fn test_only_minimal_data_are_forwarded() -> Result<()> { .set_config(Config::Displayname, Some("secretname")) .await?; let bob_id = alice.add_or_lookup_contact_id(&bob).await; - let group_id = - create_group_chat(&alice, ProtectionStatus::Unprotected, "secretgrpname").await?; + let group_id = create_group(&alice, "secretgrpname").await?; add_contact_to_chat(&alice, group_id, bob_id).await?; let mut msg = Message::new_text("bla foo".to_owned()); let sent_msg = alice.send_msg(group_id, &mut msg).await; @@ -2259,7 +2258,7 @@ async fn test_only_minimal_data_are_forwarded() -> Result<()> { let orig_msg = bob.recv_msg(&sent_msg).await; let charlie_id = bob.add_or_lookup_contact_id(&charlie).await; let single_id = ChatId::create_for_contact(&bob, charlie_id).await?; - let group_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "group2").await?; + let group_id = create_group(&bob, "group2").await?; add_contact_to_chat(&bob, group_id, charlie_id).await?; let broadcast_id = create_broadcast(&bob, "Channel".to_string()).await?; add_contact_to_chat(&bob, broadcast_id, charlie_id).await?; @@ -2357,7 +2356,7 @@ async fn test_save_msgs_order() -> Result<()> { for a in [alice, alice1] { a.set_config_bool(Config::SyncMsgs, true).await?; } - let chat_id = create_group_chat(alice, ProtectionStatus::Protected, "grp").await?; + let chat_id = create_group(alice, "grp").await?; let sent = [ alice.send_text(chat_id, "0").await, alice.send_text(chat_id, "1").await, @@ -2413,14 +2412,14 @@ async fn test_forward_from_saved_to_saved() -> Result<()> { let bob = TestContext::new_bob().await; let sent = alice.send_text(alice.create_chat(&bob).await.id, "k").await; - bob.recv_msg(&sent).await; + let received_message = bob.recv_msg(&sent).await; let orig = bob.get_last_msg().await; let self_chat = bob.get_self_chat().await; save_msgs(&bob, &[orig.id]).await?; let saved1 = bob.get_last_msg().await; assert_eq!( saved1.get_original_msg_id(&bob).await?.unwrap(), - sent.sender_msg_id + received_message.id ); assert_ne!(saved1.from_id, ContactId::SELF); @@ -2478,7 +2477,7 @@ async fn test_resend_own_message() -> Result<()> { let alice = TestContext::new_alice().await; let bob = TestContext::new_bob().await; let fiona = TestContext::new_fiona().await; - let alice_grp = create_group_chat(&alice, ProtectionStatus::Unprotected, "grp").await?; + let alice_grp = create_group(&alice, "grp").await?; add_contact_to_chat( &alice, alice_grp, @@ -2565,7 +2564,7 @@ async fn test_resend_foreign_message_fails() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let alice_grp = create_group_chat(alice, ProtectionStatus::Unprotected, "grp").await?; + let alice_grp = create_group(alice, "grp").await?; add_contact_to_chat(alice, alice_grp, alice.add_or_lookup_contact_id(bob).await).await?; let sent1 = alice.send_text(alice_grp, "alice->bob").await; @@ -2582,7 +2581,7 @@ async fn test_resend_info_message_fails() -> Result<()> { let bob = &tcm.bob().await; let charlie = &tcm.charlie().await; - let alice_grp = create_group_chat(alice, ProtectionStatus::Unprotected, "grp").await?; + let alice_grp = create_group(alice, "grp").await?; add_contact_to_chat(alice, alice_grp, alice.add_or_lookup_contact_id(bob).await).await?; alice.send_text(alice_grp, "alice->bob").await; @@ -2605,7 +2604,7 @@ async fn test_can_send_group() -> Result<()> { let chat_id = ChatId::create_for_contact(&alice, bob).await?; let chat = Chat::load_from_db(&alice, chat_id).await?; assert!(chat.can_send(&alice).await?); - let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&alice, "foo").await?; assert_eq!( Chat::load_from_db(&alice, chat_id) .await? @@ -2645,7 +2644,7 @@ async fn test_broadcast() -> Result<()> { add_contact_to_chat( &alice, broadcast_id, - get_chat_contacts(&alice, chat_bob.id).await?.pop().unwrap(), + get_chat_contacts(&alice, msg.chat_id).await?.pop().unwrap(), ) .await?; let fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await; @@ -3012,7 +3011,7 @@ async fn test_leave_broadcast() -> Result<()> { } /// Tests that if Bob leaves a broadcast channel with one device, -/// the other device shows a correct info message "You left.". +/// the other device shows a correct info message "You left the channel.". #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_leave_broadcast_multidevice() -> Result<()> { let mut tcm = TestContextManager::new(); @@ -3047,10 +3046,7 @@ async fn test_leave_broadcast_multidevice() -> Result<()> { assert_eq!(rcvd.chat_id, bob1_hello.chat_id); assert!(rcvd.is_info()); assert_eq!(rcvd.get_info_type(), SystemMessage::MemberRemovedFromGroup); - assert_eq!( - rcvd.text, - stock_str::msg_group_left_local(bob1, ContactId::SELF).await - ); + assert_eq!(rcvd.text, stock_str::msg_you_left_broadcast(bob1).await); Ok(()) } @@ -3104,7 +3100,7 @@ async fn test_chat_get_encryption_info() -> Result<()> { let contact_bob = alice.add_or_lookup_contact_id(bob).await; let contact_fiona = alice.add_or_lookup_contact_id(fiona).await; - let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?; + let chat_id = create_group(alice, "Group").await?; assert_eq!( chat_id.get_encryption_info(alice).await?, "End-to-end encryption available" @@ -3159,11 +3155,33 @@ 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("", &[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; - let chat_id1 = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; - let chat_id2 = create_group_chat(&t, ProtectionStatus::Unprotected, "bar").await?; + let chat_id1 = create_group(&t, "foo").await?; + let chat_id2 = create_group(&t, "bar").await?; assert_eq!( get_chat_media( @@ -3387,7 +3405,7 @@ async fn test_get_chat_media_webxdc_order() -> Result<()> { async fn test_blob_renaming() -> Result<()> { let alice = TestContext::new_alice().await; let bob = TestContext::new_bob().await; - let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group").await?; + let chat_id = create_group(&alice, "Group").await?; add_contact_to_chat(&alice, chat_id, alice.add_or_lookup_contact_id(&bob).await).await?; let file = alice.get_blobdir().join("harmless_file.\u{202e}txt.exe"); fs::write(&file, "aaa").await?; @@ -3449,9 +3467,7 @@ async fn test_sync_blocked() -> Result<()> { // - Group chats synchronisation. // - That blocking a group deletes it on other devices. let fiona = TestContext::new_fiona().await; - let fiona_grp_chat_id = fiona - .create_group_with_members(ProtectionStatus::Unprotected, "grp", &[alice0]) - .await; + let fiona_grp_chat_id = fiona.create_group_with_members("grp", &[alice0]).await; let sent_msg = fiona.send_text(fiona_grp_chat_id, "hi").await; let a0_grp_chat_id = alice0.recv_msg(&sent_msg).await.chat_id; let a1_grp_chat_id = alice1.recv_msg(&sent_msg).await.chat_id; @@ -3584,9 +3600,7 @@ async fn test_sync_delete_chat() -> Result<()> { .get_matching(|evt| matches!(evt, EventType::ChatDeleted { .. })) .await; - let bob_grp_chat_id = bob - .create_group_with_members(ProtectionStatus::Unprotected, "grp", &[alice0]) - .await; + let bob_grp_chat_id = bob.create_group_with_members("grp", &[alice0]).await; let sent_msg = bob.send_text(bob_grp_chat_id, "hi").await; let a0_grp_chat_id = alice0.recv_msg(&sent_msg).await.chat_id; let a1_grp_chat_id = alice1.recv_msg(&sent_msg).await.chat_id; @@ -3819,6 +3833,61 @@ async fn test_sync_name() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_sync_create_group() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice0 = &tcm.alice().await; + let alice1 = &tcm.alice().await; + for a in [alice0, alice1] { + a.set_config_bool(Config::SyncMsgs, true).await?; + } + let bob = &tcm.bob().await; + let a0_bob_contact_id = alice0.add_or_lookup_contact_id(bob).await; + let a1_bob_contact_id = alice1.add_or_lookup_contact_id(bob).await; + let a0_chat_id = create_group(alice0, "grp").await?; + sync(alice0, alice1).await; + let a0_chat = Chat::load_from_db(alice0, a0_chat_id).await?; + let a1_chat_id = get_chat_id_by_grpid(alice1, &a0_chat.grpid) + .await? + .unwrap() + .0; + let a1_chat = Chat::load_from_db(alice1, a1_chat_id).await?; + assert_eq!(a1_chat.get_type(), Chattype::Group); + assert_eq!(a1_chat.is_promoted(), false); + assert_eq!(a1_chat.get_name(), "grp"); + + set_chat_name(alice0, a0_chat_id, "renamed").await?; + sync(alice0, alice1).await; + let a1_chat = Chat::load_from_db(alice1, a1_chat_id).await?; + assert_eq!(a1_chat.is_promoted(), false); + assert_eq!(a1_chat.get_name(), "renamed"); + + add_contact_to_chat(alice0, a0_chat_id, a0_bob_contact_id).await?; + sync(alice0, alice1).await; + let a1_chat = Chat::load_from_db(alice1, a1_chat_id).await?; + assert_eq!(a1_chat.is_promoted(), false); + assert_eq!( + get_chat_contacts(alice1, a1_chat_id).await?, + [a1_bob_contact_id, ContactId::SELF] + ); + + // Let's test a contact removal from another device. + remove_contact_from_chat(alice1, a1_chat_id, a1_bob_contact_id).await?; + sync(alice1, alice0).await; + let a0_chat = Chat::load_from_db(alice0, a0_chat_id).await?; + assert_eq!(a0_chat.is_promoted(), false); + assert_eq!( + get_chat_contacts(alice0, a0_chat_id).await?, + [ContactId::SELF] + ); + + let sent_msg = alice0.send_text(a0_chat_id, "hi").await; + let msg = alice1.recv_msg(&sent_msg).await; + assert_eq!(msg.chat_id, a1_chat_id); + assert_eq!(a1_chat_id.is_promoted(alice1).await?, true); + Ok(()) +} + /// Tests sending JPEG image with .png extension. /// /// This is a regression test, previously sending failed @@ -3953,9 +4022,7 @@ async fn test_info_contact_id() -> Result<()> { } // Alice creates group, Bob receives group - let alice_chat_id = alice - .create_group_with_members(ProtectionStatus::Unprotected, "play", &[bob]) - .await; + let alice_chat_id = alice.create_group_with_members("play", &[bob]).await; let sent_msg1 = alice.send_text(alice_chat_id, "moin").await; let msg = bob.recv_msg(&sent_msg1).await; @@ -4001,26 +4068,27 @@ async fn test_info_contact_id() -> Result<()> { ) .await?; - let fiona_id = alice.add_or_lookup_contact_id(&tcm.fiona().await).await; // contexts are in sync, fiona_id is same everywhere - add_contact_to_chat(alice, alice_chat_id, fiona_id).await?; + let alice_fiona_id = alice.add_or_lookup_contact_id(&tcm.fiona().await).await; + let bob_fiona_id = bob.add_or_lookup_contact_id(&tcm.fiona().await).await; + add_contact_to_chat(alice, alice_chat_id, alice_fiona_id).await?; pop_recv_and_check( alice, alice2, bob, SystemMessage::MemberAddedToGroup, - fiona_id, - fiona_id, + alice_fiona_id, + bob_fiona_id, ) .await?; - remove_contact_from_chat(alice, alice_chat_id, fiona_id).await?; + remove_contact_from_chat(alice, alice_chat_id, alice_fiona_id).await?; pop_recv_and_check( alice, alice2, bob, SystemMessage::MemberRemovedFromGroup, - fiona_id, - fiona_id, + alice_fiona_id, + bob_fiona_id, ) .await?; @@ -4028,11 +4096,12 @@ async fn test_info_contact_id() -> Result<()> { // We raw delete in db as Contact::delete() leaves a tombstone (which is great as the tap works longer then) alice .sql - .execute("DELETE FROM contacts WHERE id=?", (fiona_id,)) + .execute("DELETE FROM contacts WHERE id=?", (alice_fiona_id,)) .await?; let msg = alice.get_last_msg().await; assert_eq!(msg.get_info_type(), SystemMessage::MemberRemovedFromGroup); assert!(msg.get_info_contact_id(alice).await?.is_none()); + assert!(msg.get_info_contact_id(bob).await?.is_none()); Ok(()) } @@ -4050,8 +4119,7 @@ async fn test_add_member_bug() -> Result<()> { let alice_fiona_contact_id = alice.add_or_lookup_contact_id(fiona).await; // Create a group. - let alice_chat_id = - create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?; + let alice_chat_id = create_group(alice, "Group chat").await?; add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; add_contact_to_chat(alice, alice_chat_id, alice_fiona_contact_id).await?; @@ -4095,8 +4163,7 @@ async fn test_past_members() -> Result<()> { let alice_fiona_contact_id = alice.add_or_lookup_contact_id(fiona).await; tcm.section("Alice creates a chat."); - let alice_chat_id = - create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?; + let alice_chat_id = create_group(alice, "Group chat").await?; add_contact_to_chat(alice, alice_chat_id, alice_fiona_contact_id).await?; alice .send_text(alice_chat_id, "Hi! I created a group.") @@ -4122,7 +4189,7 @@ async fn test_past_members() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn non_member_cannot_modify_member_list() -> Result<()> { +async fn test_non_member_cannot_modify_member_list() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; @@ -4130,8 +4197,7 @@ async fn non_member_cannot_modify_member_list() -> Result<()> { let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; - let alice_chat_id = - create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?; + let alice_chat_id = create_group(alice, "Group chat").await?; add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; let alice_sent_msg = alice .send_text(alice_chat_id, "Hi! I created a group.") @@ -4154,6 +4220,12 @@ async fn 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(()) } @@ -4168,8 +4240,7 @@ async fn unpromoted_group_no_tombstones() -> Result<()> { let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; let alice_fiona_contact_id = alice.add_or_lookup_contact_id(fiona).await; - let alice_chat_id = - create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?; + let alice_chat_id = create_group(alice, "Group chat").await?; add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; add_contact_to_chat(alice, alice_chat_id, alice_fiona_contact_id).await?; assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 3); @@ -4200,8 +4271,7 @@ async fn test_expire_past_members_after_60_days() -> Result<()> { let fiona = &tcm.fiona().await; let alice_fiona_contact_id = alice.add_or_lookup_contact_id(fiona).await; - let alice_chat_id = - create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?; + let alice_chat_id = create_group(alice, "Group chat").await?; add_contact_to_chat(alice, alice_chat_id, alice_fiona_contact_id).await?; alice .send_text(alice_chat_id, "Hi! I created a group.") @@ -4238,7 +4308,7 @@ async fn test_past_members_order() -> Result<()> { let fiona = tcm.fiona().await; let fiona_contact_id = t.add_or_lookup_contact_id(&fiona).await; - let chat_id = create_group_chat(t, ProtectionStatus::Unprotected, "Group chat").await?; + let chat_id = create_group(t, "Group chat").await?; add_contact_to_chat(t, chat_id, bob_contact_id).await?; add_contact_to_chat(t, chat_id, charlie_contact_id).await?; add_contact_to_chat(t, chat_id, fiona_contact_id).await?; @@ -4300,8 +4370,7 @@ async fn test_restore_backup_after_60_days() -> Result<()> { let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; let alice_charlie_contact_id = alice.add_or_lookup_contact_id(charlie).await; - let alice_chat_id = - create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?; + let alice_chat_id = create_group(alice, "Group chat").await?; add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; add_contact_to_chat(alice, alice_chat_id, alice_charlie_contact_id).await?; @@ -4489,9 +4558,7 @@ async fn test_cannot_send_edit_request() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let chat_id = alice - .create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[bob]) - .await; + let chat_id = alice.create_group_with_members("My Group", &[bob]).await; // Alice can edit her message let sent1 = alice.send_text(chat_id, "foo").await; @@ -4527,17 +4594,6 @@ async fn test_cannot_send_edit_request() -> Result<()> { .is_err() ); - // Videochat invitations cannot be edited - alice - .set_config(Config::WebrtcInstance, Some("https://foo.bar")) - .await?; - let msg_id = send_videochat_invitation(alice, chat_id).await?; - assert!( - send_edit_request(alice, msg_id, "bar".to_string()) - .await - .is_err() - ); - // If not text was given initally, there is nothing to edit // (this also avoids complexity in UI element changes; focus is typos and rewordings) let mut msg = Message::new(Viewtype::File); @@ -4673,7 +4729,7 @@ async fn test_no_address_contacts_in_group_chats() -> Result<()> { let bob = &tcm.bob().await; let charlie = &tcm.charlie().await; - let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group chat").await?; + let chat_id = create_group(alice, "Group chat").await?; let bob_key_contact_id = alice.add_or_lookup_contact_id(bob).await; let charlie_address_contact_id = alice.add_or_lookup_address_contact_id(charlie).await; @@ -4732,7 +4788,7 @@ async fn test_create_unencrypted_group_chat() -> Result<()> { let bob = &tcm.bob().await; let charlie = &tcm.charlie().await; - let chat_id = create_group_ex(alice, None, "Group chat").await?; + let chat_id = create_group_unencrypted(alice, "Group chat").await?; let bob_key_contact_id = alice.add_or_lookup_contact_id(bob).await; let charlie_address_contact_id = alice.add_or_lookup_address_contact_id(charlie).await; @@ -4749,6 +4805,16 @@ 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(alice, " ").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<()> { @@ -4789,7 +4855,7 @@ async fn test_long_group_name() -> Result<()> { let bob = &tcm.bob().await; let group_name = "δδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδ"; - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, group_name).await?; + let alice_chat_id = create_group(alice, 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 diff --git a/src/chatlist.rs b/src/chatlist.rs index 423a30ea96..81734a869f 100644 --- a/src/chatlist.rs +++ b/src/chatlist.rs @@ -481,27 +481,23 @@ mod tests { use super::*; use crate::chat::save_msgs; use crate::chat::{ - ProtectionStatus, add_contact_to_chat, create_group_chat, get_chat_contacts, - remove_contact_from_chat, send_text_msg, + add_contact_to_chat, create_group, get_chat_contacts, remove_contact_from_chat, + send_text_msg, }; use crate::receive_imf::receive_imf; 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() { let mut tcm = TestContextManager::new(); let bob = &tcm.bob().await; - let chat_id1 = create_group_chat(bob, ProtectionStatus::Unprotected, "a chat") - .await - .unwrap(); - let chat_id2 = create_group_chat(bob, ProtectionStatus::Unprotected, "b chat") - .await - .unwrap(); - let chat_id3 = create_group_chat(bob, ProtectionStatus::Unprotected, "c chat") - .await - .unwrap(); + let chat_id1 = create_group(bob, "a chat").await.unwrap(); + let chat_id2 = create_group(bob, "b chat").await.unwrap(); + let chat_id3 = create_group(bob, "c chat").await.unwrap(); // check that the chatlist starts with the most recent message let chats = Chatlist::try_load(bob, 0, None, None).await.unwrap(); @@ -510,6 +506,8 @@ 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 @@ -532,9 +530,7 @@ mod tests { // receive a message from alice let alice = &tcm.alice().await; - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "alice chat") - .await - .unwrap(); + let alice_chat_id = create_group(alice, "alice chat").await.unwrap(); add_contact_to_chat( alice, alice_chat_id, @@ -572,9 +568,7 @@ mod tests { async fn test_sort_self_talk_up_on_forward() { let t = TestContext::new_alice().await; t.update_device_chats().await.unwrap(); - create_group_chat(&t, ProtectionStatus::Unprotected, "a chat") - .await - .unwrap(); + create_group(&t, "a chat").await.unwrap(); let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap(); assert_eq!(chats.len(), 3); @@ -761,9 +755,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_summary_unwrap() { let t = TestContext::new().await; - let chat_id1 = create_group_chat(&t, ProtectionStatus::Unprotected, "a chat") - .await - .unwrap(); + let chat_id1 = create_group(&t, "a chat").await.unwrap(); let mut msg = Message::new_text("foo:\nbar \r\n test".to_string()); chat_id1.set_draft(&t, Some(&mut msg)).await.unwrap(); @@ -779,9 +771,7 @@ mod tests { async fn test_get_summary_deleted_draft() { let t = TestContext::new().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "a chat") - .await - .unwrap(); + let chat_id = create_group(&t, "a chat").await.unwrap(); let mut msg = Message::new_text("Foobar".to_string()); chat_id.set_draft(&t, Some(&mut msg)).await.unwrap(); @@ -820,15 +810,9 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_load_broken() { let t = TestContext::new_bob().await; - let chat_id1 = create_group_chat(&t, ProtectionStatus::Unprotected, "a chat") - .await - .unwrap(); - create_group_chat(&t, ProtectionStatus::Unprotected, "b chat") - .await - .unwrap(); - create_group_chat(&t, ProtectionStatus::Unprotected, "c chat") - .await - .unwrap(); + let chat_id1 = create_group(&t, "a chat").await.unwrap(); + create_group(&t, "b chat").await.unwrap(); + create_group(&t, "c chat").await.unwrap(); // check that the chatlist starts with the most recent message let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap(); diff --git a/src/color.rs b/src/color.rs index 146970b0fc..a84c59f464 100644 --- a/src/color.rs +++ b/src/color.rs @@ -1,38 +1,39 @@ -//! Implementation of Consistent Color Generation. +//! Color generation. //! -//! 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; +//! 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}; use sha1::{Digest, Sha1}; /// Converts an identifier to Hue angle. -fn str_to_angle(s: &str) -> f64 { +fn str_to_angle(s: &str) -> f32 { 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)); - f64::from(checksum) / 65536.0 * 360.0 + f32::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((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 +fn rgb_to_u32(rgb: Rgb) -> u32 { + 65536 * u32::from(rgb.r) + 256 * u32::from(rgb.g) + u32::from(rgb.b) } /// Converts an identifier to RGB color. /// -/// 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. +/// Lightness is set to half (0.5) to make colors suitable both for light and dark theme. pub fn str_to_color(s: &str) -> u32 { - rgb_to_u32(hsluv_to_rgb((str_to_angle(s), 100.0, 50.0))) + let lightness = 0.5; + let chroma = 0.23; + let angle = str_to_angle(s); + let oklch = Oklch::new(lightness, chroma, angle); + let rgb = oklch.to_rgb(TransferFunction::Srgb); + + rgb_to_u32(rgb) } /// Returns color as a "#RRGGBB" `String` where R, G, B are hex digits. @@ -45,6 +46,7 @@ mod tests { use super::*; #[test] + #[allow(clippy::excessive_precision)] fn test_str_to_angle() { // Test against test vectors from // @@ -57,11 +59,11 @@ mod tests { #[test] fn test_rgb_to_u32() { - 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); + 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); } } diff --git a/src/config.rs b/src/config.rs index 6cd1705b6d..1f73262b2c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -151,10 +151,6 @@ 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"))] @@ -350,9 +346,6 @@ pub enum Config { /// Unset, when quota falls below minimal warning threshold again. QuotaExceeding, - /// address to webrtc instance to use for videochats - WebrtcInstance, - /// Timestamp of the last time housekeeping was run LastHousekeeping, @@ -396,12 +389,6 @@ pub enum Config { /// Make all outgoing messages with Autocrypt header "multipart/signed". SignUnencrypted, - /// Enable header protection for `Autocrypt` header. - /// - /// This is an experimental setting not compatible to other MUAs - /// and older Delta Chat versions (core version <= v1.149.0). - ProtectAutocrypt, - /// Let the core save all events to the database. /// This value is used internally to remember the MsgId of the logging xdc #[strum(props(default = "0"))] @@ -417,16 +404,6 @@ pub enum Config { #[strum(props(default = "172800"))] GossipPeriod, - /// Deprecated 2025-07. Feature flag for verified 1:1 chats; the UI should set it - /// to 1 if it supports verified 1:1 chats. - /// Regardless of this setting, `chat.is_protected()` returns true while the key is verified, - /// and when the key changes, an info message is posted into the chat. - /// 0=Nothing else happens when the key changes. - /// 1=After the key changed, `can_send()` returns false and `is_protection_broken()` returns true - /// until `chat_id.accept()` is called. - #[strum(props(default = "0"))] - VerifiedOneOnOneChats, - /// Row ID of the key in the `keypairs` table /// used for signatures, encryption to self and included in `Autocrypt` header. KeyId, @@ -454,6 +431,9 @@ 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 { @@ -705,7 +685,6 @@ impl Context { Config::Socks5Enabled | Config::ProxyEnabled | Config::BccSelf - | Config::E2eeEnabled | Config::MdnsEnabled | Config::SentboxWatch | Config::MvboxMove @@ -734,7 +713,7 @@ impl Context { Self::check_config(key, value)?; let _pause = match key.needs_io_restart() { - true => self.scheduler.pause(self.clone()).await?, + true => self.scheduler.pause(self).await?, _ => Default::default(), }; self.set_config_internal(key, value).await?; diff --git a/src/configure.rs b/src/configure.rs index a4fcf4062b..91aff2c5c0 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -137,7 +137,7 @@ impl Context { let res = self .inner_configure(param) - .race(cancel_channel.recv().map(|_| Err(format_err!("Cancelled")))) + .race(cancel_channel.recv().map(|_| Err(format_err!("Canceled")))) .await; self.free_ongoing().await; @@ -300,8 +300,6 @@ async fn get_configured_param( param.smtp.password.clone() }; - let proxy_enabled = ctx.get_config_bool(Config::ProxyEnabled).await?; - let mut addr = param.addr.clone(); if param.oauth2 { // the used oauth2 addr may differ, check this. @@ -343,7 +341,7 @@ async fn get_configured_param( "checking internal provider-info for offline autoconfig" ); - provider = provider::get_provider_info(ctx, ¶m_domain, proxy_enabled).await; + provider = provider::get_provider_info(¶m_domain); if let Some(provider) = provider { if provider.server.is_empty() { info!(ctx, "Offline autoconfig found, but no servers defined."); diff --git a/src/configure/auto_mozilla.rs b/src/configure/auto_mozilla.rs index 62de517419..664334ed96 100644 --- a/src/configure/auto_mozilla.rs +++ b/src/configure/auto_mozilla.rs @@ -106,7 +106,7 @@ fn parse_server( } } Event::Text(ref event) => { - let val = event.unescape().unwrap_or_default().trim().to_owned(); + let val = event.xml_content().unwrap_or_default().trim().to_owned(); match tag_config { MozConfigTag::Hostname => hostname = Some(val), diff --git a/src/configure/auto_outlook.rs b/src/configure/auto_outlook.rs index 6f4687ee81..40bbcfe5e4 100644 --- a/src/configure/auto_outlook.rs +++ b/src/configure/auto_outlook.rs @@ -79,7 +79,7 @@ fn parse_protocol( } } Event::Text(ref e) => { - let val = e.unescape().unwrap_or_default(); + let val = e.xml_content().unwrap_or_default(); if let Some(ref tag) = current_tag { match tag.as_str() { @@ -123,7 +123,7 @@ fn parse_redirecturl( let mut buf = Vec::new(); match reader.read_event_into(&mut buf)? { Event::Text(ref e) => { - let val = e.unescape().unwrap_or_default(); + let val = e.xml_content().unwrap_or_default(); Ok(val.trim().to_string()) } _ => Ok("".to_string()), diff --git a/src/constants.rs b/src/constants.rs index 59b69d8450..b1275fd214 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -60,23 +60,6 @@ pub enum MediaQuality { Worse = 1, } -/// Video chat URL type. -#[derive( - Debug, Default, Display, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive, FromSql, ToSql, -)] -#[repr(i8)] -pub enum VideochatType { - /// Unknown type. - #[default] - Unknown = 0, - - /// [basicWebRTC](https://github.com/cracker0dks/basicwebrtc) instance. - BasicWebrtc = 1, - - /// [Jitsi Meet](https://jitsi.org/jitsi-meet/) instance. - Jitsi = 2, -} - pub const DC_HANDSHAKE_CONTINUE_NORMAL_PROCESSING: i32 = 0x01; pub const DC_HANDSHAKE_STOP_NORMAL_PROCESSING: i32 = 0x02; pub const DC_HANDSHAKE_ADD_DELETE_JOB: i32 = 0x04; @@ -95,10 +78,11 @@ 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. -// 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, +// reference is the release date. +// 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 = 365; +// "90 days" has proven to be too short at some point (user were informed but there was no update) +pub(crate) const DC_OUTDATED_WARNING_DAYS: i64 = 183; /// 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); @@ -134,7 +118,7 @@ pub enum Chattype { /// Group chat. /// - /// Created by [`crate::chat::create_group_chat`]. + /// Created by [`crate::chat::create_group`]. Group = 120, /// An (unencrypted) mailing list, @@ -307,16 +291,4 @@ mod tests { assert_eq!(MediaQuality::Balanced, MediaQuality::from_i32(0).unwrap()); assert_eq!(MediaQuality::Worse, MediaQuality::from_i32(1).unwrap()); } - - #[test] - fn test_videochattype_values() { - // values may be written to disk and must not change - assert_eq!(VideochatType::Unknown, VideochatType::default()); - assert_eq!(VideochatType::Unknown, VideochatType::from_i32(0).unwrap()); - assert_eq!( - VideochatType::BasicWebrtc, - VideochatType::from_i32(1).unwrap() - ); - assert_eq!(VideochatType::Jitsi, VideochatType::from_i32(2).unwrap()); - } } diff --git a/src/contact.rs b/src/contact.rs index cf215f1013..91b4969b5b 100644 --- a/src/contact.rs +++ b/src/contact.rs @@ -21,7 +21,7 @@ use tokio::task; use tokio::time::{Duration, timeout}; use crate::blob::BlobObject; -use crate::chat::{ChatId, ChatIdBlocked, ProtectionStatus}; +use crate::chat::ChatId; use crate::color::str_to_color; use crate::config::Config; use crate::constants::{self, Blocked, Chattype}; @@ -36,7 +36,7 @@ use crate::message::MessageState; use crate::mimeparser::AvatarAction; use crate::param::{Param, Params}; use crate::sync::{self, Sync::*}; -use crate::tools::{SystemTime, duration_to_str, get_abs_path, time}; +use crate::tools::{SystemTime, duration_to_str, get_abs_path, time, to_lowercase}; use crate::{chat, chatlist_events, ensure_and_debug_assert_ne, stock_str}; /// Time during which a contact is considered as seen recently. @@ -1574,12 +1574,10 @@ impl Contact { Ok(None) } - /// Get a color for the contact. - /// 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. + /// Returns a color for the contact. + /// See [`self::get_color`]. pub fn get_color(&self) -> u32 { - str_to_color(&self.addr.to_lowercase()) + get_color(self.id == ContactId::SELF, &self.addr, &self.fingerprint()) } /// Gets the contact's status. @@ -1645,29 +1643,6 @@ 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 { - 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 { if !context.sql.is_open().await { @@ -1698,6 +1673,21 @@ impl Contact { } } +/// Returns a color for a contact having given attributes. +/// +/// The color is calculated from contact's fingerprint (for key-contacts) or email address (for +/// address-contacts; should be lowercased to avoid allocation) 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(is_self: bool, addr: &str, fingerprint: &Option) -> u32 { + if let Some(fingerprint) = fingerprint { + str_to_color(&fingerprint.hex()) + } else if is_self { + 0x808080 + } else { + str_to_color(&to_lowercase(addr)) + } +} + // Updates the names of the chats which use the contact name. // // This is one of the few duplicated data, however, getting the chat list is easier this way. @@ -1760,8 +1750,7 @@ pub(crate) async fn set_blocked( ) -> Result<()> { ensure!( !contact_id.is_special(), - "Can't block special contact {}", - contact_id + "Can't block special contact {contact_id}" ); let contact = Contact::get_by_id(context, contact_id).await?; @@ -1801,9 +1790,7 @@ WHERE type=? AND id IN ( // also unblock mailinglist // if the contact is a mailinglist address explicitly created to allow unblocking if !new_blocking && contact.origin == Origin::MailinglistAddress { - if let Some((chat_id, _, _)) = - chat::get_chat_id_by_grpid(context, &contact.addr).await? - { + if let Some((chat_id, ..)) = chat::get_chat_id_by_grpid(context, &contact.addr).await? { chat_id.unblock_ex(context, Nosync).await?; } } @@ -1943,16 +1930,21 @@ 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: ContactId, + verifier_id: Option, ) -> Result<()> { + ensure_and_debug_assert_ne!(contact_id, ContactId::SELF,); ensure_and_debug_assert_ne!( - contact_id, + Some(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| { @@ -1965,20 +1957,33 @@ 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: String = transaction.query_row( - "SELECT fingerprint FROM contacts WHERE id=?", - (verifier_id,), - |row| row.get(0), - )?; + 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)?)), + )?; 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=? WHERE id=?", - (verifier_id, contact_id), + "UPDATE contacts SET verifier=?1 + WHERE id=?2 AND (verifier=0 OR verifier=id OR ?3)", + (verifier_id, contact_id, by_self), )?; Ok(()) }) diff --git a/src/contact/contact_tests.rs b/src/contact/contact_tests.rs index 14ccc4d93b..b2f78630c9 100644 --- a/src/contact/contact_tests.rs +++ b/src/contact/contact_tests.rs @@ -4,6 +4,7 @@ use super::*; use crate::chat::{Chat, get_chat_contacts, send_text_msg}; use crate::chatlist::Chatlist; use crate::receive_imf::receive_imf; +use crate::securejoin::get_securejoin_qr; use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote}; #[test] @@ -759,7 +760,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, 0xA739FF); + assert_eq!(color1, 0x4844e2); let t = TestContext::new().await; let contact_id = Contact::create(&t, "prename name", "name@example.net").await?; @@ -773,6 +774,20 @@ async fn test_contact_get_color() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_self_color_vs_key() -> Result<()> { + let mut tcm = TestContextManager::new(); + let t = &tcm.unconfigured().await; + t.configure_addr("alice@example.org").await; + assert!(t.is_configured().await?); + let color = Contact::get_by_id(t, ContactId::SELF).await?.get_color(); + assert_eq!(color, 0x808080); + get_securejoin_qr(t, None).await?; + let color1 = Contact::get_by_id(t, ContactId::SELF).await?.get_color(); + assert_ne!(color1, color); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_contact_get_encrinfo() -> Result<()> { let mut tcm = TestContextManager::new(); @@ -1302,13 +1317,9 @@ 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()); - let chat_id = ChatId::get_for_contact(&alice, ContactId::SELF).await?; - assert!(chat_id.is_protected(&alice).await.unwrap() == ProtectionStatus::Protected); - Ok(()) } diff --git a/src/context.rs b/src/context.rs index ca964deb16..8d160ae9f0 100644 --- a/src/context.rs +++ b/src/context.rs @@ -14,7 +14,7 @@ use pgp::types::PublicKeyTrait; use ratelimit::Ratelimit; use tokio::sync::{Mutex, Notify, RwLock}; -use crate::chat::{ChatId, ProtectionStatus, get_chat_cnt}; +use crate::chat::{ChatId, get_chat_cnt}; use crate::chatlist_events; use crate::config::Config; use crate::constants::{ @@ -30,11 +30,12 @@ use crate::log::{info, warn}; use crate::logged_debug_assert; use crate::login_param::{ConfiguredLoginParam, EnteredLoginParam}; use crate::message::{self, Message, MessageState, MsgId}; +use crate::net::tls::TlsSessionStore; use crate::param::{Param, Params}; use crate::peer_channels::Iroh; use crate::push::PushSubscriber; use crate::quota::QuotaInfo; -use crate::scheduler::{SchedulerState, convert_folder_meaning}; +use crate::scheduler::{ConnectivityStore, SchedulerState, convert_folder_meaning}; use crate::sql::Sql; use crate::stock_str::StockStrings; use crate::timesmearing::SmearedTimestamp; @@ -297,6 +298,9 @@ pub struct InnerContext { /// True if account has subscribed to push notifications via IMAP. pub(crate) push_subscribed: AtomicBool, + /// TLS session resumption cache. + pub(crate) tls_session_store: TlsSessionStore, + /// Iroh for realtime peer channels. pub(crate) iroh: Arc>>, @@ -304,6 +308,10 @@ 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, + + /// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity, + /// see [`Context::get_connectivity()`]. + pub(crate) connectivities: parking_lot::Mutex>, } /// The state of ongoing process. @@ -471,8 +479,10 @@ impl Context { debug_logging: std::sync::RwLock::new(None), push_subscriber, push_subscribed: AtomicBool::new(false), + tls_session_store: TlsSessionStore::new(), iroh: Arc::new(RwLock::new(None)), self_fingerprint: OnceLock::new(), + connectivities: parking_lot::Mutex::new(Vec::new()), }; let ctx = Context { @@ -502,7 +512,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.clone()).await; + self.scheduler.start(self).await; } /// Stops the IO scheduler. @@ -579,7 +589,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.clone()).await?; + let _pause_guard = self.scheduler.pause(self).await?; // Start a new dedicated connection. let mut connection = Imap::new_configured(self, channel::bounded(1).1).await?; @@ -828,7 +838,6 @@ 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?; @@ -962,19 +971,12 @@ impl Context { res.insert("configured_mvbox_folder", configured_mvbox_folder); res.insert("configured_trash_folder", configured_trash_folder); res.insert("mdns_enabled", mdns_enabled.to_string()); - res.insert("e2ee_enabled", e2ee_enabled.to_string()); res.insert("bcc_self", bcc_self.to_string()); res.insert("sync_msgs", sync_msgs.to_string()); res.insert("disable_idle", disable_idle.to_string()); res.insert("private_key_count", prv_key_cnt.to_string()); res.insert("public_key_count", pub_key_cnt.to_string()); res.insert("fingerprint", fingerprint_str); - res.insert( - "webrtc_instance", - self.get_config(Config::WebrtcInstance) - .await? - .unwrap_or_else(|| "".to_string()), - ); res.insert( "media_quality", self.get_config_int(Config::MediaQuality).await?.to_string(), @@ -1033,12 +1035,6 @@ impl Context { .await? .to_string(), ); - res.insert( - "protect_autocrypt", - self.get_config_int(Config::ProtectAutocrypt) - .await? - .to_string(), - ); res.insert( "debug_logging", self.get_config_int(Config::DebugLogging).await?.to_string(), @@ -1051,12 +1047,6 @@ impl Context { "gossip_period", self.get_config_int(Config::GossipPeriod).await?.to_string(), ); - res.insert( - "verified_one_on_one_chats", // deprecated 2025-07 - self.get_config_bool(Config::VerifiedOneOnOneChats) - .await? - .to_string(), - ); res.insert( "webxdc_realtime_enabled", self.get_config_bool(Config::WebxdcRealtimeEnabled) @@ -1076,6 +1066,13 @@ 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)); @@ -1086,7 +1083,6 @@ impl Context { async fn get_self_report(&self) -> Result { #[derive(Default)] struct ChatNumbers { - protected: u32, opportunistic_dc: u32, opportunistic_mua: u32, unencrypted_dc: u32, @@ -1121,7 +1117,6 @@ impl Context { res += &format!("key_created {key_created}\n"); // how many of the chats active in the last months are: - // - protected // - opportunistic-encrypted and the contact uses Delta Chat // - opportunistic-encrypted and the contact uses a classical MUA // - unencrypted and the contact uses Delta Chat @@ -1130,7 +1125,7 @@ impl Context { let chats = self .sql .query_map( - "SELECT c.protected, m.param, m.msgrmsg + "SELECT m.param, m.msgrmsg FROM chats c JOIN msgs m ON c.id=m.chat_id @@ -1148,23 +1143,20 @@ impl Context { GROUP BY c.id", (DownloadState::Done, ContactId::INFO, three_months_ago), |row| { - let protected: ProtectionStatus = row.get(0)?; let message_param: Params = row.get::<_, String>(1)?.parse().unwrap_or_default(); let is_dc_message: bool = row.get(2)?; - Ok((protected, message_param, is_dc_message)) + Ok((message_param, is_dc_message)) }, |rows| { let mut chats = ChatNumbers::default(); for row in rows { - let (protected, message_param, is_dc_message) = row?; + let (message_param, is_dc_message) = row?; let encrypted = message_param .get_bool(Param::GuaranteeE2ee) .unwrap_or(false); - if protected == ProtectionStatus::Protected { - chats.protected += 1; - } else if encrypted { + if encrypted { if is_dc_message { chats.opportunistic_dc += 1; } else { @@ -1180,7 +1172,6 @@ impl Context { }, ) .await?; - res += &format!("chats_protected {}\n", chats.protected); 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); @@ -1210,12 +1201,9 @@ impl Context { .await? .first() .context("Self reporting bot vCard does not contain a contact")?; - mark_contact_id_as_verified(self, contact_id, ContactId::SELF).await?; + mark_contact_id_as_verified(self, contact_id, Some(ContactId::SELF)).await?; let chat_id = ChatId::create_for_contact(self, contact_id).await?; - chat_id - .set_protection(self, ProtectionStatus::Protected, time(), Some(contact_id)) - .await?; let mut msg = Message::new_text(self.get_self_report().await?); diff --git a/src/context/context_tests.rs b/src/context/context_tests.rs index e80c17448f..03dcaf3bbe 100644 --- a/src/context/context_tests.rs +++ b/src/context/context_tests.rs @@ -604,15 +604,12 @@ async fn test_draft_self_report() -> Result<()> { let chat_id = alice.draft_self_report().await?; let msg = get_chat_msg(&alice, chat_id, 0, 1).await; - assert_eq!(msg.get_info_type(), SystemMessage::ChatProtectionEnabled); - - let chat = Chat::load_from_db(&alice, chat_id).await?; - assert!(chat.is_protected()); + assert_eq!(msg.get_info_type(), SystemMessage::ChatE2ee); let mut draft = chat_id.get_draft(&alice).await?.unwrap(); assert!(draft.text.starts_with("core_version")); - // Test that sending into the protected chat works: + // Test that sending into the chat works: let _sent = alice.send_msg(chat_id, &mut draft).await; Ok(()) diff --git a/src/dehtml.rs b/src/dehtml.rs index 007708195a..a6d70b1f7d 100644 --- a/src/dehtml.rs +++ b/src/dehtml.rs @@ -7,6 +7,7 @@ use std::sync::LazyLock; use quick_xml::{ Reader, + errors::Error as QuickXmlError, events::{BytesEnd, BytesStart, BytesText}, }; @@ -132,6 +133,7 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) { reader.config_mut().check_end_names = false; let mut buf = Vec::new(); + let mut char_buf = String::with_capacity(4); loop { match reader.read_event_into(&mut buf) { @@ -140,16 +142,9 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) { } Ok(quick_xml::events::Event::End(ref e)) => dehtml_endtag_cb(e, &mut dehtml), Ok(quick_xml::events::Event::Text(ref e)) => dehtml_text_cb(e, &mut dehtml), - Ok(quick_xml::events::Event::CData(e)) => match e.escape() { - Ok(e) => dehtml_text_cb(&e, &mut dehtml), - Err(e) => { - eprintln!( - "CDATA escape error at position {}: {:?}", - reader.buffer_position(), - e, - ); - } - }, + Ok(quick_xml::events::Event::CData(e)) => { + str_cb(&String::from_utf8_lossy(&e as &[_]), &mut dehtml) + } Ok(quick_xml::events::Event::Empty(ref e)) => { // Handle empty tags as a start tag immediately followed by end tag. // For example, `

` is treated as `

`. @@ -159,6 +154,33 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) { &mut dehtml, ); } + Ok(quick_xml::events::Event::GeneralRef(ref e)) => { + match e.resolve_char_ref() { + Err(err) => eprintln!( + "resolve_char_ref() error at position {}: {:?}", + reader.buffer_position(), + err, + ), + Ok(Some(ch)) => { + char_buf.clear(); + char_buf.push(ch); + str_cb(&char_buf, &mut dehtml); + } + Ok(None) => { + let event_str = String::from_utf8_lossy(e); + if let Some(s) = quick_xml::escape::resolve_html5_entity(&event_str) { + str_cb(s, &mut dehtml); + } else { + // Nonstandard entity. Add escaped. + str_cb(&format!("&{event_str};"), &mut dehtml); + } + } + } + } + Err(QuickXmlError::IllFormed(_)) => { + // This is probably not HTML at all and should be left as is. + str_cb(&String::from_utf8_lossy(&buf), &mut dehtml); + } Err(e) => { eprintln!( "Parse html error: Error at position {}: {:?}", @@ -176,36 +198,36 @@ fn dehtml_quick_xml(buf: &str) -> (String, String) { } fn dehtml_text_cb(event: &BytesText, dehtml: &mut Dehtml) { - static LINE_RE: LazyLock = - LazyLock::new(|| regex::Regex::new(r"(\r?\n)+").unwrap()); - if dehtml.get_add_text() == AddText::YesPreserveLineEnds || dehtml.get_add_text() == AddText::YesRemoveLineEnds { let event = event as &[_]; let event_str = std::str::from_utf8(event).unwrap_or_default(); - let mut last_added = escaper::decode_html_buf_sloppy(event).unwrap_or_default(); - if event_str.starts_with(&last_added) { - last_added = event_str.to_string(); - } + str_cb(event_str, dehtml); + } +} - if dehtml.get_add_text() == AddText::YesRemoveLineEnds { - // Replace all line ends with spaces. - // E.g. `\r\n\r\n` is replaced with one space. - let last_added = LINE_RE.replace_all(&last_added, " "); - - // Add a space if `last_added` starts with a space - // and there is no whitespace at the end of the buffer yet. - // Trim the rest of leading whitespace from `last_added`. - let buf = dehtml.get_buf(); - if !buf.ends_with(' ') && !buf.ends_with('\n') && last_added.starts_with(' ') { - *buf += " "; - } +fn str_cb(event_str: &str, dehtml: &mut Dehtml) { + static LINE_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"(\r?\n)+").unwrap()); - *buf += last_added.trim_start(); - } else { - *dehtml.get_buf() += LINE_RE.replace_all(&last_added, "\n").as_ref(); + let add_text = dehtml.get_add_text(); + if add_text == AddText::YesRemoveLineEnds { + // Replace all line ends with spaces. + // E.g. `\r\n\r\n` is replaced with one space. + let event_str = LINE_RE.replace_all(event_str, " "); + + // Add a space if `event_str` starts with a space + // and there is no whitespace at the end of the buffer yet. + // Trim the rest of leading whitespace from `event_str`. + let buf = dehtml.get_buf(); + if !buf.ends_with(' ') && !buf.ends_with('\n') && event_str.starts_with(' ') { + *buf += " "; } + + *buf += event_str.trim_start(); + } else if add_text == AddText::YesPreserveLineEnds { + *dehtml.get_buf() += LINE_RE.replace_all(event_str, "\n").as_ref(); } } diff --git a/src/download.rs b/src/download.rs index 57086a43dc..d4ad5ec1db 100644 --- a/src/download.rs +++ b/src/download.rs @@ -238,14 +238,20 @@ 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, ) -> 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? { @@ -259,9 +265,10 @@ impl MimeMessage { info!(context, "Partial download: {}", text); - self.parts.push(Part { + self.do_add_single_part(Part { typ: Viewtype::Text, msg: text, + error, ..Default::default() }); @@ -276,8 +283,9 @@ mod tests { use super::*; use crate::chat::{get_chat_msgs, send_msg}; use crate::ephemeral::Timer; + use crate::message::delete_msgs; use crate::receive_imf::receive_imf_from_inbox; - use crate::test_utils::{E2EE_INFO_MSGS, TestContext}; + use crate::test_utils::{E2EE_INFO_MSGS, TestContext, TestContextManager}; #[test] fn test_downloadstate_values() { @@ -536,4 +544,43 @@ mod tests { Ok(()) } + + /// Tests that fully downloading the message + /// works even if the Message-ID already exists + /// in the database assigned to the trash chat. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_partial_download_trashed() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + + let imf_raw = b"From: Bob \n\ + To: Alice \n\ + Chat-Version: 1.0\n\ + Subject: subject\n\ + Message-ID: \n\ + Date: Sun, 14 Nov 2021 00:10:00 +0000\ + Content-Type: text/plain"; + + // Download message from Bob partially. + let partial_received_msg = + receive_imf_from_inbox(alice, "first@example.org", imf_raw, false, Some(100000)) + .await? + .unwrap(); + assert_eq!(partial_received_msg.msg_ids.len(), 1); + + // Delete the received message. + // Not it is still in the database, + // but in the trash chat. + delete_msgs(alice, &[partial_received_msg.msg_ids[0]]).await?; + + // Fully download message after deletion. + let full_received_msg = + receive_imf_from_inbox(alice, "first@example.org", imf_raw, false, None).await?; + + // The message does not reappear. + // However, `receive_imf` should not fail. + assert!(full_received_msg.is_none()); + + Ok(()) + } } diff --git a/src/e2ee.rs b/src/e2ee.rs index 9968c22457..1eabbb4e1c 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -4,10 +4,8 @@ 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; @@ -21,9 +19,7 @@ pub struct EncryptHelper { impl EncryptHelper { pub async fn new(context: &Context) -> Result { - let prefer_encrypt = - EncryptPreference::from_i32(context.get_config_int(Config::E2eeEnabled).await?) - .unwrap_or_default(); + let prefer_encrypt = EncryptPreference::Mutual; let addr = context.get_primary_self_addr().await?; let public_key = load_self_public_key(context).await?; @@ -35,9 +31,12 @@ impl EncryptHelper { } pub fn get_aheader(&self) -> Aheader { - let pk = self.public_key.clone(); - let addr = self.addr.to_string(); - Aheader::new(addr, pk, self.prefer_encrypt) + Aheader { + addr: self.addr.clone(), + public_key: self.public_key.clone(), + prefer_encrypt: self.prefer_encrypt, + verified: false, + } } /// Tries to encrypt the passed in `mail`. diff --git a/src/ephemeral.rs b/src/ephemeral.rs index b31445f326..84a5805174 100644 --- a/src/ephemeral.rs +++ b/src/ephemeral.rs @@ -376,6 +376,10 @@ pub(crate) async fn start_chat_ephemeral_timers(context: &Context, chat_id: Chat /// `delete_device_after` setting or `ephemeral_timestamp` column. /// /// For each message a row ID, chat id, viewtype and location ID is returned. +/// +/// Unknown viewtypes are returned as `Viewtype::Unknown` +/// and not as errors bubbled up, easily resulting in infinite loop or leaving messages undeleted. +/// (Happens when viewtypes are removed or added on another device which was backup/add-second-device source) async fn select_expired_messages( context: &Context, now: i64, @@ -395,7 +399,11 @@ WHERE |row| { let id: MsgId = row.get("id")?; let chat_id: ChatId = row.get("chat_id")?; - let viewtype: Viewtype = row.get("type")?; + let viewtype: Viewtype = row + .get("type") + .context("Using default viewtype for ephemeral handling.") + .log_err(context) + .unwrap_or_default(); let location_id: u32 = row.get("location_id")?; Ok((id, chat_id, viewtype, location_id)) }, @@ -437,7 +445,11 @@ WHERE |row| { let id: MsgId = row.get("id")?; let chat_id: ChatId = row.get("chat_id")?; - let viewtype: Viewtype = row.get("type")?; + let viewtype: Viewtype = row + .get("type") + .context("Using default viewtype for delete-old handling.") + .log_err(context) + .unwrap_or_default(); let location_id: u32 = row.get("location_id")?; Ok((id, chat_id, viewtype, location_id)) }, diff --git a/src/ephemeral/ephemeral_tests.rs b/src/ephemeral/ephemeral_tests.rs index 56d18de454..988f19ba6e 100644 --- a/src/ephemeral/ephemeral_tests.rs +++ b/src/ephemeral/ephemeral_tests.rs @@ -12,7 +12,7 @@ use crate::receive_imf::receive_imf; use crate::test_utils::{TestContext, TestContextManager}; use crate::timesmearing::MAX_SECONDS_TO_LEND_FROM_FUTURE; use crate::{ - chat::{self, Chat, ChatItem, ProtectionStatus, create_group_chat, send_text_msg}, + chat::{self, Chat, ChatItem, create_group, send_text_msg}, tools::IsNoneOrEmpty, }; @@ -164,7 +164,7 @@ async fn test_ephemeral_enable_disable() -> Result<()> { async fn test_ephemeral_unpromoted() -> Result<()> { let alice = TestContext::new_alice().await; - let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group name").await?; + let chat_id = create_group(&alice, "Group name").await?; // Group is unpromoted, the timer can be changed without sending a message. assert!(chat_id.is_unpromoted(&alice).await?); @@ -799,8 +799,7 @@ async fn test_ephemeral_timer_non_member() -> Result<()> { let bob = &tcm.bob().await; let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await; - let alice_chat_id = - create_group_chat(alice, ProtectionStatus::Unprotected, "Group name").await?; + let alice_chat_id = create_group(alice, "Group name").await?; add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?; send_text_msg(alice, alice_chat_id, "Hi!".to_string()).await?; @@ -826,3 +825,68 @@ async fn test_ephemeral_timer_non_member() -> Result<()> { Ok(()) } + +/// Tests that expiration of a disappearing message +/// with unknown viewtype does not make `delete_expired_messages` fail. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_disappearing_unknown_viewtype() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let chat = alice.create_chat(bob).await; + + let duration = 60; + chat.id + .set_ephemeral_timer(alice, Timer::Enabled { duration }) + .await?; + + let mut msg = Message::new_text("Expiring message".to_string()); + let _alice_sent_message = alice.send_msg(chat.id, &mut msg).await; + + // Set message viewtype to unassigned + // type 70 that was previously used for videochat invitations. + alice + .sql + .execute("UPDATE msgs SET type=70 WHERE id=?", (msg.id,)) + .await?; + + SystemTime::shift(Duration::from_secs(100)); + + // This should not fail. + delete_expired_messages(alice, time()).await?; + + Ok(()) +} + +/// Tests that deletion of a message with unknown viewtype +/// triggered by `delete_device_after` +/// does not make `delete_expired_messages` fail. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_delete_device_after_unknown_viewtype() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let chat = alice.create_chat(bob).await; + alice + .set_config(Config::DeleteDeviceAfter, Some("600")) + .await?; + + let mut msg = Message::new_text("Some message".to_string()); + let _alice_sent_message = alice.send_msg(chat.id, &mut msg).await; + + // Set message viewtype to unassigned + // type 70 that was previously used for videochat invitations. + alice + .sql + .execute("UPDATE msgs SET type=70 WHERE id=?", (msg.id,)) + .await?; + + SystemTime::shift(Duration::from_secs(1000)); + + // This should not fail. + delete_expired_messages(alice, time()).await?; + + Ok(()) +} diff --git a/src/events/chatlist_events.rs b/src/events/chatlist_events.rs index 87af729429..93ea7b6803 100644 --- a/src/events/chatlist_events.rs +++ b/src/events/chatlist_events.rs @@ -66,8 +66,7 @@ mod test_chatlist_events { use crate::{ EventType, chat::{ - self, ChatId, ChatVisibility, MuteDuration, ProtectionStatus, create_broadcast, - create_group_chat, set_muted, + self, ChatId, ChatVisibility, MuteDuration, create_broadcast, create_group, set_muted, }, config::Config, constants::*, @@ -138,12 +137,7 @@ mod test_chatlist_events { async fn test_change_chat_visibility() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; - let chat_id = create_group_chat( - &alice, - crate::chat::ProtectionStatus::Unprotected, - "my_group", - ) - .await?; + let chat_id = create_group(&alice, "my_group").await?; chat_id .set_visibility(&alice, ChatVisibility::Pinned) @@ -289,7 +283,7 @@ mod test_chatlist_events { async fn test_delete_chat() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; - let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + let chat = create_group(&alice, "My Group").await?; alice.evtracker.clear_events(); chat.delete(&alice).await?; @@ -299,11 +293,11 @@ mod test_chatlist_events { /// Create group chat #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_create_group_chat() -> Result<()> { + async fn test_create_group() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; alice.evtracker.clear_events(); - let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + let chat = create_group(&alice, "My Group").await?; wait_for_chatlist_and_specific_item(&alice, chat).await; Ok(()) } @@ -324,7 +318,7 @@ mod test_chatlist_events { async fn test_mute_chat() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; - let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + let chat = create_group(&alice, "My Group").await?; alice.evtracker.clear_events(); chat::set_muted(&alice, chat, MuteDuration::Forever).await?; @@ -343,7 +337,7 @@ mod test_chatlist_events { async fn test_mute_chat_expired() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; - let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + let chat = create_group(&alice, "My Group").await?; let mute_duration = MuteDuration::Until( std::time::SystemTime::now() @@ -363,7 +357,7 @@ mod test_chatlist_events { async fn test_change_chat_name() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; - let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + let chat = create_group(&alice, "My Group").await?; alice.evtracker.clear_events(); chat::set_chat_name(&alice, chat, "New Name").await?; @@ -377,7 +371,7 @@ mod test_chatlist_events { async fn test_change_chat_profile_image() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; - let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + let chat = create_group(&alice, "My Group").await?; alice.evtracker.clear_events(); let file = alice.dir.path().join("avatar.png"); @@ -395,9 +389,7 @@ mod test_chatlist_events { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; - let chat = alice - .create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob]) - .await; + let chat = alice.create_group_with_members("My Group", &[&bob]).await; let sent_msg = alice.send_text(chat, "Hello").await; let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id; @@ -419,9 +411,7 @@ mod test_chatlist_events { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; - let chat = alice - .create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob]) - .await; + let chat = alice.create_group_with_members("My Group", &[&bob]).await; let sent_msg = alice.send_text(chat, "Hello").await; let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id; @@ -438,9 +428,7 @@ mod test_chatlist_events { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; - let chat = alice - .create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob]) - .await; + let chat = alice.create_group_with_members("My Group", &[&bob]).await; let sent_msg = alice.send_text(chat, "Hello").await; let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id; @@ -456,7 +444,7 @@ mod test_chatlist_events { async fn test_delete_message() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; - let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + let chat = create_group(&alice, "My Group").await?; let message = chat::send_text_msg(&alice, chat, "Hello World".to_owned()).await?; alice.evtracker.clear_events(); @@ -473,9 +461,7 @@ mod test_chatlist_events { let alice = tcm.alice().await; let bob = tcm.bob().await; - let chat = alice - .create_group_with_members(ProtectionStatus::Unprotected, "My Group", &[&bob]) - .await; + let chat = alice.create_group_with_members("My Group", &[&bob]).await; let sent_msg = alice.send_text(chat, "Hello").await; let chat_id_for_bob = bob.recv_msg(&sent_msg).await.chat_id; chat_id_for_bob.accept(&bob).await?; @@ -516,7 +502,7 @@ mod test_chatlist_events { async fn test_update_after_ephemeral_messages() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; - let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + let chat = create_group(&alice, "My Group").await?; chat.set_ephemeral_timer(&alice, crate::ephemeral::Timer::Enabled { duration: 60 }) .await?; alice @@ -560,8 +546,7 @@ First thread."#; let alice = tcm.alice().await; let bob = tcm.bob().await; - let alice_chatid = - chat::create_group_chat(&alice.ctx, ProtectionStatus::Protected, "the chat").await?; + let alice_chatid = chat::create_group(&alice.ctx, "the chat").await?; // Step 1: Generate QR-code, secure-join implied by chatid let qr = get_securejoin_qr(&alice.ctx, Some(alice_chatid)).await?; @@ -608,7 +593,7 @@ First thread."#; async fn test_resend_message() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; - let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + let chat = create_group(&alice, "My Group").await?; let msg_id = chat::send_text_msg(&alice, chat, "Hello".to_owned()).await?; let _ = alice.pop_sent_msg().await; @@ -628,7 +613,7 @@ First thread."#; async fn test_reaction() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; - let chat = create_group_chat(&alice, ProtectionStatus::Protected, "My Group").await?; + let chat = create_group(&alice, "My Group").await?; let msg_id = chat::send_text_msg(&alice, chat, "Hello".to_owned()).await?; let _ = alice.pop_sent_msg().await; diff --git a/src/events/payload.rs b/src/events/payload.rs index a5e2f99653..bf7e1fa35e 100644 --- a/src/events/payload.rs +++ b/src/events/payload.rs @@ -5,6 +5,7 @@ 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; @@ -272,11 +273,13 @@ pub enum EventType { /// ID of the contact that wants to join. contact_id: ContactId, - /// 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. + /// ID of the chat in case of success. + chat_id: ChatId, + + /// The type of the joined chat. + chat_type: Chattype, + + /// Progress, always 1000. progress: usize, }, @@ -376,6 +379,44 @@ 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, diff --git a/src/headerdef.rs b/src/headerdef.rs index 330a4d9ba0..8102bcb813 100644 --- a/src/headerdef.rs +++ b/src/headerdef.rs @@ -86,6 +86,7 @@ pub enum HeaderDef { ChatDispositionNotificationTo, ChatWebrtcRoom, + ChatWebrtcAccepted, /// This message deletes the messages listed in the value by rfc724_mid. ChatDelete, @@ -118,6 +119,11 @@ pub enum HeaderDef { AuthenticationResults, /// Node address from iroh where direct addresses have been removed. + /// + /// The node address sent in this header must have + /// a non-null relay URL as contacting home relay + /// is the only way to reach the node without + /// direct addresses and global discovery. IrohNodeAddr, /// Advertised gossip topic for one webxdc. diff --git a/src/imap.rs b/src/imap.rs index 81f62721e4..5481d75b7c 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -24,6 +24,7 @@ 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; @@ -47,7 +48,7 @@ use crate::receive_imf::{ }; use crate::scheduler::connectivity::ConnectivityStore; use crate::stock_str; -use crate::tools::{self, create_id, duration_to_str}; +use crate::tools::{self, create_id, duration_to_str, time}; pub(crate) mod capabilities; mod client; @@ -123,6 +124,18 @@ pub(crate) struct ServerMetadata { pub admin: Option, pub iroh_relay: Option, + + /// 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 { @@ -146,7 +159,6 @@ pub enum FolderMeaning { Mvbox, Sent, Trash, - Drafts, /// Virtual folders. /// @@ -166,7 +178,6 @@ impl FolderMeaning { FolderMeaning::Mvbox => Some(Config::ConfiguredMvboxFolder), FolderMeaning::Sent => Some(Config::ConfiguredSentboxFolder), FolderMeaning::Trash => Some(Config::ConfiguredTrashFolder), - FolderMeaning::Drafts => None, FolderMeaning::Virtual => None, } } @@ -325,7 +336,7 @@ impl Imap { } info!(context, "Connecting to IMAP server."); - self.connectivity.set_connecting(context).await; + self.connectivity.set_connecting(context); self.conn_last_try = tools::Time::now(); const BACKOFF_MIN_MS: u64 = 2000; @@ -408,7 +419,7 @@ impl Imap { "IMAP-LOGIN as {}", lp.user ))); - self.connectivity.set_preparing(context).await; + self.connectivity.set_preparing(context); info!(context, "Successfully logged into IMAP server."); return Ok(session); } @@ -466,7 +477,7 @@ impl Imap { let mut session = match self.connect(context, configuring).await { Ok(session) => session, Err(err) => { - self.connectivity.set_err(context, &err).await; + self.connectivity.set_err(context, &err); return Err(err); } }; @@ -555,10 +566,38 @@ impl Imap { } session.new_mail = false; + let mut read_cnt = 0; + loop { + let (n, fetch_more) = self + .fetch_new_msg_batch(context, session, folder, folder_meaning) + .await?; + read_cnt += n; + if !fetch_more { + return Ok(read_cnt > 0); + } + } + } + + /// Returns number of messages processed and whether the function should be called again. + async fn fetch_new_msg_batch( + &mut self, + context: &Context, + session: &mut Session, + folder: &str, + folder_meaning: FolderMeaning, + ) -> Result<(usize, bool)> { let uid_validity = get_uidvalidity(context, folder).await?; let old_uid_next = get_uid_next(context, folder).await?; + info!( + context, + "fetch_new_msg_batch({folder}): UIDVALIDITY={uid_validity}, UIDNEXT={old_uid_next}." + ); - let msgs = session.prefetch(old_uid_next).await.context("prefetch")?; + let uids_to_prefetch = 500; + let msgs = session + .prefetch(old_uid_next, uids_to_prefetch) + .await + .context("prefetch")?; let read_cnt = msgs.len(); let download_limit = context.download_limit().await?; @@ -692,7 +731,7 @@ impl Imap { } if !uids_fetch.is_empty() { - self.connectivity.set_working(context).await; + self.connectivity.set_working(context); } let (sender, receiver) = async_channel::unbounded(); @@ -718,7 +757,8 @@ impl Imap { largest_uid_fetched }; - let actually_download_messages_future = async move { + let actually_download_messages_future = async { + let sender = sender; let mut uids_fetch_in_batch = Vec::with_capacity(max(uids_fetch.len(), 1)); let mut fetch_partially = false; uids_fetch.push((0, !uids_fetch.last().unwrap_or(&(0, false)).1)); @@ -753,14 +793,17 @@ impl Imap { // if the message has arrived after selecting mailbox // and determining its UIDNEXT and before prefetch. let mut new_uid_next = largest_uid_fetched + 1; - if fetch_res.is_ok() { + let fetch_more = fetch_res.is_ok() && { + let prefetch_uid_next = old_uid_next + uids_to_prefetch; // If we have successfully fetched all messages we planned during prefetch, // then we have covered at least the range between old UIDNEXT // and UIDNEXT of the mailbox at the time of selecting it. - new_uid_next = max(new_uid_next, mailbox_uid_next); + new_uid_next = max(new_uid_next, min(prefetch_uid_next, mailbox_uid_next)); new_uid_next = max(new_uid_next, largest_uid_skipped.unwrap_or(0) + 1); - } + + prefetch_uid_next < mailbox_uid_next + }; if new_uid_next > old_uid_next { set_uid_next(context, folder, new_uid_next).await?; } @@ -777,7 +820,7 @@ impl Imap { // establish a new session if this one is broken. fetch_res?; - Ok(read_cnt > 0) + Ok((read_cnt, fetch_more)) } /// Read the recipients from old emails sent by the user and add them as contacts. @@ -814,7 +857,10 @@ impl Session { .context("listing folders for resync")?; for folder in all_folders { let folder_meaning = get_folder_meaning(&folder); - if folder_meaning != FolderMeaning::Virtual { + if !matches!( + folder_meaning, + FolderMeaning::Virtual | FolderMeaning::Unknown + ) { self.resync_folder_uids(context, folder.name(), folder_meaning) .await?; } @@ -1466,7 +1512,7 @@ impl Session { context, "Passing message UID {} to receive_imf().", request_uid ); - match receive_imf_inner( + let res = receive_imf_inner( context, folder, uidvalidity, @@ -1474,20 +1520,31 @@ impl Session { rfc724_mid, body, is_seen, - partial, + partial.map(|msg_size| (msg_size, None)), ) - .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?; + .await; + let received_msg = if let Err(err) = res { + warn!(context, "receive_imf error: {:#}.", err); + if partial.is_some() { + return Err(err); } + 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 @@ -1534,7 +1591,43 @@ impl Session { } let mut lock = context.metadata.write().await; - if (*lock).is_some() { + 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?; + } return Ok(()); } @@ -1546,6 +1639,8 @@ 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 = ""; @@ -1553,7 +1648,7 @@ impl Session { .get_metadata( mailbox, options, - "(/shared/comment /shared/admin /shared/vendor/deltachat/irohrelay)", + "(/shared/comment /shared/admin /shared/vendor/deltachat/irohrelay /shared/vendor/deltachat/turn)", ) .await?; for m in metadata { @@ -1576,13 +1671,36 @@ 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(()) } @@ -2120,27 +2238,6 @@ 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", @@ -2167,8 +2264,6 @@ 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 { @@ -2182,7 +2277,6 @@ 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() { diff --git a/src/imap/client.rs b/src/imap/client.rs index 84d123da9b..bd5b13659d 100644 --- a/src/imap/client.rs +++ b/src/imap/client.rs @@ -37,12 +37,12 @@ impl DerefMut for Client { } /// Converts port number to ALPN list. -fn alpn(port: u16) -> &'static [&'static str] { +fn alpn(port: u16) -> &'static str { if port == 993 { // Do not request ALPN on standard port. - &[] + "" } else { - &["imap"] + "imap" } } @@ -210,7 +210,15 @@ impl Client { let account_id = context.get_id(); let events = context.events.clone(); let logging_stream = LoggingStream::new(tcp_stream, account_id, events)?; - let tls_stream = wrap_tls(strict_tls, hostname, alpn(addr.port()), logging_stream).await?; + let tls_stream = wrap_tls( + strict_tls, + hostname, + addr.port(), + alpn(addr.port()), + logging_stream, + &context.tls_session_store, + ) + .await?; let buffered_stream = BufWriter::new(tls_stream); let session_stream: Box = Box::new(buffered_stream); let mut client = Client::new(session_stream); @@ -262,9 +270,16 @@ impl Client { let buffered_tcp_stream = client.into_inner(); let tcp_stream = buffered_tcp_stream.into_inner(); - let tls_stream = wrap_tls(strict_tls, host, &[], tcp_stream) - .await - .context("STARTTLS upgrade failed")?; + let tls_stream = wrap_tls( + strict_tls, + host, + addr.port(), + "", + tcp_stream, + &context.tls_session_store, + ) + .await + .context("STARTTLS upgrade failed")?; let buffered_stream = BufWriter::new(tls_stream); let session_stream: Box = Box::new(buffered_stream); let client = Client::new(session_stream); @@ -281,7 +296,15 @@ impl Client { let proxy_stream = proxy_config .connect(context, domain, port, strict_tls) .await?; - let tls_stream = wrap_tls(strict_tls, domain, alpn(port), proxy_stream).await?; + let tls_stream = wrap_tls( + strict_tls, + domain, + port, + alpn(port), + proxy_stream, + &context.tls_session_store, + ) + .await?; let buffered_stream = BufWriter::new(tls_stream); let session_stream: Box = Box::new(buffered_stream); let mut client = Client::new(session_stream); @@ -334,9 +357,16 @@ impl Client { let buffered_proxy_stream = client.into_inner(); let proxy_stream = buffered_proxy_stream.into_inner(); - let tls_stream = wrap_tls(strict_tls, hostname, &[], proxy_stream) - .await - .context("STARTTLS upgrade failed")?; + let tls_stream = wrap_tls( + strict_tls, + hostname, + port, + "", + proxy_stream, + &context.tls_session_store, + ) + .await + .context("STARTTLS upgrade failed")?; let buffered_stream = BufWriter::new(tls_stream); let session_stream: Box = Box::new(buffered_stream); let client = Client::new(session_stream); diff --git a/src/imap/scan_folders.rs b/src/imap/scan_folders.rs index 860c27568d..9b22c613af 100644 --- a/src/imap/scan_folders.rs +++ b/src/imap/scan_folders.rs @@ -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 diff --git a/src/imap/session.rs b/src/imap/session.rs index eb73b4a35b..8588fbab8c 100644 --- a/src/imap/session.rs +++ b/src/imap/session.rs @@ -110,14 +110,16 @@ impl Session { Ok(list) } - /// Prefetch all messages greater than or equal to `uid_next`. Returns a list of fetch results - /// in the order of ascending delivery time to the server (INTERNALDATE). + /// Prefetch `n_uids` messages starting from `uid_next`. Returns a list of fetch results in the + /// order of ascending delivery time to the server (INTERNALDATE). pub(crate) async fn prefetch( &mut self, uid_next: u32, + n_uids: u32, ) -> Result> { + let uid_last = uid_next.saturating_add(n_uids - 1); // fetch messages with larger UID than the last one seen - let set = format!("{uid_next}:*"); + let set = format!("{uid_next}:{uid_last}"); let mut list = self .uid_fetch(set, PREFETCH_FLAGS) .await @@ -126,16 +128,7 @@ impl Session { let mut msgs = BTreeMap::new(); while let Some(msg) = list.try_next().await? { if let Some(msg_uid) = msg.uid { - // If the mailbox is not empty, results always include - // at least one UID, even if last_seen_uid+1 is past - // the last UID in the mailbox. It happens because - // uid:* is interpreted the same way as *:uid. - // See for - // standard reference. Therefore, sometimes we receive - // already seen messages and have to filter them out. - if msg_uid >= uid_next { - msgs.insert((msg.internal_date(), msg_uid), msg); - } + msgs.insert((msg.internal_date(), msg_uid), msg); } } diff --git a/src/imex.rs b/src/imex.rs index 179db071ef..80425cd419 100644 --- a/src/imex.rs +++ b/src/imex.rs @@ -90,7 +90,7 @@ pub async fn imex( let cancel = context.alloc_ongoing().await?; let res = { - let _guard = context.scheduler.pause(context.clone()).await?; + let _guard = context.scheduler.pause(context).await?; imex_inner(context, what, path, passphrase) .race(async { cancel.recv().await.ok(); @@ -140,32 +140,8 @@ pub async fn has_backup(_context: &Context, dir_name: &Path) -> Result { } async fn set_self_key(context: &Context, armored: &str) -> Result<()> { - // try hard to only modify key-state - let (private_key, header) = SignedSecretKey::from_asc(armored)?; + let private_key = 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, @@ -952,75 +928,56 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_export_and_import_backup() -> Result<()> { - for set_verified_oneonone_chats in [true, false] { - let backup_dir = tempfile::tempdir().unwrap(); - - let context1 = TestContext::new_alice().await; - assert!(context1.is_configured().await?); - if set_verified_oneonone_chats { - context1 - .set_config_bool(Config::VerifiedOneOnOneChats, true) - .await?; - } + let backup_dir = tempfile::tempdir().unwrap(); - let context2 = TestContext::new().await; - assert!(!context2.is_configured().await?); - assert!(has_backup(&context2, backup_dir.path()).await.is_err()); + let context1 = TestContext::new_alice().await; + assert!(context1.is_configured().await?); - // export from context1 - assert!( - imex(&context1, ImexMode::ExportBackup, backup_dir.path(), None) - .await - .is_ok() - ); - let _event = context1 - .evtracker - .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000))) - .await; - - // import to context2 - let backup = has_backup(&context2, backup_dir.path()).await?; - - // Import of unencrypted backup with incorrect "foobar" backup passphrase fails. - assert!( - imex( - &context2, - ImexMode::ImportBackup, - backup.as_ref(), - Some("foobar".to_string()) - ) + let context2 = TestContext::new().await; + assert!(!context2.is_configured().await?); + assert!(has_backup(&context2, backup_dir.path()).await.is_err()); + + // export from context1 + assert!( + imex(&context1, ImexMode::ExportBackup, backup_dir.path(), None) .await - .is_err() - ); + .is_ok() + ); + let _event = context1 + .evtracker + .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000))) + .await; - assert!( - imex(&context2, ImexMode::ImportBackup, backup.as_ref(), None) - .await - .is_ok() - ); - let _event = context2 - .evtracker - .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000))) - .await; + // import to context2 + let backup = has_backup(&context2, backup_dir.path()).await?; - assert!(context2.is_configured().await?); - assert_eq!( - context2.get_config(Config::Addr).await?, - Some("alice@example.org".to_string()) - ); - assert_eq!( - context2 - .get_config_bool(Config::VerifiedOneOnOneChats) - .await?, - false - ); - assert_eq!( - context1 - .get_config_bool(Config::VerifiedOneOnOneChats) - .await?, - set_verified_oneonone_chats - ); - } + // Import of unencrypted backup with incorrect "foobar" backup passphrase fails. + assert!( + imex( + &context2, + ImexMode::ImportBackup, + backup.as_ref(), + Some("foobar".to_string()) + ) + .await + .is_err() + ); + + assert!( + imex(&context2, ImexMode::ImportBackup, backup.as_ref(), None) + .await + .is_ok() + ); + let _event = context2 + .evtracker + .get_matching(|evt| matches!(evt, EventType::ImexProgress(1000))) + .await; + + assert!(context2.is_configured().await?); + assert_eq!( + context2.get_config(Config::Addr).await?, + Some("alice@example.org".to_string()) + ); Ok(()) } diff --git a/src/imex/key_transfer.rs b/src/imex/key_transfer.rs index 2df4643919..6d1f9f418e 100644 --- a/src/imex/key_transfer.rs +++ b/src/imex/key_transfer.rs @@ -93,10 +93,7 @@ pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result None, - true => Some(("Autocrypt-Prefer-Encrypt", "mutual")), - }; + let ac_headers = 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? diff --git a/src/imex/transfer.rs b/src/imex/transfer.rs index 6d1cb0a721..16f2f6dc5f 100644 --- a/src/imex/transfer.rs +++ b/src/imex/transfer.rs @@ -105,7 +105,7 @@ impl BackupProvider { // Acquire global "ongoing" mutex. let cancel_token = context.alloc_ongoing().await?; - let paused_guard = context.scheduler.pause(context.clone()).await?; + let paused_guard = context.scheduler.pause(context).await?; let context_dir = context .get_blobdir() .parent() @@ -242,7 +242,7 @@ impl BackupProvider { if let Err(err) = Self::handle_connection(context.clone(), conn, auth_token, dbfile).race( async { cancel_token.recv().await.ok(); - Err(format_err!("Backup transfer cancelled")) + Err(format_err!("Backup transfer canceled")) } ).race( async { @@ -262,12 +262,12 @@ impl BackupProvider { } }, _ = cancel_token.recv() => { - info!(context, "Backup transfer cancelled by the user, stopping accept loop."); + info!(context, "Backup transfer canceled by the user, stopping accept loop."); context.emit_event(EventType::ImexProgress(0)); break; } _ = drop_token.cancelled() => { - info!(context, "Backup transfer cancelled by dropping the provider, stopping accept loop."); + info!(context, "Backup transfer canceled by dropping the provider, stopping accept loop."); context.emit_event(EventType::ImexProgress(0)); break; } @@ -364,7 +364,7 @@ pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> { let res = get_backup2(context, node_addr, auth_token) .race(async { cancel_token.recv().await.ok(); - Err(format_err!("Backup reception cancelled")) + Err(format_err!("Backup reception canceled")) }) .await; if let Err(ref res) = res { diff --git a/src/key.rs b/src/key.rs index 7d01812d2b..cde859d006 100644 --- a/src/key.rs +++ b/src/key.rs @@ -15,6 +15,7 @@ use rand::thread_rng; use tokio::runtime::Handle; use crate::context::Context; +use crate::events::EventType; use crate::log::{LogExt, info}; use crate::pgp::KeyPair; use crate::tools::{self, time_elapsed}; @@ -24,7 +25,7 @@ use crate::tools::{self, time_elapsed}; /// This trait is implemented for rPGP's [SignedPublicKey] and /// [SignedSecretKey] types and makes working with them a little /// easier in the deltachat world. -pub(crate) trait DcKey: Serialize + Deserializable + Clone { +pub trait DcKey: Serialize + Deserializable + Clone { /// Create a key from some bytes. fn from_slice(bytes: &[u8]) -> Result { let res = ::from_bytes(Cursor::new(bytes)); @@ -71,31 +72,17 @@ pub(crate) trait DcKey: Serialize + Deserializable + Clone { } /// Create a key from an ASCII-armored string. - /// - /// 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)> { + fn from_asc(data: &str) -> Result { 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")?, }; - 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)) + Ok(key) } /// Serialise the key as bytes. @@ -125,7 +112,10 @@ pub(crate) trait DcKey: Serialize + Deserializable + Clone { /// The fingerprint for the key. fn dc_fingerprint(&self) -> Fingerprint; + /// Whether the key is private (or public). fn is_private() -> bool; + + /// Returns the OpenPGP Key ID. fn key_id(&self) -> KeyId; } @@ -428,15 +418,11 @@ pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) -> "INSERT INTO config (keyname, value) VALUES ('key_id', ?)", (new_key_id,), )?; - Ok(Some(new_key_id)) + Ok(new_key_id) }) .await?; - - if let Some(new_key_id) = new_key_id { - // Update config cache if transaction succeeded and changed current default key. - config_cache_lock.insert("key_id".to_string(), Some(new_key_id.to_string())); - } - + context.emit_event(EventType::AccountsItemChanged); + config_cache_lock.insert("key_id".to_string(), Some(new_key_id.to_string())); Ok(()) } @@ -446,7 +432,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)?.0; + let secret = SignedSecretKey::from_asc(secret_data)?; let public = secret.split_public_key()?; let keypair = KeyPair { public, secret }; store_self_keypair(context, &keypair).await?; @@ -514,7 +500,7 @@ impl std::str::FromStr for Fingerprint { .filter(|&c| c.is_ascii_hexdigit()) .collect(); let v: Vec = hex::decode(&hex_repr)?; - ensure!(v.len() == 20, "wrong fingerprint length: {}", hex_repr); + ensure!(v.len() == 20, "wrong fingerprint length: {hex_repr}"); let fp = Fingerprint::new(v); Ok(fp) } @@ -532,7 +518,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 @@ -600,17 +586,13 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD fn test_asc_roundtrip() { let key = KEYPAIR.public.clone(); let asc = key.to_asc(Some(("spam", "ham"))); - let (key2, hdrs) = SignedPublicKey::from_asc(&asc).unwrap(); + let key2 = 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, hdrs) = SignedSecretKey::from_asc(&asc).unwrap(); + let key2 = SignedSecretKey::from_asc(&asc).unwrap(); assert_eq!(key, key2); - assert_eq!(hdrs.len(), 1); - assert_eq!(hdrs.get("spam"), Some(&String::from("ham"))); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 3c6402cbfe..6a33b23c92 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,6 +53,7 @@ pub use events::*; mod aheader; pub mod blob; +pub mod calls; pub mod chat; pub mod chatlist; pub mod config; diff --git a/src/location.rs b/src/location.rs index 6961461204..4f9fe867e0 100644 --- a/src/location.rs +++ b/src/location.rs @@ -140,7 +140,7 @@ impl Kml { if self.tag == KmlTag::PlacemarkTimestampWhen || self.tag == KmlTag::PlacemarkPointCoordinates { - let val = event.unescape().unwrap_or_default(); + let val = event.xml_content().unwrap_or_default(); let val = val.replace(['\n', '\r', '\t', ' '], ""); diff --git a/src/message.rs b/src/message.rs index 04fbad610a..8ae88f6bb6 100644 --- a/src/message.rs +++ b/src/message.rs @@ -15,9 +15,7 @@ use crate::blob::BlobObject; use crate::chat::{Chat, ChatId, ChatIdBlocked, ChatVisibility, send_msg}; use crate::chatlist_events; use crate::config::Config; -use crate::constants::{ - Blocked, Chattype, DC_CHAT_ID_TRASH, DC_MSG_ID_LAST_SPECIAL, VideochatType, -}; +use crate::constants::{Blocked, Chattype, DC_CHAT_ID_TRASH, DC_MSG_ID_LAST_SPECIAL}; use crate::contact::{self, Contact, ContactId}; use crate::context::Context; use crate::debug_logging::set_debug_logging_xdc; @@ -492,8 +490,7 @@ impl Message { pub async fn load_from_db_optional(context: &Context, id: MsgId) -> Result> { ensure!( !id.is_special(), - "Can not load special message ID {} from DB", - id + "Can not load special message ID {id} from DB" ); let msg = context .sql @@ -568,7 +565,7 @@ impl Message { timestamp_rcvd: row.get("timestamp_rcvd")?, ephemeral_timer: row.get("ephemeral_timer")?, ephemeral_timestamp: row.get("ephemeral_timestamp")?, - viewtype: row.get("type")?, + viewtype: row.get("type").unwrap_or_default(), state: state.with_mdns(mdn_msg_id.is_some()), download_state: row.get("download_state")?, error: Some(row.get::<_, String>("error")?) @@ -651,8 +648,10 @@ 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 (self.viewtype == Viewtype::Image || self.viewtype == Viewtype::Gif) - && !self.param.exists(Param::Width) + if matches!( + self.viewtype, + Viewtype::Image | Viewtype::Gif | Viewtype::Sticker + ) && !self.param.exists(Param::Width) { let buf = read_file(context, &path_and_filename).await?; @@ -973,6 +972,8 @@ impl Message { | SystemMessage::WebxdcStatusUpdate | SystemMessage::WebxdcInfoMessage | SystemMessage::IrohNodeAddr + | SystemMessage::CallAccepted + | SystemMessage::CallEnded | SystemMessage::Unknown => Ok(None), } } @@ -1013,85 +1014,6 @@ impl Message { None } - // add room to a webrtc_instance as defined by the corresponding config-value; - // the result may still be prefixed by the type - pub(crate) fn create_webrtc_instance(instance: &str, room: &str) -> String { - let (videochat_type, mut url) = Message::parse_webrtc_instance(instance); - - // make sure, there is a scheme in the url - if !url.contains(':') { - url = format!("https://{url}"); - } - - // add/replace room - let url = if url.contains("$ROOM") { - url.replace("$ROOM", room) - } else if url.contains("$NOROOM") { - // there are some usecases where a separate room is not needed to use a service - // eg. if you let in people manually anyway, see discussion at - // . - // hacks as hiding the room behind `#` are not reliable, therefore, - // these services are supported by adding the string `$NOROOM` to the url. - url.replace("$NOROOM", "") - } else { - // if there nothing that would separate the room, add a slash as a separator; - // this way, urls can be given as "https://meet.jit.si" as well as "https://meet.jit.si/" - let maybe_slash = if url.ends_with('/') - || url.ends_with('?') - || url.ends_with('#') - || url.ends_with('=') - { - "" - } else { - "/" - }; - format!("{url}{maybe_slash}{room}") - }; - - // re-add and normalize type - match videochat_type { - VideochatType::BasicWebrtc => format!("basicwebrtc:{url}"), - VideochatType::Jitsi => format!("jitsi:{url}"), - VideochatType::Unknown => url, - } - } - - /// split a webrtc_instance as defined by the corresponding config-value into a type and a url - pub fn parse_webrtc_instance(instance: &str) -> (VideochatType, String) { - let instance: String = instance.split_whitespace().collect(); - let mut split = instance.splitn(2, ':'); - let type_str = split.next().unwrap_or_default().to_lowercase(); - let url = split.next(); - match type_str.as_str() { - "basicwebrtc" => ( - VideochatType::BasicWebrtc, - url.unwrap_or_default().to_string(), - ), - "jitsi" => (VideochatType::Jitsi, url.unwrap_or_default().to_string()), - _ => (VideochatType::Unknown, instance.to_string()), - } - } - - /// Returns videochat URL if the message is a videochat invitation. - pub fn get_videochat_url(&self) -> Option { - if self.viewtype == Viewtype::VideochatInvitation { - if let Some(instance) = self.param.get(Param::WebrtcRoom) { - return Some(Message::parse_webrtc_instance(instance).1); - } - } - None - } - - /// Returns videochat type if the message is a videochat invitation. - pub fn get_videochat_type(&self) -> Option { - if self.viewtype == Viewtype::VideochatInvitation { - if let Some(instance) = self.param.get(Param::WebrtcRoom) { - return Some(Message::parse_webrtc_instance(instance).0); - } - } - None - } - /// Sets or unsets message text. pub fn set_text(&mut self, text: String) { self.text = text; @@ -2273,8 +2195,8 @@ pub enum Viewtype { /// and retrieved via dc_msg_get_file(). File = 60, - /// 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, @@ -2298,7 +2220,7 @@ impl Viewtype { Viewtype::Voice => true, Viewtype::Video => true, Viewtype::File => true, - Viewtype::VideochatInvitation => false, + Viewtype::Call => false, Viewtype::Webxdc => true, Viewtype::Vcard => true, } diff --git a/src/message/message_tests.rs b/src/message/message_tests.rs index af3e74b980..267c7acbbd 100644 --- a/src/message/message_tests.rs +++ b/src/message/message_tests.rs @@ -25,82 +25,6 @@ fn test_guess_msgtype_from_suffix() { ); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_parse_webrtc_instance() { - let (webrtc_type, url) = Message::parse_webrtc_instance("basicwebrtc:https://foo/bar"); - assert_eq!(webrtc_type, VideochatType::BasicWebrtc); - assert_eq!(url, "https://foo/bar"); - - let (webrtc_type, url) = Message::parse_webrtc_instance("bAsIcwEbrTc:url"); - assert_eq!(webrtc_type, VideochatType::BasicWebrtc); - assert_eq!(url, "url"); - - let (webrtc_type, url) = Message::parse_webrtc_instance("https://foo/bar?key=val#key=val"); - assert_eq!(webrtc_type, VideochatType::Unknown); - assert_eq!(url, "https://foo/bar?key=val#key=val"); - - let (webrtc_type, url) = Message::parse_webrtc_instance("jitsi:https://j.si/foo"); - assert_eq!(webrtc_type, VideochatType::Jitsi); - assert_eq!(url, "https://j.si/foo"); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_create_webrtc_instance() { - // webrtc_instance may come from an input field of the ui, be pretty tolerant on input - let instance = Message::create_webrtc_instance("https://meet.jit.si/", "123"); - assert_eq!(instance, "https://meet.jit.si/123"); - - let instance = Message::create_webrtc_instance("https://meet.jit.si", "456"); - assert_eq!(instance, "https://meet.jit.si/456"); - - let instance = Message::create_webrtc_instance("meet.jit.si", "789"); - assert_eq!(instance, "https://meet.jit.si/789"); - - let instance = Message::create_webrtc_instance("bla.foo?", "123"); - assert_eq!(instance, "https://bla.foo?123"); - - let instance = Message::create_webrtc_instance("jitsi:bla.foo#", "456"); - assert_eq!(instance, "jitsi:https://bla.foo#456"); - - let instance = Message::create_webrtc_instance("bla.foo#room=", "789"); - assert_eq!(instance, "https://bla.foo#room=789"); - - let instance = Message::create_webrtc_instance("https://bla.foo#room", "123"); - assert_eq!(instance, "https://bla.foo#room/123"); - - let instance = Message::create_webrtc_instance("bla.foo#room$ROOM", "123"); - assert_eq!(instance, "https://bla.foo#room123"); - - let instance = Message::create_webrtc_instance("bla.foo#room=$ROOM&after=cont", "234"); - assert_eq!(instance, "https://bla.foo#room=234&after=cont"); - - let instance = Message::create_webrtc_instance(" meet.jit .si ", "789"); - assert_eq!(instance, "https://meet.jit.si/789"); - - let instance = Message::create_webrtc_instance(" basicwebrtc: basic . stuff\n ", "12345ab"); - assert_eq!(instance, "basicwebrtc:https://basic.stuff/12345ab"); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_create_webrtc_instance_noroom() { - // webrtc_instance may come from an input field of the ui, be pretty tolerant on input - let instance = Message::create_webrtc_instance("bla.foo$NOROOM", "123"); - assert_eq!(instance, "https://bla.foo"); - - let instance = Message::create_webrtc_instance(" bla . foo $NOROOM ", "456"); - assert_eq!(instance, "https://bla.foo"); - - let instance = Message::create_webrtc_instance(" $NOROOM bla . foo ", "789"); - assert_eq!(instance, "https://bla.foo"); - - let instance = Message::create_webrtc_instance(" bla.foo / $NOROOM ? a = b ", "123"); - assert_eq!(instance, "https://bla.foo/?a=b"); - - // $ROOM has a higher precedence - let instance = Message::create_webrtc_instance("bla.foo/?$NOROOM=$ROOM", "123"); - assert_eq!(instance, "https://bla.foo/?$NOROOM=123"); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_width_height() { let t = TestContext::new_alice().await; @@ -648,10 +572,6 @@ fn test_viewtype_values() { assert_eq!(Viewtype::Voice, Viewtype::from_i32(41).unwrap()); assert_eq!(Viewtype::Video, Viewtype::from_i32(50).unwrap()); assert_eq!(Viewtype::File, Viewtype::from_i32(60).unwrap()); - assert_eq!( - Viewtype::VideochatInvitation, - Viewtype::from_i32(70).unwrap() - ); assert_eq!(Viewtype::Webxdc, Viewtype::from_i32(80).unwrap()); assert_eq!(Viewtype::Vcard, Viewtype::from_i32(90).unwrap()); } @@ -827,3 +747,21 @@ async fn test_send_empty_file() -> Result<()> { assert_eq!(bob_received_msg.get_viewtype(), Viewtype::File); Ok(()) } + +/// Tests that viewtype 70 +/// which previously corresponded to videochat invitations, +/// is loaded as unknown viewtype without errors. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_load_unknown_viewtype() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let msg_id = tcm.send_recv(alice, bob, "Hello!").await.id; + bob.sql + .execute("UPDATE msgs SET type=70 WHERE id=?", (msg_id,)) + .await?; + let bob_msg = Message::load_from_db(bob, msg_id).await?; + assert_eq!(bob_msg.get_viewtype(), Viewtype::Unknown); + Ok(()) +} diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 0d971bf243..298e807219 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -419,10 +419,7 @@ impl MimeFactory { None } else { if keys.is_empty() && !recipients.is_empty() { - bail!( - "No recipient keys are available, cannot encrypt to {:?}.", - recipients - ); + bail!("No recipient keys are available, cannot encrypt to {recipients:?}."); } // Remove recipients for which the key is missing. @@ -967,10 +964,6 @@ impl MimeFactory { hidden_headers.push(header.clone()); } else if is_hidden(&header_name) { hidden_headers.push(header.clone()); - } else if header_name == "autocrypt" - && !context.get_config_bool(Config::ProtectAutocrypt).await? - { - unprotected_headers.push(header.clone()); } else if header_name == "from" { // Unencrypted securejoin messages should _not_ include the display name: if is_encrypted || !is_securejoin_message { @@ -1088,17 +1081,29 @@ impl MimeFactory { .is_none_or(|ts| now >= ts + gossip_period || now < ts) }; + let verifier_id: Option = context + .sql + .query_get_value( + "SELECT verifier FROM contacts WHERE fingerprint=?", + (&fingerprint,), + ) + .await?; + + let is_verified = + verifier_id.is_some_and(|verifier_id| verifier_id != 0); + if !should_do_gossip { continue; } - let header = Aheader::new( - addr.clone(), - key.clone(), + let header = Aheader { + addr: addr.clone(), + public_key: key.clone(), // Autocrypt 1.1.0 specification says that // `prefer-encrypt` attribute SHOULD NOT be included. - EncryptPreference::NoPreference, - ) + prefer_encrypt: EncryptPreference::NoPreference, + verified: is_verified, + } .to_string(); message = message.header( @@ -1322,20 +1327,6 @@ impl MimeFactory { let command = msg.param.get_cmd(); let mut placeholdertext = None; - let send_verified_headers = match chat.typ { - Chattype::Single => true, - Chattype::Group => true, - // Mailinglists and broadcast channels can actually never be verified: - Chattype::Mailinglist => false, - Chattype::OutBroadcast | Chattype::InBroadcast => false, - }; - if chat.is_protected() && send_verified_headers { - headers.push(( - "Chat-Verified", - mail_builder::headers::raw::Raw::new("1").into(), - )); - } - if chat.typ == Chattype::Group { // Send group ID unless it is an ad hoc group that has no ID. if !chat.grpid.is_empty() { @@ -1521,16 +1512,31 @@ impl MimeFactory { )); } SystemMessage::IrohNodeAddr => { + let node_addr = context + .get_or_try_init_peer_channel() + .await? + .get_node_addr() + .await?; + + // We should not send `null` as relay URL + // as this is the only way to reach the node. + debug_assert!(node_addr.relay_url().is_some()); headers.push(( HeaderDef::IrohNodeAddr.into(), - mail_builder::headers::text::Text::new(serde_json::to_string( - &context - .get_or_try_init_peer_channel() - .await? - .get_node_addr() - .await?, - )?) - .into(), + mail_builder::headers::text::Text::new(serde_json::to_string(&node_addr)?) + .into(), + )); + } + SystemMessage::CallAccepted => { + headers.push(( + "Chat-Content", + mail_builder::headers::raw::Raw::new("call-accepted").into(), + )); + } + SystemMessage::CallEnded => { + headers.push(( + "Chat-Content", + mail_builder::headers::raw::Raw::new("call-ended").into(), )); } _ => {} @@ -1552,20 +1558,25 @@ impl MimeFactory { "Chat-Content", mail_builder::headers::raw::Raw::new("sticker").into(), )); - } else if msg.viewtype == Viewtype::VideochatInvitation { + } else if msg.viewtype == Viewtype::Call { headers.push(( "Chat-Content", - mail_builder::headers::raw::Raw::new("videochat-invitation").into(), + 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( - msg.param - .get(Param::WebrtcRoom) - .unwrap_or_default() - .to_string(), - ) - .into(), + 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(), )); } @@ -1859,5 +1870,17 @@ 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; diff --git a/src/mimefactory/mimefactory_tests.rs b/src/mimefactory/mimefactory_tests.rs index a856a243d3..61cf0fec1e 100644 --- a/src/mimefactory/mimefactory_tests.rs +++ b/src/mimefactory/mimefactory_tests.rs @@ -6,8 +6,7 @@ use std::time::Duration; use super::*; use crate::chat::{ - self, ChatId, ProtectionStatus, add_contact_to_chat, create_group_chat, - remove_contact_from_chat, send_text_msg, + self, ChatId, add_contact_to_chat, create_group, remove_contact_from_chat, send_text_msg, }; use crate::chatlist::Chatlist; use crate::constants; @@ -352,9 +351,7 @@ async fn test_subject_in_group() -> Result<()> { let mut tcm = TestContextManager::new(); let t = tcm.alice().await; let bob = tcm.bob().await; - let group_id = chat::create_group_chat(&t, chat::ProtectionStatus::Unprotected, "groupname") - .await - .unwrap(); + let group_id = chat::create_group(&t, "groupname").await.unwrap(); let bob_contact_id = t.add_or_lookup_contact_id(&bob).await; chat::add_contact_to_chat(&t, group_id, bob_contact_id).await?; @@ -666,7 +663,7 @@ async fn test_selfavatar_unencrypted_signed() { assert_eq!(part.match_indices("From:").count(), 1); assert_eq!(part.match_indices("Message-ID:").count(), 0); assert_eq!(part.match_indices("Subject:").count(), 1); - assert_eq!(part.match_indices("Autocrypt:").count(), 0); + assert_eq!(part.match_indices("Autocrypt:").count(), 1); assert_eq!(part.match_indices("Chat-User-Avatar:").count(), 0); let part = payload.next().unwrap(); @@ -717,7 +714,7 @@ async fn test_selfavatar_unencrypted_signed() { assert_eq!(part.match_indices("From:").count(), 1); assert_eq!(part.match_indices("Message-ID:").count(), 0); assert_eq!(part.match_indices("Subject:").count(), 1); - assert_eq!(part.match_indices("Autocrypt:").count(), 0); + assert_eq!(part.match_indices("Autocrypt:").count(), 1); assert_eq!(part.match_indices("Chat-User-Avatar:").count(), 0); let part = payload.next().unwrap(); @@ -756,7 +753,7 @@ async fn test_remove_member_bcc() -> Result<()> { let charlie_contact = Contact::get_by_id(alice, charlie_id).await?; let charlie_addr = charlie_contact.get_addr(); - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "foo").await?; + let alice_chat_id = create_group(alice, "foo").await?; add_contact_to_chat(alice, alice_chat_id, bob_id).await?; add_contact_to_chat(alice, alice_chat_id, charlie_id).await?; send_text_msg(alice, alice_chat_id, "Creating a group".to_string()).await?; @@ -846,16 +843,12 @@ async fn test_dont_remove_self() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let first_group = alice - .create_group_with_members(ProtectionStatus::Unprotected, "First group", &[bob]) - .await; + let first_group = alice.create_group_with_members("First group", &[bob]).await; alice.send_text(first_group, "Hi! I created a group.").await; remove_contact_from_chat(alice, first_group, ContactId::SELF).await?; alice.pop_sent_msg().await; - let second_group = alice - .create_group_with_members(ProtectionStatus::Unprotected, "First group", &[bob]) - .await; + let second_group = alice.create_group_with_members("First group", &[bob]).await; let sent = alice .send_text(second_group, "Hi! I created another group.") .await; @@ -883,9 +876,7 @@ async fn test_new_member_is_first_recipient() -> Result<()> { 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; + let group = alice.create_group_with_members("Group", &[bob]).await; alice.send_text(group, "Hi! I created a group.").await; SystemTime::shift(Duration::from_secs(60)); diff --git a/src/mimeparser.rs b/src/mimeparser.rs index 1036cbb06b..3943bea08e 100644 --- a/src/mimeparser.rs +++ b/src/mimeparser.rs @@ -1,7 +1,7 @@ //! # MIME message parsing module. use std::cmp::min; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; use std::str; use std::str::FromStr; @@ -36,6 +36,17 @@ 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 @@ -76,16 +87,16 @@ pub(crate) struct MimeMessage { pub chat_disposition_notification_to: Option, pub decrypting_failed: bool, - /// Set of valid signature fingerprints if a message is an + /// Valid signature fingerprint if a message is an /// Autocrypt encrypted and signed message. /// /// If a message is not encrypted or the signature is not valid, - /// this set is empty. - pub signatures: HashSet, + /// this is `None`. + pub signature: Option, /// The addresses for which there was a gossip header /// and their respective gossiped keys. - pub gossiped_keys: HashMap, + pub gossiped_keys: BTreeMap, /// Fingerprint of the key in the Autocrypt header. /// @@ -216,6 +227,12 @@ 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"; @@ -223,12 +240,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 `body` contains the header only. + /// 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. pub(crate) async fn from_bytes( context: &Context, body: &[u8], - partial: Option, + partial: Option<(u32, Option)>, ) -> Result { let mail = mailparse::parse_mail(body)?; @@ -334,7 +351,7 @@ impl MimeMessage { let incoming = !context.is_self_addr(&from.addr).await?; - let mut aheader_value: Option = mail.headers.get_header_value(HeaderDef::Autocrypt); + let mut aheader_values = mail.headers.get_all_values(HeaderDef::Autocrypt.into()); let mail_raw; // Memory location for a possible decrypted message. let decrypted_msg; // Decrypted signed OpenPGP message. @@ -361,11 +378,11 @@ impl MimeMessage { timestamp_rcvd, ); - if let Some(protected_aheader_value) = decrypted_mail + let protected_aheader_values = decrypted_mail .headers - .get_header_value(HeaderDef::Autocrypt) - { - aheader_value = Some(protected_aheader_value); + .get_all_values(HeaderDef::Autocrypt.into()); + if !protected_aheader_values.is_empty() { + aheader_values = protected_aheader_values; } (Ok(decrypted_mail), true) @@ -383,26 +400,27 @@ impl MimeMessage { } }; - 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 - } + 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; } - } else { - None - }; + } let autocrypt_fingerprint = if let Some(autocrypt_header) = &autocrypt_header { let fingerprint = autocrypt_header.public_key.dc_fingerprint().hex(); @@ -427,7 +445,7 @@ impl MimeMessage { None }; - let public_keyring = if incoming { + let mut public_keyring = if incoming { if let Some(autocrypt_header) = autocrypt_header { vec![autocrypt_header.public_key] } else { @@ -437,8 +455,46 @@ 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 = 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() }; @@ -533,7 +589,7 @@ impl MimeMessage { decrypting_failed: mail.is_err(), // only non-empty if it was a valid autocrypt message - signatures, + signature: signatures.into_iter().last(), autocrypt_fingerprint, gossiped_keys, is_forwarded: false, @@ -556,9 +612,9 @@ impl MimeMessage { }; match partial { - Some(org_bytes) => { + Some((org_bytes, err)) => { parser - .create_stub_from_partial_download(context, org_bytes) + .create_stub_from_partial_download(context, org_bytes, err) .await?; } None => match mail { @@ -578,7 +634,7 @@ impl MimeMessage { error: Some(format!("Decrypting failed: {err:#}")), ..Default::default() }; - parser.parts.push(part); + parser.do_add_single_part(part); } }, }; @@ -638,6 +694,10 @@ 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; @@ -660,16 +720,24 @@ impl MimeMessage { } fn parse_videochat_headers(&mut self) { - 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; - part.param - .set(Param::WebrtcRoom, instance.unwrap_or_default()); + 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 == "call" { + part.typ = Viewtype::Call; + part.param.set(Param::WebrtcRoom, room); } + } else if let Some(accepted) = accepted { + part.param.set(Param::WebrtcAccepted, accepted); } } } @@ -695,7 +763,7 @@ impl MimeMessage { | Viewtype::Vcard | Viewtype::File | Viewtype::Webxdc => true, - Viewtype::Unknown | Viewtype::Text | Viewtype::VideochatInvitation => false, + Viewtype::Unknown | Viewtype::Text | Viewtype::Call => false, }) { let mut parts = std::mem::take(&mut self.parts); @@ -898,7 +966,7 @@ impl MimeMessage { /// This means the message was both encrypted and signed with a /// valid signature. pub fn was_encrypted(&self) -> bool { - !self.signatures.is_empty() + self.signature.is_some() } /// Returns whether the email contains a `chat-version` header. @@ -999,47 +1067,61 @@ 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") => { - for cur_data in &mail.subparts { - let mime_type = get_mime_type( + // multipart/alternative is described in + // . + // 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( cur_data, &get_attachment_filename(context, cur_data)?, self.has_chat_version(), - )? - .0; - if mime_type == "multipart/mixed" || mime_type == "multipart/related" { + )?; + + if mime_type == mime::TEXT_PLAIN || mime_type.type_() == mime::MULTIPART { any_part_added = self .parse_mime_recursive(context, cur_data, is_related) .await?; break; } } - 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; - } + + // Explicitly look for a `text/calendar` part. + // Messages conforming to + // 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::()?; + 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 { - /* `text/plain` not found - use the first part */ - for cur_part in &mail.subparts { + for cur_part in mail.subparts.iter().rev() { if self .parse_mime_recursive(context, cur_part, is_related) .await? @@ -1447,7 +1529,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:#}."); @@ -1470,7 +1552,7 @@ impl MimeMessage { Ok(true) } - fn do_add_single_part(&mut self, mut part: Part) { + pub(crate) fn do_add_single_part(&mut self, mut part: Part) { if self.was_encrypted() { part.param.set_int(Param::GuaranteeE2ee, 1); } @@ -1510,13 +1592,11 @@ impl MimeMessage { } } - 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); - } + /// 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(crate) fn get_rfc724_mid(&self) -> Option { @@ -1904,9 +1984,9 @@ async fn parse_gossip_headers( from: &str, recipients: &[SingleInfo], gossip_headers: Vec, -) -> Result> { +) -> Result> { // XXX split the parsing from the modification part - let mut gossiped_keys: HashMap = Default::default(); + let mut gossiped_keys: BTreeMap = Default::default(); for value in &gossip_headers { let header = match value.parse::() { @@ -1948,7 +2028,12 @@ async fn parse_gossip_headers( ) .await?; - gossiped_keys.insert(header.addr.to_lowercase(), header.public_key); + let gossiped_key = GossipedKey { + public_key: header.public_key, + + verified: header.verified, + }; + gossiped_keys.insert(header.addr.to_lowercase(), gossiped_key); } Ok(gossiped_keys) @@ -1995,7 +2080,7 @@ pub(crate) fn parse_message_id(ids: &str) -> Result { if let Some(id) = parse_message_ids(ids).first() { Ok(id.to_string()) } else { - bail!("could not parse message_id: {}", ids); + bail!("could not parse message_id: {ids}"); } } diff --git a/src/mimeparser/mimeparser_tests.rs b/src/mimeparser/mimeparser_tests.rs index 73ea93b211..19060f37c4 100644 --- a/src/mimeparser/mimeparser_tests.rs +++ b/src/mimeparser/mimeparser_tests.rs @@ -476,6 +476,10 @@ async fn test_mimeparser_with_avatars() { assert!(mimeparser.group_avatar.unwrap().is_change()); } +/// Tests that video chat invitations that are not supported anymore +/// are displayed as text messages. +/// +/// User can still click on the link manually. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_mimeparser_with_videochat() { let t = TestContext::new_alice().await; @@ -483,14 +487,8 @@ async fn test_mimeparser_with_videochat() { let raw = include_bytes!("../../test-data/message/videochat_invitation.eml"); let mimeparser = MimeMessage::from_bytes(&t, &raw[..], None).await.unwrap(); assert_eq!(mimeparser.parts.len(), 1); - assert_eq!(mimeparser.parts[0].typ, Viewtype::VideochatInvitation); - assert_eq!( - mimeparser.parts[0] - .param - .get(Param::WebrtcRoom) - .unwrap_or_default(), - "https://example.org/p2p/?roomname=6HiduoAn4xN" - ); + assert_eq!(mimeparser.parts[0].typ, Viewtype::Text); + assert_eq!(mimeparser.parts[0].param.get(Param::WebrtcRoom), None); assert!( mimeparser.parts[0] .msg @@ -1819,39 +1817,6 @@ async fn test_take_last_header() { ); } -async fn test_protect_autocrypt(enabled: bool) -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = &tcm.alice().await; - let bob = &tcm.bob().await; - - let chat = alice.create_chat(bob).await; - alice - .set_config_bool(Config::ProtectAutocrypt, enabled) - .await?; - let sent = alice.send_text(chat.id, "Hello!").await; - assert_eq!(sent.payload().contains("Autocrypt: "), !enabled); - let msg = bob.recv_msg(&sent).await; - assert_eq!(msg.get_showpadlock(), true); - - Ok(()) -} - -/// Tests that if `protect_autocrypt` is enabled, -/// `Autocrypt` header does not appear in the outer headers -/// of encrypted messages. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_protect_autocrypt_enabled() -> Result<()> { - test_protect_autocrypt(true).await -} - -/// Tests that if `protect_autocrypt` is disabled, -/// `Autocrypt` header appears in the outer headers -/// of encrypted messages. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_protect_autocrypt_false() -> Result<()> { - test_protect_autocrypt(false).await -} - /// Tests that CRLF before MIME boundary /// is not treated as the part body. /// @@ -1990,6 +1955,27 @@ 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<()> { @@ -2063,3 +2049,48 @@ 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: +/// +#[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 +To: Alice +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."); +} diff --git a/src/net.rs b/src/net.rs index 01bd8ed9b2..4471a492de 100644 --- a/src/net.rs +++ b/src/net.rs @@ -12,6 +12,7 @@ use tokio_io_timeout::TimeoutStream; use crate::context::Context; use crate::net::session::SessionStream; +use crate::net::tls::TlsSessionStore; use crate::sql::Sql; use crate::tools::time; @@ -127,10 +128,19 @@ pub(crate) async fn connect_tls_inner( addr: SocketAddr, host: &str, strict_tls: bool, - alpn: &[&str], + alpn: &str, + tls_session_store: &TlsSessionStore, ) -> Result { let tcp_stream = connect_tcp_inner(addr).await?; - let tls_stream = wrap_tls(strict_tls, host, alpn, tcp_stream).await?; + let tls_stream = wrap_tls( + strict_tls, + host, + addr.port(), + alpn, + tcp_stream, + tls_session_store, + ) + .await?; Ok(tls_stream) } diff --git a/src/net/dns.rs b/src/net/dns.rs index 2109b18f7f..abd207cfc6 100644 --- a/src/net/dns.rs +++ b/src/net/dns.rs @@ -227,9 +227,6 @@ pub(crate) async fn update_connect_timestamp( } /// Preloaded DNS results that can be used in case of DNS server failures. -/// -/// See and -/// for reasons. static DNS_PRELOAD: LazyLock>> = LazyLock::new(|| { HashMap::from([ ( diff --git a/src/net/http.rs b/src/net/http.rs index 4d09b68ded..68dff5fc7b 100644 --- a/src/net/http.rs +++ b/src/net/http.rs @@ -16,6 +16,10 @@ use crate::net::session::SessionStream; use crate::net::tls::wrap_rustls; use crate::tools::time; +/// User-Agent for HTTP requests if a resource usage policy requires it. +/// By default we do not set User-Agent. +const USER_AGENT: &str = "chatmail/2 (+https://github.com/chatmail/core/)"; + /// HTTP(S) GET response. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Response { @@ -76,11 +80,13 @@ where let proxy_stream = proxy_config .connect(context, host, port, load_cache) .await?; - let tls_stream = wrap_rustls(host, &[], proxy_stream).await?; + let tls_stream = + wrap_rustls(host, port, "", proxy_stream, &context.tls_session_store).await?; Box::new(tls_stream) } else { let tcp_stream = crate::net::connect_tcp(context, host, port, load_cache).await?; - let tls_stream = wrap_rustls(host, &[], tcp_stream).await?; + let tls_stream = + wrap_rustls(host, port, "", tcp_stream, &context.tls_session_store).await?; Box::new(tls_stream) } } @@ -102,6 +108,13 @@ fn http_url_cache_timestamps(url: &str, mimetype: Option<&str>) -> (i64, i64) { let stale = if url.ends_with(".xdc") { // WebXDCs are never stale, they just expire. expires + } else if url.starts_with("https://tile.openstreetmap.org/") + || url.starts_with("https://vector.openstreetmap.org/") + { + // Policy at + // requires that we cache tiles for at least 7 days. + // Do not revalidate earlier than that. + now + 3600 * 24 * 7 } else if mimetype.is_some_and(|s| s.starts_with("image/")) { // Cache images for 1 day. // @@ -243,8 +256,22 @@ async fn fetch_url(context: &Context, original_url: &str) -> Result { .context("URL has no authority")? .clone(); - let req = hyper::Request::builder() - .uri(parsed_url) + let req = hyper::Request::builder().uri(parsed_url); + + // OSM usage policy requires + // that User-Agent is set for HTTP GET requests + // to tile servers: + // + // Same for vectory tiles + // at . + let req = + if authority == "tile.openstreetmap.org" || authority == "vector.openstreetmap.org" { + req.header("User-Agent", USER_AGENT) + } else { + req + }; + + let req = req .header(hyper::header::HOST, authority.as_str()) .body(http_body_util::Empty::::new())?; let response = sender.send_request(req).await?; diff --git a/src/net/proxy.rs b/src/net/proxy.rs index 0f657b5439..c59692ccc8 100644 --- a/src/net/proxy.rs +++ b/src/net/proxy.rs @@ -429,7 +429,14 @@ impl ProxyConfig { load_cache, ) .await?; - let tls_stream = wrap_rustls(&https_config.host, &[], tcp_stream).await?; + let tls_stream = wrap_rustls( + &https_config.host, + https_config.port, + "", + tcp_stream, + &context.tls_session_store, + ) + .await?; let auth = if let Some((username, password)) = &https_config.user_password { Some((username.as_str(), password.as_str())) } else { diff --git a/src/net/tls.rs b/src/net/tls.rs index 1c9eb5a90a..fce4abcb92 100644 --- a/src/net/tls.rs +++ b/src/net/tls.rs @@ -1,27 +1,38 @@ //! TLS support. +use parking_lot::Mutex; +use std::collections::HashMap; use std::sync::Arc; use anyhow::Result; use crate::net::session::SessionStream; +use tokio_rustls::rustls::client::ClientSessionStore; + pub async fn wrap_tls<'a>( strict_tls: bool, hostname: &str, - alpn: &[&str], + port: u16, + alpn: &str, stream: impl SessionStream + 'static, + tls_session_store: &TlsSessionStore, ) -> Result { if strict_tls { - let tls_stream = wrap_rustls(hostname, alpn, stream).await?; + let tls_stream = wrap_rustls(hostname, port, alpn, stream, tls_session_store).await?; let boxed_stream: Box = Box::new(tls_stream); Ok(boxed_stream) } else { // We use native_tls because it accepts 1024-bit RSA keys. // Rustls does not support them even if // certificate checks are disabled: . + let alpns = if alpn.is_empty() { + Box::from([]) + } else { + Box::from([alpn]) + }; let tls = async_native_tls::TlsConnector::new() .min_protocol_version(Some(async_native_tls::Protocol::Tlsv12)) - .request_alpns(alpn) + .request_alpns(&alpns) .danger_accept_invalid_hostnames(true) .danger_accept_invalid_certs(true); let tls_stream = tls.connect(hostname, stream).await?; @@ -30,18 +41,82 @@ pub async fn wrap_tls<'a>( } } +/// Map to store TLS session tickets. +/// +/// Tickets are separated by port and ALPN +/// to avoid trying to use Postfix ticket for Dovecot and vice versa. +/// Doing so would not be a security issue, +/// but wastes the ticket and the opportunity to resume TLS session unnecessarily. +/// Rustls takes care of separating tickets that belong to different domain names. +#[derive(Debug)] +pub(crate) struct TlsSessionStore { + sessions: Mutex>>, +} + +// This is the default for TLS session store +// as of Rustls version 0.23.16, +// but we want to create multiple caches +// to separate them by port and ALPN. +const TLS_CACHE_SIZE: usize = 256; + +impl TlsSessionStore { + /// Creates a new TLS session store. + /// + /// One such store should be created per profile + /// to keep TLS sessions independent. + pub fn new() -> Self { + Self { + sessions: Default::default(), + } + } + + /// Returns session store for given port and ALPN. + /// + /// Rustls additionally separates sessions by hostname. + pub fn get(&self, port: u16, alpn: &str) -> Arc { + Arc::clone( + self.sessions + .lock() + .entry((port, alpn.to_string())) + .or_insert_with(|| { + Arc::new(tokio_rustls::rustls::client::ClientSessionMemoryCache::new( + TLS_CACHE_SIZE, + )) + }), + ) + } +} + pub async fn wrap_rustls<'a>( hostname: &str, - alpn: &[&str], + port: u16, + alpn: &str, stream: impl SessionStream + 'a, + tls_session_store: &TlsSessionStore, ) -> Result { - let mut root_cert_store = rustls::RootCertStore::empty(); + let mut root_cert_store = tokio_rustls::rustls::RootCertStore::empty(); root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - let mut config = rustls::ClientConfig::builder() + let mut config = tokio_rustls::rustls::ClientConfig::builder() .with_root_certificates(root_cert_store) .with_no_client_auth(); - config.alpn_protocols = alpn.iter().map(|s| s.as_bytes().to_vec()).collect(); + config.alpn_protocols = if alpn.is_empty() { + vec![] + } else { + vec![alpn.as_bytes().to_vec()] + }; + + // Enable TLS 1.3 session resumption + // as defined in . + // + // Obsolete TLS 1.2 mechanisms defined in RFC 5246 + // and RFC 5077 have worse security + // and are not worth increasing + // attack surface: . + let resumption_store = tls_session_store.get(port, alpn); + let resumption = tokio_rustls::rustls::client::Resumption::store(resumption_store) + .tls12_resumption(tokio_rustls::rustls::client::Tls12Resumption::Disabled); + config.resumption = resumption; let tls = tokio_rustls::TlsConnector::from(Arc::new(config)); let name = rustls_pki_types::ServerName::try_from(hostname)?.to_owned(); diff --git a/src/oauth2.rs b/src/oauth2.rs index ae875d8273..5c0b6a00e1 100644 --- a/src/oauth2.rs +++ b/src/oauth2.rs @@ -14,17 +14,6 @@ use crate::provider; use crate::provider::Oauth2Authorizer; use crate::tools::time; -const OAUTH2_GMAIL: Oauth2 = Oauth2 { - // see - client_id: "959970109878-4mvtgf6feshskf7695nfln6002mom908.apps.googleusercontent.com", - get_code: "https://accounts.google.com/o/oauth2/auth?client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&response_type=code&scope=https%3A%2F%2Fmail.google.com%2F%20email&access_type=offline", - init_token: "https://accounts.google.com/o/oauth2/token?client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&code=$CODE&grant_type=authorization_code", - refresh_token: "https://accounts.google.com/o/oauth2/token?client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&refresh_token=$REFRESH_TOKEN&grant_type=refresh_token", - get_userinfo: Some( - "https://www.googleapis.com/oauth2/v1/userinfo?alt=json&access_token=$ACCESS_TOKEN", - ), -}; - const OAUTH2_YANDEX: Oauth2 = Oauth2 { // see client_id: "c4d0b6735fc8420a816d7e1303469341", @@ -64,7 +53,7 @@ pub async fn get_oauth2_url( addr: &str, redirect_uri: &str, ) -> Result> { - if let Some(oauth2) = Oauth2::from_address(context, addr).await { + if let Some(oauth2) = Oauth2::from_address(addr) { context .sql .set_raw_config("oauth2_pending_redirect_uri", Some(redirect_uri)) @@ -84,7 +73,7 @@ pub(crate) async fn get_oauth2_access_token( code: &str, regenerate: bool, ) -> Result> { - if let Some(oauth2) = Oauth2::from_address(context, addr).await { + if let Some(oauth2) = Oauth2::from_address(addr) { let lock = context.oauth2_mutex.lock().await; // read generated token @@ -232,7 +221,7 @@ pub(crate) async fn get_oauth2_addr( addr: &str, code: &str, ) -> Result> { - let oauth2 = match Oauth2::from_address(context, addr).await { + let oauth2 = match Oauth2::from_address(addr) { Some(o) => o, None => return Ok(None), }; @@ -267,19 +256,16 @@ pub(crate) async fn get_oauth2_addr( } impl Oauth2 { - async fn from_address(context: &Context, addr: &str) -> Option { + fn from_address(addr: &str) -> Option { let addr_normalized = normalize_addr(addr); - let skip_mx = true; if let Some(domain) = addr_normalized .find('@') .map(|index| addr_normalized.split_at(index + 1).1) { - if let Some(oauth2_authorizer) = provider::get_provider_info(context, domain, skip_mx) - .await + if let Some(oauth2_authorizer) = provider::get_provider_info(domain) .and_then(|provider| provider.oauth2_authorizer.as_ref()) { return Some(match oauth2_authorizer { - Oauth2Authorizer::Gmail => OAUTH2_GMAIL, Oauth2Authorizer::Yandex => OAUTH2_YANDEX, }); } @@ -366,21 +352,16 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_oauth_from_address() { - let t = TestContext::new().await; - // Delta Chat does not have working Gmail client ID anymore. - assert_eq!(Oauth2::from_address(&t, "hello@gmail.com").await, None); - assert_eq!(Oauth2::from_address(&t, "hello@googlemail.com").await, None); + assert_eq!(Oauth2::from_address("hello@gmail.com"), None); + assert_eq!(Oauth2::from_address("hello@googlemail.com"), None); assert_eq!( - Oauth2::from_address(&t, "hello@yandex.com").await, - Some(OAUTH2_YANDEX) - ); - assert_eq!( - Oauth2::from_address(&t, "hello@yandex.ru").await, + Oauth2::from_address("hello@yandex.com"), Some(OAUTH2_YANDEX) ); - assert_eq!(Oauth2::from_address(&t, "hello@web.de").await, None); + assert_eq!(Oauth2::from_address("hello@yandex.ru"), Some(OAUTH2_YANDEX)); + assert_eq!(Oauth2::from_address("hello@web.de"), None); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src/param.rs b/src/param.rs index 9e0433a256..1ea377100a 100644 --- a/src/param.rs +++ b/src/param.rs @@ -120,6 +120,9 @@ 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 @@ -262,7 +265,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 { let mut inner = BTreeMap::new(); - let mut lines = s.lines().peekable(); + let mut lines = s.split('\n').peekable(); while let Some(line) = lines.next() { if let [key, value] = line.splitn(2, '=').collect::>()[..] { @@ -281,7 +284,7 @@ impl str::FromStr for Params { inner.insert(key, value); } } else { - bail!("Not a key-value pair: {:?}", line); + bail!("Not a key-value pair: {line:?}"); } } @@ -454,6 +457,7 @@ 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::().unwrap(), params); } diff --git a/src/peer_channels.rs b/src/peer_channels.rs index 53d37927d1..a8797f8861 100644 --- a/src/peer_channels.rs +++ b/src/peer_channels.rs @@ -185,9 +185,14 @@ impl Iroh { } /// Get the iroh [NodeAddr] without direct IP addresses. + /// + /// The address is guaranteed to have home relay URL set + /// as it is the only way to reach the node + /// without global discovery mechanisms. pub(crate) async fn get_node_addr(&self) -> Result { let mut addr = self.router.endpoint().node_addr().await?; addr.direct_addresses = BTreeSet::new(); + debug_assert!(addr.relay_url().is_some()); Ok(addr) } @@ -278,18 +283,24 @@ impl Context { }) } + /// Returns [`None`] if the peer channels has not been initialized. + pub async fn get_peer_channels(&self) -> Option> { + tokio::sync::RwLockReadGuard::<'_, std::option::Option>::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> { if !self.get_config_bool(Config::WebxdcRealtimeEnabled).await? { - bail!("Attempt to get Iroh when realtime is disabled"); + bail!("Attempt to initialize Iroh when realtime is disabled"); } - if let Ok(lock) = tokio::sync::RwLockReadGuard::<'_, std::option::Option>::try_map( - self.iroh.read().await, - |opt_iroh| opt_iroh.as_ref(), - ) { + if let Some(lock) = self.get_peer_channels().await { return Ok(lock); } @@ -479,14 +490,17 @@ pub async fn send_webxdc_realtime_data(ctx: &Context, msg_id: MsgId, data: Vec Result<()> { - if !ctx.get_config_bool(Config::WebxdcRealtimeEnabled).await? { + let Some(iroh) = ctx.get_peer_channels().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?; + }; + let Some(topic) = get_iroh_topic_for_msg(ctx, msg_id).await? else { + return Ok(()); + }; iroh.leave_realtime(topic).await?; info!(ctx, "IROH_REALTIME: Left gossip for message {msg_id}"); @@ -560,7 +574,7 @@ mod tests { use super::*; use crate::{ EventType, - chat::{self, ChatId, ProtectionStatus, add_contact_to_chat, resend_msgs, send_msg}, + chat::{self, ChatId, add_contact_to_chat, resend_msgs, send_msg}, message::{Message, Viewtype}, test_utils::{TestContext, TestContextManager}, }; @@ -602,7 +616,7 @@ mod tests { loop { let event = bob.evtracker.recv().await.unwrap(); if let EventType::WebxdcRealtimeAdvertisementReceived { msg_id } = event.typ { - assert!(msg_id == alice_webxdc.id); + assert!(msg_id == bob_webxdc.id); break; } } @@ -948,9 +962,7 @@ mod tests { 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(); + let group = chat::create_group(alice, "group chat").await.unwrap(); // Alice sends webxdc to bob let mut instance = Message::new(Viewtype::File); @@ -1110,7 +1122,6 @@ 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()); @@ -1119,4 +1130,19 @@ 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()); + } } diff --git a/src/pgp.rs b/src/pgp.rs index e00a41310b..86d39aeb1d 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -8,9 +8,9 @@ use chrono::SubsecRound; use deltachat_contact_tools::EmailAddress; use pgp::armor::BlockType; use pgp::composed::{ - ArmorOptions, Deserializable, KeyType as PgpKeyType, Message, MessageBuilder, - SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey, SignedSecretKey, - StandaloneSignature, SubkeyParamsBuilder, TheRing, + ArmorOptions, DecryptionOptions, Deserializable, DetachedSignature, KeyType as PgpKeyType, + Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey, + SignedSecretKey, SubkeyParamsBuilder, TheRing, }; use pgp::crypto::ecc_curve::ECCCurve; use pgp::crypto::hash::HashAlgorithm; @@ -178,7 +178,7 @@ pub async fn pk_encrypt( let msg = MessageBuilder::from_bytes("", plain); let mut msg = msg.seipd_v1(&mut rng, SYMMETRIC_KEY_ALGORITHM); for pkey in pkeys { - msg.encrypt_to_key(&mut rng, &pkey)?; + msg.encrypt_to_key_anonymous(&mut rng, &pkey)?; } if let Some(ref skey) = private_key_for_signing { @@ -226,7 +226,7 @@ pub fn pk_calc_signature( plain.as_slice(), )?; - let sig = StandaloneSignature::new(signature); + let sig = DetachedSignature::new(signature); Ok(sig.to_armored_string(ArmorOptions::default())?) } @@ -245,12 +245,13 @@ 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![], - allow_legacy: false, + decrypt_options, }; let (msg, ring_result) = msg.decrypt_the_ring(ring, true)?; anyhow::ensure!( @@ -272,7 +273,7 @@ pub fn pk_decrypt( pub fn valid_signature_fingerprints( msg: &pgp::composed::Message, public_keys_for_validation: &[SignedPublicKey], -) -> Result> { +) -> HashSet { let mut ret_signature_fingerprints: HashSet = Default::default(); if msg.is_signed() { for pkey in public_keys_for_validation { @@ -282,7 +283,7 @@ pub fn valid_signature_fingerprints( } } } - Ok(ret_signature_fingerprints) + ret_signature_fingerprints } /// Validates detached signature. @@ -293,10 +294,10 @@ pub fn pk_validate( ) -> Result> { let mut ret: HashSet = Default::default(); - let standalone_signature = StandaloneSignature::from_armor_single(Cursor::new(signature))?.0; + let detached_signature = DetachedSignature::from_armor_single(Cursor::new(signature))?.0; for pkey in public_keys_for_validation { - if standalone_signature.verify(pkey, content).is_ok() { + if detached_signature.verify(pkey, content).is_ok() { let fp = pkey.dc_fingerprint(); ret.insert(fp); } @@ -346,6 +347,8 @@ mod tests { use super::*; use crate::test_utils::{alice_keypair, bob_keypair}; + use pgp::composed::Esk; + use pgp::packet::PublicKeyEncryptedSessionKey; fn pk_decrypt_and_validate<'a>( ctext: &'a [u8], @@ -359,7 +362,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)) } @@ -542,4 +545,34 @@ mod tests { assert_eq!(content, CLEARTEXT); assert_eq!(valid_signatures.len(), 0); } + + /// Tests that recipient key IDs and fingerprints + /// are omitted or replaced with wildcards. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_anonymous_recipients() -> Result<()> { + let ctext = ctext_signed().await.as_bytes(); + let cursor = Cursor::new(ctext); + let (msg, _headers) = Message::from_armor(cursor)?; + + let Message::Encrypted { esk, .. } = msg else { + unreachable!(); + }; + + for encrypted_session_key in esk { + let Esk::PublicKeyEncryptedSessionKey(pkesk) = encrypted_session_key else { + unreachable!() + }; + + match pkesk { + PublicKeyEncryptedSessionKey::V3 { id, .. } => { + assert!(id.is_wildcard()); + } + PublicKeyEncryptedSessionKey::V6 { fingerprint, .. } => { + assert!(fingerprint.is_none()); + } + PublicKeyEncryptedSessionKey::Other { .. } => unreachable!(), + } + } + Ok(()) + } } diff --git a/src/provider.rs b/src/provider.rs index 24de61ada3..9d5ee63c00 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -4,13 +4,9 @@ pub(crate) mod data; use anyhow::Result; use deltachat_contact_tools::EmailAddress; -use hickory_resolver::name_server::TokioConnectionProvider; -use hickory_resolver::{Resolver, TokioResolver, config}; use serde::{Deserialize, Serialize}; use crate::config::Config; -use crate::context::Context; -use crate::log::warn; use crate::provider::data::{PROVIDER_DATA, PROVIDER_IDS}; /// Provider status according to manual testing. @@ -82,13 +78,9 @@ pub enum UsernamePattern { /// Type of OAuth 2 authorization. #[derive(Debug, PartialEq, Eq)] -#[repr(u8)] pub enum Oauth2Authorizer { /// Yandex. - Yandex = 1, - - /// Gmail. - Gmail = 2, + Yandex, } /// Email server endpoint. @@ -175,61 +167,17 @@ impl ProviderOptions { } } -/// Get resolver to query MX records. -/// -/// We first try to read the system's resolver from `/etc/resolv.conf`. -/// This does not work at least on some Androids, therefore we fallback -/// to the default `ResolverConfig` which uses eg. to google's `8.8.8.8` or `8.8.4.4`. -fn get_resolver() -> Result { - if let Ok(resolver) = TokioResolver::builder_tokio() { - return Ok(resolver.build()); - } - let resolver = Resolver::builder_with_config( - config::ResolverConfig::default(), - TokioConnectionProvider::default(), - ); - Ok(resolver.build()) -} - /// Returns provider for the given an e-mail address. /// /// Returns an error if provided address is not valid. -pub async fn get_provider_info_by_addr( - context: &Context, - addr: &str, - skip_mx: bool, -) -> Result> { +pub fn get_provider_info_by_addr(addr: &str) -> Result> { let addr = EmailAddress::new(addr)?; - let provider = get_provider_info(context, &addr.domain, skip_mx).await; - Ok(provider) -} - -/// Returns provider for the given domain. -/// -/// This function looks up domain in offline database first. If not -/// found, it queries MX record for the domain and looks up offline -/// database for MX domains. -pub async fn get_provider_info( - context: &Context, - domain: &str, - skip_mx: bool, -) -> Option<&'static Provider> { - if let Some(provider) = get_provider_by_domain(domain) { - return Some(provider); - } - - if !skip_mx { - if let Some(provider) = get_provider_by_mx(context, domain).await { - return Some(provider); - } - } - - None + Ok(get_provider_info(&addr.domain)) } /// Finds a provider in offline database based on domain. -pub fn get_provider_by_domain(domain: &str) -> Option<&'static Provider> { +pub fn get_provider_info(domain: &str) -> Option<&'static Provider> { let domain = domain.to_lowercase(); for (pattern, provider) in PROVIDER_DATA { if let Some(suffix) = pattern.strip_prefix('*') { @@ -247,51 +195,6 @@ pub fn get_provider_by_domain(domain: &str) -> Option<&'static Provider> { None } -/// Finds a provider based on MX record for the given domain. -/// -/// For security reasons, only Gmail can be configured this way. -pub async fn get_provider_by_mx(context: &Context, domain: &str) -> Option<&'static Provider> { - let Ok(resolver) = get_resolver() else { - warn!(context, "Cannot get a resolver to check MX records."); - return None; - }; - - let mut fqdn: String = domain.to_string(); - if !fqdn.ends_with('.') { - fqdn.push('.'); - } - - let Ok(mx_domains) = resolver.mx_lookup(fqdn).await else { - warn!(context, "Cannot resolve MX records for {domain:?}."); - return None; - }; - - for (provider_domain_pattern, provider) in PROVIDER_DATA { - if provider.id != "gmail" { - // MX lookup is limited to Gmail for security reasons - continue; - } - - if provider_domain_pattern.starts_with('*') { - // Skip wildcard patterns. - continue; - } - - let provider_fqdn = provider_domain_pattern.to_string() + "."; - let provider_fqdn_dot = ".".to_string() + &provider_fqdn; - - for mx_domain in mx_domains.iter() { - let mx_domain = mx_domain.exchange().to_lowercase().to_utf8(); - - if mx_domain == provider_fqdn || mx_domain.ends_with(&provider_fqdn_dot) { - return Some(provider); - } - } - } - - None -} - /// Returns a provider with the given ID from the database. pub fn get_provider_by_id(id: &str) -> Option<&'static Provider> { if let Some(provider) = PROVIDER_IDS.get(id) { @@ -304,24 +207,23 @@ pub fn get_provider_by_id(id: &str) -> Option<&'static Provider> { #[cfg(test)] mod tests { use super::*; - use crate::test_utils::TestContext; #[test] fn test_get_provider_by_domain_unexistant() { - let provider = get_provider_by_domain("unexistant.org"); + let provider = get_provider_info("unexistant.org"); assert!(provider.is_none()); } #[test] fn test_get_provider_by_domain_mixed_case() { - let provider = get_provider_by_domain("nAUta.Cu").unwrap(); + let provider = get_provider_info("nAUta.Cu").unwrap(); assert!(provider.status == Status::Ok); } #[test] - fn test_get_provider_by_domain() { + fn test_get_provider_info() { let addr = "nauta.cu"; - let provider = get_provider_by_domain(addr).unwrap(); + let provider = get_provider_info(addr).unwrap(); assert!(provider.status == Status::Ok); let server = &provider.server[0]; assert_eq!(server.protocol, Protocol::Imap); @@ -336,13 +238,17 @@ mod tests { assert_eq!(server.port, 25); assert_eq!(server.username_pattern, UsernamePattern::Email); - let provider = get_provider_by_domain("gmail.com").unwrap(); + let provider = get_provider_info("gmail.com").unwrap(); assert!(provider.status == Status::Preparation); assert!(!provider.before_login_hint.is_empty()); assert!(!provider.overview_page.is_empty()); - let provider = get_provider_by_domain("googlemail.com").unwrap(); + let provider = get_provider_info("googlemail.com").unwrap(); assert!(provider.status == Status::Preparation); + + assert!(get_provider_info("").is_none()); + assert!(get_provider_info("google.com").unwrap().id == "gmail"); + assert!(get_provider_info("example@google.com").is_none()); } #[test] @@ -351,39 +257,10 @@ mod tests { assert!(provider.id == "gmail"); } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_get_provider_info() { - let t = TestContext::new().await; - assert!(get_provider_info(&t, "", false).await.is_none()); - assert!(get_provider_info(&t, "google.com", false).await.unwrap().id == "gmail"); - assert!( - get_provider_info(&t, "example@google.com", false) - .await - .is_none() - ); - } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_provider_info_by_addr() -> Result<()> { - let t = TestContext::new().await; - assert!( - get_provider_info_by_addr(&t, "google.com", false) - .await - .is_err() - ); - assert!( - get_provider_info_by_addr(&t, "example@google.com", false) - .await? - .unwrap() - .id - == "gmail" - ); - Ok(()) - } - - #[test] - fn test_get_resolver() -> Result<()> { - assert!(get_resolver().is_ok()); + assert!(get_provider_info_by_addr("google.com").is_err()); + assert!(get_provider_info_by_addr("example@google.com")?.unwrap().id == "gmail"); Ok(()) } } diff --git a/src/provider/data.rs b/src/provider/data.rs index 9979be93dd..1e328b9ff8 100644 --- a/src/provider/data.rs +++ b/src/provider/data.rs @@ -13,8 +13,8 @@ use std::sync::LazyLock; // 163.md: 163.com static P_163: Provider = Provider { id: "163", - status: Status::Ok, - before_login_hint: "", + 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", 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 with Delta Chat, you need to set up an app password in the AOL web interface.", + before_login_hint: "To log in to AOL, 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 for Delta Chat, 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, 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 for Delta Chat before you can log in.", + before_login_hint: "You must create an app-specific password 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 mit Delta Chat zu benutzen, musst du erst auf der freenet.de-Webseite \"POP3/IMAP/SMTP\" aktivieren.", + before_login_hint: "Um deine freenet.de E-Mail-Adresse 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,10 +647,6 @@ static P_HERMES_RADIO: Provider = Provider { key: Config::MdnsEnabled, value: "0", }, - ConfigDefault { - key: Config::E2eeEnabled, - value: "0", - }, ConfigDefault { key: Config::ShowEmails, value: "2", @@ -663,7 +659,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 with Delta Chat to hey.com.", + before_login_hint: "hey.com does not offer the standard IMAP e-mail protocol, so you cannot log in to hey.com.", after_login_hint: "", overview_page: "https://providers.delta.chat/hey-com", server: &[], @@ -702,7 +698,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 for Delta Chat before login.", + before_login_hint: "You must create an app-specific password before login.", after_login_hint: "", overview_page: "https://providers.delta.chat/icloud", server: &[ @@ -787,7 +783,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 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.", + 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.", after_login_hint: "", overview_page: "https://providers.delta.chat/mail-com", server: &[], @@ -828,7 +824,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 Ρ€Π°Π±ΠΎΡ‚Π°Π» с Delta Chat.", + before_login_hint: "Π’Π°ΠΌ Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΠΎ ΡΠ³Π΅Π½Π΅Ρ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ \"ΠΏΠ°Ρ€ΠΎΠ»ΡŒ для внСшнСго прилоТСния\" Π² Π²Π΅Π±-интСрфСйсС mail.ru, Ρ‡Ρ‚ΠΎΠ±Ρ‹ mail.ru Ρ€Π°Π±ΠΎΡ‚Π°Π» с chatmail.", after_login_hint: "", overview_page: "https://providers.delta.chat/mail-ru", server: &[ @@ -1222,8 +1218,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::Ok, - before_login_hint: "", + status: Status::Broken, + before_login_hint: "Unfortunately, Outlook does not allow using passwords anymore, per-app-passwords are currently not working.", after_login_hint: "", overview_page: "https://providers.delta.chat/outlook-com", server: &[ @@ -1321,8 +1317,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 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.", + 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.", overview_page: "https://providers.delta.chat/protonmail", server: &[], opt: ProviderOptions::new(), @@ -1362,7 +1358,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 for Delta Chat are required.", + before_login_hint: "Manually enabling IMAP/SMTP and creating an app-specific password are required.", after_login_hint: "", overview_page: "https://providers.delta.chat/qq", server: &[ @@ -1390,7 +1386,7 @@ static P_QQ: Provider = Provider { static P_RAMBLER_RU: Provider = Provider { id: "rambler.ru", status: Status::Preparation, - before_login_hint: "Π§Ρ‚ΠΎΠ±Ρ‹ Π²ΠΎΠΉΡ‚ΠΈ Π² Π Π°ΠΌΠ±Π»Π΅Ρ€/ΠΏΠΎΡ‡Ρ‚Π° Ρ‡Π΅Ρ€Π΅Π· Delta Chat, Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΠΎ ΠΏΡ€Π΅Π΄Π²Π°Ρ€ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎ Π²ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ доступ с ΠΏΠΎΠΌΠΎΡ‰ΡŒΡŽ ΠΏΠΎΡ‡Ρ‚ΠΎΠ²Ρ‹Ρ… ΠΊΠ»ΠΈΠ΅Π½Ρ‚ΠΎΠ² Π½Π° сайтС mail.rambler.ru", + before_login_hint: "Π§Ρ‚ΠΎΠ±Ρ‹ Π²ΠΎΠΉΡ‚ΠΈ Π² Π Π°ΠΌΠ±Π»Π΅Ρ€/ΠΏΠΎΡ‡Ρ‚Π°, Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΠΎ ΠΏΡ€Π΅Π΄Π²Π°Ρ€ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎ Π²ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ доступ с ΠΏΠΎΠΌΠΎΡ‰ΡŒΡŽ ΠΏΠΎΡ‡Ρ‚ΠΎΠ²Ρ‹Ρ… ΠΊΠ»ΠΈΠ΅Π½Ρ‚ΠΎΠ² Π½Π° сайтС mail.rambler.ru", after_login_hint: "", overview_page: "https://providers.delta.chat/rambler-ru", server: &[ @@ -1566,7 +1562,7 @@ static P_SYSTEMLI_ORG: Provider = Provider { static P_T_ONLINE: Provider = Provider { id: "t-online", status: Status::Preparation, - before_login_hint: "To use Delta Chat with a T-Online email address, you need to create an app password in the web interface.", + before_login_hint: "To use 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: &[ @@ -1677,7 +1673,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 with Delta Chat to Tutanota.", + before_login_hint: "Tutanota does not offer the standard IMAP e-mail protocol, so you cannot log in to Tutanota.", after_login_hint: "", overview_page: "https://providers.delta.chat/tutanota", server: &[], @@ -1787,7 +1783,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 Ρ€Π°Π±ΠΎΡ‚Π°Π» с Delta Chat.", + before_login_hint: "Π’Π°ΠΌ Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΠΎ ΡΠ³Π΅Π½Π΅Ρ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ \"ΠΏΠ°Ρ€ΠΎΠ»ΡŒ для внСшнСго прилоТСния\" Π² Π²Π΅Π±-интСрфСйсС mail.ru https://account.mail.ru/user/2-step-auth/passwords/ Ρ‡Ρ‚ΠΎΠ±Ρ‹ vk.com Ρ€Π°Π±ΠΎΡ‚Π°Π» с chatmail.", after_login_hint: "", overview_page: "https://providers.delta.chat/vk-com", server: &[ @@ -1906,7 +1902,7 @@ static P_WKPB_DE: Provider = Provider { static P_YAHOO: Provider = Provider { id: "yahoo", status: Status::Preparation, - before_login_hint: "To use Delta Chat with your Yahoo email address you have to create an \"App-Password\" in the account security screen.", + before_login_hint: "To use 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: &[ @@ -2662,4 +2658,4 @@ pub(crate) static PROVIDER_IDS: LazyLock = - LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2024, 9, 13).unwrap()); + LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2025, 9, 4).unwrap()); diff --git a/src/push.rs b/src/push.rs index ffd31cd9cf..8b4bcc3ca5 100644 --- a/src/push.rs +++ b/src/push.rs @@ -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 { - let public_key = pgp::composed::SignedPublicKey::from_asc(NOTIFIERS_PUBLIC_KEY)?.0; + let public_key = pgp::composed::SignedPublicKey::from_asc(NOTIFIERS_PUBLIC_KEY)?; let encryption_subkey = public_key .public_subkeys .first() diff --git a/src/qr.rs b/src/qr.rs index 6453188033..aa2cfc19f1 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -16,18 +16,16 @@ use crate::contact::{Contact, ContactId, Origin}; use crate::context::Context; use crate::events::EventType; use crate::key::Fingerprint; -use crate::message::Message; use crate::net::http::post_empty; use crate::net::proxy::{DEFAULT_SOCKS_PORT, ProxyConfig}; use crate::token; -use crate::tools::validate_id; +use crate::tools::{time, validate_id}; const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; // yes: uppercase const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#"; const IDELTACHAT_NOSLASH_SCHEME: &str = "https://i.delta.chat#"; const DCACCOUNT_SCHEME: &str = "DCACCOUNT:"; pub(super) const DCLOGIN_SCHEME: &str = "DCLOGIN:"; -const DCWEBRTC_SCHEME: &str = "DCWEBRTC:"; const TG_SOCKS_SCHEME: &str = "https://t.me/socks"; const MAILTO_SCHEME: &str = "mailto:"; const MATMSG_SCHEME: &str = "MATMSG:"; @@ -122,15 +120,6 @@ pub enum Qr { /// The QR code is a backup, but it is too new. The user has to update its Delta Chat. BackupTooNew {}, - /// Ask the user if they want to use the given service for video chats. - WebrtcInstance { - /// Server domain name. - domain: String, - - /// URL pattern for video chat rooms. - instance_pattern: String, - }, - /// Ask the user if they want to use the given proxy. /// /// Note that HTTP(S) URLs without a path @@ -294,8 +283,6 @@ pub async fn check_qr(context: &Context, qr: &str) -> Result { decode_account(qr)? } else if starts_with_ignore_case(qr, DCLOGIN_SCHEME) { dclogin_scheme::decode_login(qr)? - } else if starts_with_ignore_case(qr, DCWEBRTC_SCHEME) { - decode_webrtc_instance(context, qr)? } else if starts_with_ignore_case(qr, TG_SOCKS_SCHEME) { decode_tg_socks_proxy(context, qr)? } else if qr.starts_with(SHADOWSOCKS_SCHEME) { @@ -421,7 +408,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+` match percent_decode_str(&encoded_name).decode_utf8() { Ok(name) => name.to_string(), - Err(err) => bail!("Invalid name: {}", err), + Err(err) => bail!("Invalid name: {err}"), } } else { "".to_string() @@ -445,7 +432,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+` match percent_decode_str(&encoded_name).decode_utf8() { Ok(name) => Some(name.to_string()), - Err(err) => bail!("Invalid group name: {}", err), + Err(err) => bail!("Invalid group name: {err}"), } } else { None @@ -573,28 +560,6 @@ fn decode_account(qr: &str) -> Result { } } -/// scheme: `DCWEBRTC:https://meet.jit.si/$ROOM` -fn decode_webrtc_instance(_context: &Context, qr: &str) -> Result { - let payload = qr - .get(DCWEBRTC_SCHEME.len()..) - .context("Invalid DCWEBRTC payload")?; - - let (_type, url) = Message::parse_webrtc_instance(payload); - let url = url::Url::parse(&url).context("Invalid WebRTC instance")?; - - if url.scheme() == "http" || url.scheme() == "https" { - Ok(Qr::WebrtcInstance { - domain: url - .host_str() - .context("can't extract WebRTC instance domain")? - .to_string(), - instance_pattern: payload.to_string(), - }) - } else { - bail!("Bad URL scheme for WebRTC instance: {:?}", url.scheme()); - } -} - /// scheme: `https://t.me/socks?server=foo&port=123` or `https://t.me/socks?server=1.2.3.4&port=123` fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result { let url = url::Url::parse(qr).context("Invalid t.me/socks url")?; @@ -616,7 +581,7 @@ fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result { } let Some(host) = host else { - bail!("Bad t.me/socks url: {:?}", url); + bail!("Bad t.me/socks url: {url:?}"); }; let mut url = "socks5://".to_string(); @@ -719,10 +684,7 @@ pub(crate) async fn set_account_from_qr(context: &Context, qr: &str) -> Result<( context.emit_event(EventType::Error(format!( "Cannot create account, server response could not be parsed:\n{parse_error:#}\nraw response:\n{response_text}" ))); - bail!( - "Cannot create account, unexpected server response:\n{:?}", - response_text - ) + bail!("Cannot create account, unexpected server response:\n{response_text:?}") } } } @@ -732,14 +694,6 @@ pub(crate) async fn set_account_from_qr(context: &Context, qr: &str) -> Result<( pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> { match check_qr(context, qr).await? { Qr::Account { .. } => set_account_from_qr(context, qr).await?, - Qr::WebrtcInstance { - domain: _, - instance_pattern, - } => { - context - .set_config_internal(Config::WebrtcInstance, Some(&instance_pattern)) - .await?; - } Qr::Proxy { url, .. } => { let old_proxy_url_value = context .get_config(Config::ProxyUrl) @@ -766,19 +720,18 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> { authcode, .. } => { - token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?; - token::delete(context, token::Namespace::Auth, &authcode).await?; + token::delete(context, "").await?; context .sync_qr_code_token_deletion(invitenumber, authcode) .await?; } Qr::WithdrawVerifyGroup { + grpid, invitenumber, authcode, .. } => { - token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?; - token::delete(context, token::Namespace::Auth, &authcode).await?; + token::delete(context, &grpid).await?; context .sync_qr_code_token_deletion(invitenumber, authcode) .await?; @@ -788,8 +741,16 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> { authcode, .. } => { - token::save(context, token::Namespace::InviteNumber, None, &invitenumber).await?; - token::save(context, token::Namespace::Auth, None, &authcode).await?; + let timestamp = time(); + token::save( + context, + token::Namespace::InviteNumber, + None, + &invitenumber, + timestamp, + ) + .await?; + token::save(context, token::Namespace::Auth, None, &authcode, timestamp).await?; context.sync_qr_code_tokens(None).await?; context.scheduler.interrupt_inbox().await; } @@ -799,14 +760,23 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> { grpid, .. } => { + let timestamp = time(); token::save( context, token::Namespace::InviteNumber, Some(&grpid), &invitenumber, + timestamp, + ) + .await?; + token::save( + context, + token::Namespace::Auth, + Some(&grpid), + &authcode, + timestamp, ) .await?; - token::save(context, token::Namespace::Auth, Some(&grpid), &authcode).await?; context.sync_qr_code_tokens(Some(&grpid)).await?; context.scheduler.interrupt_inbox().await; } diff --git a/src/qr/dclogin_scheme.rs b/src/qr/dclogin_scheme.rs index cce60f0252..cdbbac352e 100644 --- a/src/qr/dclogin_scheme.rs +++ b/src/qr/dclogin_scheme.rs @@ -122,7 +122,7 @@ pub(super) fn decode_login(qr: &str) -> Result { options, }) } else { - bail!("Bad scheme for account URL: {:?}.", payload); + bail!("Bad scheme for account URL: {payload:?}."); } } @@ -139,7 +139,7 @@ fn parse_socket_security(security: Option<&String>) -> Result> { Some("starttls") => Some(Socket::Starttls), Some("default") => Some(Socket::Automatic), Some("plain") => Some(Socket::Plain), - Some(other) => bail!("Unknown security level: {}", other), + Some(other) => bail!("Unknown security level: {other}"), None => None, }) } @@ -152,7 +152,7 @@ fn parse_certificate_checks( Some("1") => Some(EnteredCertificateChecks::Strict), Some("2") => Some(EnteredCertificateChecks::AcceptInvalidCertificates), Some("3") => Some(EnteredCertificateChecks::AcceptInvalidCertificates2), - Some(other) => bail!("Unknown certificatecheck level: {}", other), + Some(other) => bail!("Unknown certificatecheck level: {other}"), None => None, }) } diff --git a/src/qr/qr_tests.rs b/src/qr/qr_tests.rs index 01cb0edbec..41d55d4fea 100644 --- a/src/qr/qr_tests.rs +++ b/src/qr/qr_tests.rs @@ -1,8 +1,8 @@ use super::*; -use crate::chat::{ProtectionStatus, create_group_chat}; +use crate::chat::create_group; use crate::config::Config; use crate::securejoin::get_securejoin_qr; -use crate::test_utils::{TestContext, TestContextManager}; +use crate::test_utils::{TestContext, TestContextManager, sync}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_decode_http() -> Result<()> { @@ -479,7 +479,7 @@ async fn test_withdraw_verifycontact() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_withdraw_verifygroup() -> Result<()> { let alice = TestContext::new_alice().await; - let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&alice, "foo").await?; let qr = get_securejoin_qr(&alice, Some(chat_id)).await?; // scanning own verify-group code offers withdrawing @@ -509,6 +509,77 @@ 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(alice, "Group").await?; + let chat2_id = create_group(alice, "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; @@ -641,32 +712,6 @@ async fn test_decode_account() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_decode_webrtc_instance() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr(&ctx.ctx, "DCWEBRTC:basicwebrtc:https://basicurl.com/$ROOM").await?; - assert_eq!( - qr, - Qr::WebrtcInstance { - domain: "basicurl.com".to_string(), - instance_pattern: "basicwebrtc:https://basicurl.com/$ROOM".to_string() - } - ); - - // Test it again with mixcased "dcWebRTC:" uri scheme - let qr = check_qr(&ctx.ctx, "dcWebRTC:https://example.org/").await?; - assert_eq!( - qr, - Qr::WebrtcInstance { - domain: "example.org".to_string(), - instance_pattern: "https://example.org/".to_string() - } - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_decode_tg_socks_proxy() -> Result<()> { let t = TestContext::new().await; @@ -749,34 +794,6 @@ async fn test_decode_account_bad_scheme() { assert!(res.is_err()); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_set_webrtc_instance_config_from_qr() -> Result<()> { - let ctx = TestContext::new().await; - - assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none()); - - let res = set_config_from_qr(&ctx.ctx, "badqr:https://example.org/").await; - assert!(res.is_err()); - assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none()); - - let res = set_config_from_qr(&ctx.ctx, "dcwebrtc:https://example.org/").await; - assert!(res.is_ok()); - assert_eq!( - ctx.ctx.get_config(Config::WebrtcInstance).await?.unwrap(), - "https://example.org/" - ); - - let res = - set_config_from_qr(&ctx.ctx, "DCWEBRTC:basicwebrtc:https://foo.bar/?$ROOM&test").await; - assert!(res.is_ok()); - assert_eq!( - ctx.ctx.get_config(Config::WebrtcInstance).await?.unwrap(), - "basicwebrtc:https://foo.bar/?$ROOM&test" - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_set_proxy_config_from_qr() -> Result<()> { let t = TestContext::new().await; diff --git a/src/qr_code_generator.rs b/src/qr_code_generator.rs index d8d6d4327a..a8456e04fc 100644 --- a/src/qr_code_generator.rs +++ b/src/qr_code_generator.rs @@ -1,6 +1,6 @@ //! # QR code generation module. -use anyhow::Result; +use anyhow::{Result, bail}; use base64::Engine as _; use qrcodegen::{QrCode, QrCodeEcc}; @@ -108,8 +108,18 @@ async fn generate_join_group_qr_code(context: &Context, chat_id: ChatId) -> Resu None => None, }; + let qrcode_description = match chat.typ { + crate::constants::Chattype::Group => { + stock_str::secure_join_group_qr_description(context, &chat).await + } + crate::constants::Chattype::OutBroadcast => { + stock_str::secure_join_broadcast_qr_description(context, &chat).await + } + _ => bail!("Unexpected chat type {}", chat.typ), + }; + inner_generate_secure_join_qr_code( - &stock_str::secure_join_group_qr_description(context, &chat).await, + &qrcode_description, &securejoin::get_securejoin_qr(context, Some(chat_id)).await?, &color_int_to_hex_string(chat.get_color(context).await?), avatar, diff --git a/src/reaction.rs b/src/reaction.rs index 685f811c95..6b90e0947f 100644 --- a/src/reaction.rs +++ b/src/reaction.rs @@ -404,7 +404,7 @@ mod tests { use crate::config::Config; use crate::contact::{Contact, Origin}; use crate::download::DownloadState; - use crate::message::{MessageState, delete_msgs}; + use crate::message::{MessageState, Viewtype, delete_msgs}; use crate::receive_imf::{receive_imf, receive_imf_from_inbox}; use crate::sql::housekeeping; use crate::test_utils::E2EE_INFO_MSGS; @@ -550,6 +550,46 @@ Here's my footer -- bob@example.net" let reactions = get_msg_reactions(&alice, msg.id).await?; assert_eq!(reactions.to_string(), "πŸ˜€1"); + // Alice receives a message with reaction to her message from Bob. + let msg_bob = receive_imf( + &alice, + "To: alice@example.org\n\ +From: bob@example.net\n\ +Date: Today, 29 February 2021 00:00:10 -800\n\ +Message-ID: 56791@example.net\n\ +In-Reply-To: 12345@example.org\n\ +Mime-Version: 1.0\n\ +Content-Type: multipart/mixed; boundary=\"YiEDa0DAkWCtVeE4\"\n\ +Content-Disposition: inline\n\ +\n\ +--YiEDa0DAkWCtVeE4\n\ +Content-Type: text/plain; charset=utf-8\n\ +Content-Disposition: inline\n\ +\n\ +Reply + reaction\n\ +\n\ +--YiEDa0DAkWCtVeE4\n\ +Content-Type: text/plain; charset=utf-8\n\ +Content-Disposition: reaction\n\ +\n\ +\u{1F44D}\n\ +\n\ +--YiEDa0DAkWCtVeE4--" + .as_bytes(), + false, + ) + .await? + .unwrap(); + let msg_bob = Message::load_from_db(&alice, msg_bob.msg_ids[0]).await?; + assert_eq!(msg_bob.from_id, bob_id); + assert_eq!(msg_bob.chat_id, msg.chat_id); + assert_eq!(msg_bob.viewtype, Viewtype::Text); + assert_eq!(msg_bob.state, MessageState::InFresh); + assert_eq!(msg_bob.hidden, false); + assert_eq!(msg_bob.text, "Reply + reaction"); + let reactions = get_msg_reactions(&alice, msg.id).await?; + assert_eq!(reactions.to_string(), "πŸ‘1"); + Ok(()) } diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 185a2626d0..3b7182034a 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -1,6 +1,6 @@ //! Internet Message Format reception pipeline. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashSet}; use std::iter; use std::sync::LazyLock; @@ -14,9 +14,7 @@ use mailparse::SingleInfo; use num_traits::FromPrimitive; use regex::Regex; -use crate::chat::{ - self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, remove_from_chat_contacts_table, -}; +use crate::chat::{self, Chat, ChatId, ChatIdBlocked, remove_from_chat_contacts_table}; use crate::config::Config; use crate::constants::{self, Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX, ShowEmails}; use crate::contact::{Contact, ContactId, Origin, mark_contact_id_as_verified}; @@ -28,14 +26,14 @@ use crate::events::EventType; use crate::headerdef::{HeaderDef, HeaderDefMap}; use crate::imap::{GENERATED_PREFIX, markseen_on_imap_table}; use crate::key::self_fingerprint_opt; -use crate::key::{DcKey, Fingerprint, SignedPublicKey}; +use crate::key::{DcKey, Fingerprint}; use crate::log::LogExt; use crate::log::{info, warn}; use crate::logged_debug_assert; use crate::message::{ self, Message, MessageState, MessengerMessage, MsgId, Viewtype, rfc724_mid_exists, }; -use crate::mimeparser::{AvatarAction, MimeMessage, SystemMessage, parse_message_ids}; +use crate::mimeparser::{AvatarAction, GossipedKey, MimeMessage, SystemMessage, parse_message_ids}; use crate::param::{Param, Params}; use crate::peer_channels::{add_gossip_peer_from_header, insert_topic_stub}; use crate::reaction::{Reaction, set_msg_reaction}; @@ -196,7 +194,7 @@ pub(crate) async fn receive_imf_from_inbox( rfc724_mid, imf_raw, seen, - is_partial_download, + is_partial_download.map(|msg_size| (msg_size, None)), ) .await } @@ -248,9 +246,7 @@ async fn get_to_and_past_contact_ids( let chat_id = match chat_assignment { ChatAssignment::Trash => None, ChatAssignment::GroupChat { grpid } => { - if let Some((chat_id, _protected, _blocked)) = - chat::get_chat_id_by_grpid(context, grpid).await? - { + if let Some((chat_id, _blocked)) = chat::get_chat_id_by_grpid(context, grpid).await? { Some(chat_id) } else { None @@ -494,9 +490,8 @@ async fn get_to_and_past_contact_ids( /// If the message is so wrong that we didn't even create a database entry, /// returns `Ok(None)`. /// -/// If `is_partial_download` is set, it contains the full message size in bytes. -/// Do not confuse that with `replace_msg_id` that will be set when the full message is loaded -/// later. +/// If `partial` is set, it contains the full message size in bytes and an optional error text for +/// the partially downloaded message. #[expect(clippy::too_many_arguments)] pub(crate) async fn receive_imf_inner( context: &Context, @@ -506,7 +501,7 @@ pub(crate) async fn receive_imf_inner( rfc724_mid: &str, imf_raw: &[u8], seen: bool, - is_partial_download: Option, + partial: Option<(u32, Option)>, ) -> Result> { if std::env::var(crate::DCC_MIME_DEBUG).is_ok() { info!( @@ -515,9 +510,16 @@ pub(crate) async fn receive_imf_inner( String::from_utf8_lossy(imf_raw), ); } + if partial.is_none() { + ensure!( + !context + .get_config_bool(Config::FailOnReceivingFullMsg) + .await? + ); + } - let mut mime_parser = match MimeMessage::from_bytes(context, imf_raw, is_partial_download).await - { + let is_partial_download = partial.as_ref().map(|(msg_size, _err)| *msg_size); + let mut mime_parser = match MimeMessage::from_bytes(context, imf_raw, partial).await { Err(err) => { warn!(context, "receive_imf: can't parse MIME: {err:#}."); if rfc724_mid.starts_with(GENERATED_PREFIX) { @@ -551,24 +553,17 @@ pub(crate) async fn receive_imf_inner( // make sure, this check is done eg. before securejoin-processing. let (replace_msg_id, replace_chat_id); if let Some((old_msg_id, _)) = message::rfc724_mid_exists(context, rfc724_mid).await? { - if is_partial_download.is_some() { - // Should never happen, see imap::prefetch_should_download(), but still. - info!( - context, - "Got a partial download and message is already in DB." - ); - return Ok(None); - } - let msg = Message::load_from_db(context, old_msg_id).await?; replace_msg_id = Some(old_msg_id); - replace_chat_id = if msg.download_state() != DownloadState::Done { + replace_chat_id = if let Some(msg) = Message::load_from_db_optional(context, old_msg_id) + .await? + .filter(|msg| msg.download_state() != DownloadState::Done) + { // the message was partially downloaded before and is fully downloaded now. - info!( - context, - "Message already partly in DB, replacing by full message." - ); + info!(context, "Message already partly in DB, replacing."); Some(msg.chat_id) } else { + // The message was already fully downloaded + // or cannot be loaded because it is deleted. None }; } else { @@ -643,7 +638,7 @@ pub(crate) async fn receive_imf_inner( // For example, GitHub sends messages from `notifications@github.com`, // but uses display name of the user whose action generated the notification // as the display name. - let fingerprint = mime_parser.signatures.iter().next(); + let fingerprint = mime_parser.signature.as_ref(); let (from_id, _from_id_blocked, incoming_origin) = match from_field_to_contact_id( context, &mime_parser.from, @@ -743,7 +738,7 @@ pub(crate) async fn receive_imf_inner( let verified_encryption = has_verified_encryption(context, &mime_parser, from_id).await?; if verified_encryption == VerifiedEncryption::Verified { - mark_recipients_as_verified(context, from_id, &to_ids, &mime_parser).await?; + mark_recipients_as_verified(context, from_id, &mime_parser).await?; } let received_msg = if let Some(received_msg) = received_msg { @@ -763,7 +758,6 @@ pub(crate) async fn receive_imf_inner( let show_emails = ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?) .unwrap_or_default(); - let is_reaction = mime_parser.parts.iter().any(|part| part.is_reaction); let allow_creation = if mime_parser.decrypting_failed { false } else if mime_parser.is_system_message != SystemMessage::AutocryptSetupMessage @@ -777,7 +771,7 @@ pub(crate) async fn receive_imf_inner( ShowEmails::All => true, } } else { - !is_reaction + !mime_parser.parts.iter().all(|part| part.is_reaction) }; let to_id = if mime_parser.incoming { @@ -796,7 +790,6 @@ pub(crate) async fn receive_imf_inner( allow_creation, &mut mime_parser, is_partial_download, - &verified_encryption, parent_message, ) .await?; @@ -814,7 +807,6 @@ pub(crate) async fn receive_imf_inner( is_partial_download, replace_msg_id, prevent_rename, - verified_encryption, chat_id, chat_id_blocked, is_dc_message, @@ -836,7 +828,7 @@ pub(crate) async fn receive_imf_inner( context .sql .transaction(move |transaction| { - let fingerprint = gossiped_key.dc_fingerprint().hex(); + let fingerprint = gossiped_key.public_key.dc_fingerprint().hex(); transaction.execute( "INSERT INTO gossip_timestamp (chat_id, fingerprint, timestamp) VALUES (?, ?, ?) @@ -862,7 +854,9 @@ pub(crate) async fn receive_imf_inner( if let Some(ref sync_items) = mime_parser.sync_items { if from_id == ContactId::SELF { if mime_parser.was_encrypted() { - context.execute_sync_items(sync_items).await; + context + .execute_sync_items(sync_items, mime_parser.timestamp_sent) + .await; } else { warn!(context, "Sync items are not encrypted."); } @@ -1001,12 +995,21 @@ pub(crate) async fn receive_imf_inner( } } - if received_msg.hidden { + if is_partial_download.is_none() && mime_parser.is_call() { + context + .handle_call_msg(insert_msg_id, &mime_parser, from_id) + .await?; + } else if received_msg.hidden { // No need to emit an event about the changed message } else if let Some(replace_chat_id) = replace_chat_id { - context.emit_msgs_changed_without_msg_id(replace_chat_id); + match replace_chat_id == chat_id { + false => context.emit_msgs_changed_without_msg_id(replace_chat_id), + true => context.emit_msgs_changed(chat_id, replace_msg_id.unwrap_or_default()), + } } else if !chat_id.is_trash() { - let fresh = received_msg.state == MessageState::InFresh; + let fresh = received_msg.state == MessageState::InFresh + && mime_parser.is_system_message != SystemMessage::CallAccepted + && mime_parser.is_system_message != SystemMessage::CallEnded; for msg_id in &received_msg.msg_ids { chat_id.emit_msg_event(context, *msg_id, mime_parser.incoming && fresh); } @@ -1150,6 +1153,12 @@ async fn decide_chat_assignment( { info!(context, "Chat edit/delete/iroh/sync message (TRASH)."); true + } else if is_partial_download.is_none() + && (mime_parser.is_system_message == SystemMessage::CallAccepted + || mime_parser.is_system_message == SystemMessage::CallEnded) + { + info!(context, "Call state changed (TRASH)."); + true } else if mime_parser.decrypting_failed && !mime_parser.incoming { // Outgoing undecryptable message. let last_time = context @@ -1218,17 +1227,21 @@ async fn decide_chat_assignment( // // The chat may not exist yet, i.e. there may be // no database row and ChatId yet. - let mut num_recipients = mime_parser.recipients.len(); - if from_id != ContactId::SELF { - let mut has_self_addr = false; - for recipient in &mime_parser.recipients { - if context.is_self_addr(&recipient.addr).await? { - has_self_addr = true; - } + let mut num_recipients = 0; + let mut has_self_addr = false; + for recipient in &mime_parser.recipients { + if addr_cmp(&recipient.addr, &mime_parser.from.addr) { + continue; } - if !has_self_addr { - num_recipients += 1; + + if context.is_self_addr(&recipient.addr).await? { + has_self_addr = true; } + + num_recipients += 1; + } + if from_id != ContactId::SELF && !has_self_addr { + num_recipients += 1; } let chat_assignment = if should_trash { @@ -1302,7 +1315,6 @@ async fn do_chat_assignment( allow_creation: bool, mime_parser: &mut MimeMessage, is_partial_download: Option, - verified_encryption: &VerifiedEncryption, parent_message: Option, ) -> Result<(ChatId, Blocked)> { let is_bot = context.get_config_bool(Config::Bot).await?; @@ -1345,9 +1357,7 @@ async fn do_chat_assignment( } ChatAssignment::GroupChat { grpid } => { // Try to assign to a chat based on Chat-Group-ID. - if let Some((id, _protected, blocked)) = - chat::get_chat_id_by_grpid(context, grpid).await? - { + if let Some((id, blocked)) = chat::get_chat_id_by_grpid(context, grpid).await? { chat_id = Some(id); chat_id_blocked = blocked; } else if allow_creation || test_normal_chat.is_some() { @@ -1359,7 +1369,6 @@ async fn do_chat_assignment( from_id, to_ids, past_ids, - verified_encryption, grpid, ) .await? @@ -1461,45 +1470,6 @@ async fn do_chat_assignment( ); } } - - // Check if the message was sent with verified encryption and set the protection of - // the 1:1 chat accordingly. - let chat = match is_partial_download.is_none() - && mime_parser.get_header(HeaderDef::SecureJoin).is_none() - { - true => Some(Chat::load_from_db(context, chat_id).await?) - .filter(|chat| chat.typ == Chattype::Single), - false => None, - }; - if let Some(chat) = chat { - ensure_and_debug_assert!( - chat.typ == Chattype::Single, - "Chat {chat_id} is not Single", - ); - let new_protection = match verified_encryption { - VerifiedEncryption::Verified => ProtectionStatus::Protected, - VerifiedEncryption::NotVerified(_) => ProtectionStatus::Unprotected, - }; - - ensure_and_debug_assert!( - chat.protected == ProtectionStatus::Unprotected - || new_protection == ProtectionStatus::Protected, - "Chat {chat_id} can't downgrade to Unprotected", - ); - if chat.protected != new_protection { - // The message itself will be sorted under the device message since the device - // message is `MessageState::InNoticed`, which means that all following - // messages are sorted under it. - chat_id - .set_protection( - context, - new_protection, - mime_parser.timestamp_sent, - Some(from_id), - ) - .await?; - } - } } } } else { @@ -1516,9 +1486,7 @@ async fn do_chat_assignment( chat_id = Some(DC_CHAT_ID_TRASH); } ChatAssignment::GroupChat { grpid } => { - if let Some((id, _protected, blocked)) = - chat::get_chat_id_by_grpid(context, grpid).await? - { + if let Some((id, blocked)) = chat::get_chat_id_by_grpid(context, grpid).await? { chat_id = Some(id); chat_id_blocked = blocked; } else if allow_creation { @@ -1530,7 +1498,6 @@ async fn do_chat_assignment( from_id, to_ids, past_ids, - verified_encryption, grpid, ) .await? @@ -1586,7 +1553,7 @@ async fn do_chat_assignment( if chat_id.is_none() && allow_creation { let to_contact = Contact::get_by_id(context, to_id).await?; if let Some(list_id) = to_contact.param.get(Param::ListId) { - if let Some((id, _, blocked)) = + if let Some((id, blocked)) = chat::get_chat_id_by_grpid(context, list_id).await? { chat_id = Some(id); @@ -1652,7 +1619,6 @@ async fn add_parts( is_partial_download: Option, mut replace_msg_id: Option, prevent_rename: bool, - verified_encryption: VerifiedEncryption, chat_id: ChatId, chat_id_blocked: Blocked, is_dc_message: MessengerMessage, @@ -1686,12 +1652,6 @@ async fn add_parts( let name: &str = from.display_name.as_ref().unwrap_or(&from.addr); for part in &mut mime_parser.parts { part.param.set(Param::OverrideSenderDisplayname, name); - - if chat.is_protected() { - // In protected chat, also mark the message with an error. - let s = stock_str::unknown_sender_for_chat(context).await; - part.error = Some(s); - } } } } @@ -1707,16 +1667,7 @@ async fn add_parts( apply_out_broadcast_changes(context, mime_parser, &mut chat, from_id).await? } Chattype::Group => { - apply_group_changes( - context, - mime_parser, - &mut chat, - from_id, - to_ids, - past_ids, - &verified_encryption, - ) - .await? + apply_group_changes(context, mime_parser, &mut chat, from_id, to_ids, past_ids).await? } Chattype::InBroadcast => { apply_in_broadcast_changes(context, mime_parser, &mut chat, from_id).await? @@ -1863,25 +1814,6 @@ async fn add_parts( None }; - let mut verification_failed = false; - if !chat_id.is_special() && is_partial_download.is_none() { - // For outgoing emails in the 1:1 chat we have an exception that - // they are allowed to be unencrypted: - // 1. They can't be an attack (they are outgoing, not incoming) - // 2. Probably the unencryptedness is just a temporary state, after all - // the user obviously still uses DC - // -> Showing info messages every time would be a lot of noise - // 3. The info messages that are shown to the user ("Your chat partner - // likely reinstalled DC" or similar) would be wrong. - if chat.is_protected() && (mime_parser.incoming || chat.typ != Chattype::Single) { - if let VerifiedEncryption::NotVerified(err) = verified_encryption { - verification_failed = true; - warn!(context, "Verification problem: {err:#}."); - let s = format!("{err}. Re-download the message or see 'Info' for more details"); - mime_parser.replace_msg_by_error(&s); - } - } - } drop(chat); // Avoid using stale `chat` object. let sort_timestamp = tweak_sort_timestamp( @@ -1995,10 +1927,29 @@ async fn add_parts( handle_edit_delete(context, mime_parser, from_id).await?; - let is_reaction = mime_parser.parts.iter().any(|part| part.is_reaction); - let hidden = is_reaction; + if is_partial_download.is_none() + && (mime_parser.is_system_message == SystemMessage::CallAccepted + || mime_parser.is_system_message == SystemMessage::CallEnded) + { + if let Some(field) = mime_parser.get_header(HeaderDef::InReplyTo) { + if let Some(call) = + message::get_by_rfc724_mids(context, &parse_message_ids(field)).await? + { + context + .handle_call_msg(call.get_id(), mime_parser, from_id) + .await?; + } else { + warn!(context, "Call: Cannot load parent.") + } + } else { + warn!(context, "Call: Not a reply.") + } + } + + let hidden = mime_parser.parts.iter().all(|part| part.is_reaction); let mut parts = mime_parser.parts.iter().peekable(); while let Some(part) = parts.next() { + let hidden = part.is_reaction; if part.is_reaction { let reaction_str = simplify::remove_footers(part.msg.as_str()); let is_incoming_fresh = mime_parser.incoming && !seen; @@ -2139,10 +2090,6 @@ RETURNING id DownloadState::Available } else if mime_parser.decrypting_failed { DownloadState::Undecipherable - } else if verification_failed { - // Verification can fail because of message reordering. Re-downloading the - // message should help if so. - DownloadState::Available } else { DownloadState::Done }, @@ -2217,6 +2164,7 @@ RETURNING id if !chat_id.is_trash() && !hidden { let mut chat = Chat::load_from_db(context, chat_id).await?; + let mut update_param = false; // In contrast to most other update-timestamps, // use `sort_timestamp` instead of `sent_timestamp` for the subject-timestamp comparison. @@ -2230,6 +2178,14 @@ RETURNING id let subject = mime_parser.get_subject().unwrap_or_default(); chat.param.set(Param::LastSubject, subject); + update_param = true; + } + + if chat.is_unpromoted() { + chat.param.remove(Param::Unpromoted); + update_param = true; + } + if update_param { chat.update_param(context).await?; } } @@ -2625,27 +2581,12 @@ async fn create_group( from_id: ContactId, to_ids: &[Option], past_ids: &[Option], - verified_encryption: &VerifiedEncryption, grpid: &str, ) -> Result> { let to_ids_flat: Vec = to_ids.iter().filter_map(|x| *x).collect(); let mut chat_id = None; let mut chat_id_blocked = Default::default(); - let create_protected = if mime_parser.get_header(HeaderDef::ChatVerified).is_some() { - if let VerifiedEncryption::NotVerified(err) = verified_encryption { - warn!( - context, - "Creating unprotected group because of the verification problem: {err:#}." - ); - ProtectionStatus::Unprotected - } else { - ProtectionStatus::Protected - } - } else { - ProtectionStatus::Unprotected - }; - async fn self_explicitly_added( context: &Context, mime_parser: &&mut MimeMessage, @@ -2681,7 +2622,6 @@ async fn create_group( grpid, grpname, create_blocked, - create_protected, None, mime_parser.timestamp_sent, ) @@ -2853,7 +2793,6 @@ async fn apply_group_changes( from_id: ContactId, to_ids: &[Option], past_ids: &[Option], - verified_encryption: &VerifiedEncryption, ) -> Result { let to_ids_flat: Vec = to_ids.iter().filter_map(|x| *x).collect(); ensure!(chat.typ == Chattype::Group); @@ -2863,38 +2802,11 @@ async fn apply_group_changes( let (mut removed_id, mut added_id) = (None, None); let mut better_msg = None; let mut silent = false; - - // True if a Delta Chat client has explicitly added our current primary address. - let self_added = - if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) { - addr_cmp(&context.get_primary_self_addr().await?, added_addr) - } else { - false - }; - let chat_contacts = HashSet::::from_iter(chat::get_chat_contacts(context, chat.id).await?); let is_from_in_chat = !chat_contacts.contains(&ContactId::SELF) || chat_contacts.contains(&from_id); - if mime_parser.get_header(HeaderDef::ChatVerified).is_some() && !chat.is_protected() { - if let VerifiedEncryption::NotVerified(err) = verified_encryption { - warn!( - context, - "Not marking chat {} as protected due to verification problem: {err:#}.", chat.id, - ); - } else { - chat.id - .set_protection( - context, - ProtectionStatus::Protected, - mime_parser.timestamp_sent, - Some(from_id), - ) - .await?; - } - } - if let Some(removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) { // TODO: if address "alice@example.org" is a member of the group twice, // with old and new key, @@ -2904,8 +2816,12 @@ async fn apply_group_changes( // rather than old display name. // This could be fixed by looking up the contact with the highest // `remove_timestamp` after applying Chat-Group-Member-Timestamps. - removed_id = lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await?; - if let Some(id) = removed_id { + if !is_from_in_chat { + better_msg = Some(String::new()); + } else if let Some(id) = + lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await? + { + removed_id = Some(id); better_msg = if id == from_id { silent = true; Some(stock_str::msg_group_left_local(context, from_id).await) @@ -2916,14 +2832,16 @@ async fn apply_group_changes( warn!(context, "Removed {removed_addr:?} has no contact id.") } } else if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) { - if let Some(key) = mime_parser.gossiped_keys.get(added_addr) { + if !is_from_in_chat { + better_msg = Some(String::new()); + } else if let Some(key) = mime_parser.gossiped_keys.get(added_addr) { // TODO: if gossiped keys contain the same address multiple times, // we may lookup the wrong contact. // This could be fixed by looking up the contact with // highest `add_timestamp` to disambiguate. // The result of the error is that info message // may contain display name of the wrong contact. - let fingerprint = key.dc_fingerprint().hex(); + let fingerprint = key.public_key.dc_fingerprint().hex(); if let Some(contact_id) = lookup_key_contact_by_fingerprint(context, &fingerprint).await? { @@ -2995,6 +2913,15 @@ async fn apply_group_changes( .await?; } else { let mut new_members: HashSet; + // True if a Delta Chat client has explicitly and really added our primary address to an + // already existing group. + let self_added = + if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) { + addr_cmp(&context.get_primary_self_addr().await?, added_addr) + && !chat_contacts.contains(&ContactId::SELF) + } else { + false + }; if self_added { new_members = HashSet::from_iter(to_ids_flat.iter().copied()); new_members.insert(ContactId::SELF); @@ -3061,17 +2988,18 @@ async fn apply_group_changes( .collect(); if let Some(added_id) = added_id { - if !added_ids.remove(&added_id) && !self_added { - // No-op "Member added" message. - // - // Trash it. + if !added_ids.remove(&added_id) && added_id != ContactId::SELF { + // No-op "Member added" message. An exception is self-addition messages because they at + // least must be shown when a chat is created on our side. better_msg = Some(String::new()); } } if let Some(removed_id) = removed_id { removed_ids.remove(&removed_id); } - let group_changes_msgs = if self_added { + let group_changes_msgs = if !chat_contacts.contains(&ContactId::SELF) + && new_chat_contacts.contains(&ContactId::SELF) + { Vec::new() } else { group_changes_msgs(context, &added_ids, &removed_ids, chat.id).await? @@ -3266,7 +3194,7 @@ async fn create_or_lookup_mailinglist_or_broadcast( ) -> Result> { let listid = mailinglist_header_listid(list_id_header)?; - if let Some((chat_id, _, blocked)) = chat::get_chat_id_by_grpid(context, &listid).await? { + if let Some((chat_id, blocked)) = chat::get_chat_id_by_grpid(context, &listid).await? { return Ok(Some((chat_id, blocked))); } @@ -3304,7 +3232,6 @@ async fn create_or_lookup_mailinglist_or_broadcast( &listid, name, blocked, - ProtectionStatus::Unprotected, param, mime_parser.timestamp_sent, ) @@ -3525,8 +3452,7 @@ async fn apply_in_broadcast_changes( // The only member added/removed message that is ever sent is "I left.", // so, this is the only case we need to handle here if from_id == ContactId::SELF { - better_msg - .get_or_insert(stock_str::msg_group_left_local(context, ContactId::SELF).await); + better_msg.get_or_insert(stock_str::msg_you_left_broadcast(context).await); } } @@ -3586,7 +3512,6 @@ async fn create_adhoc_group( "", // Ad hoc groups have no ID. grpname, create_blocked, - ProtectionStatus::Unprotected, None, mime_parser.timestamp_sent, ) @@ -3649,7 +3574,10 @@ async fn has_verified_encryption( )); } - let signed_with_verified_key = mimeparser.signatures.contains(&fingerprint); + let signed_with_verified_key = mimeparser + .signature + .as_ref() + .is_some_and(|signature| *signature == fingerprint); if signed_with_verified_key { Ok(Verified) } else { @@ -3662,19 +3590,24 @@ async fn has_verified_encryption( async fn mark_recipients_as_verified( context: &Context, from_id: ContactId, - to_ids: &[Option], mimeparser: &MimeMessage, ) -> Result<()> { - if mimeparser.get_header(HeaderDef::ChatVerified).is_none() { - return Ok(()); - } - for to_id in to_ids.iter().filter_map(|&x| x) { + let verifier_id = Some(from_id).filter(|&id| id != ContactId::SELF); + for gossiped_key in mimeparser + .gossiped_keys + .values() + .filter(|gossiped_key| gossiped_key.verified) + { + let fingerprint = gossiped_key.public_key.dc_fingerprint().hex(); + let Some(to_id) = lookup_key_contact_by_fingerprint(context, &fingerprint).await? else { + continue; + }; + if to_id == ContactId::SELF || to_id == from_id { continue; } - mark_contact_id_as_verified(context, to_id, from_id).await?; - ChatId::set_protection_for_contact(context, to_id, mimeparser.timestamp_sent).await?; + mark_contact_id_as_verified(context, to_id, verifier_id).await?; } Ok(()) @@ -3760,7 +3693,7 @@ async fn add_or_lookup_contacts_by_address_list( async fn add_or_lookup_key_contacts( context: &Context, address_list: &[SingleInfo], - gossiped_keys: &HashMap, + gossiped_keys: &BTreeMap, fingerprints: &[Fingerprint], origin: Origin, ) -> Result>> { @@ -3776,7 +3709,7 @@ async fn add_or_lookup_key_contacts( // Iterator has not ran out of fingerprints yet. fp.hex() } else if let Some(key) = gossiped_keys.get(addr) { - key.dc_fingerprint().hex() + key.public_key.dc_fingerprint().hex() } else if context.is_self_addr(addr).await? { contact_ids.push(Some(ContactId::SELF)); continue; diff --git a/src/receive_imf/receive_imf_tests.rs b/src/receive_imf/receive_imf_tests.rs index d6ffc48f04..9bb38e83b2 100644 --- a/src/receive_imf/receive_imf_tests.rs +++ b/src/receive_imf/receive_imf_tests.rs @@ -5,7 +5,7 @@ use tokio::fs; use super::*; use crate::chat::{ - ChatItem, ChatVisibility, add_contact_to_chat, add_to_chat_contacts_table, create_group_chat, + ChatItem, ChatVisibility, add_contact_to_chat, add_to_chat_contacts_table, create_group, get_chat_contacts, get_chat_msgs, is_contact_in_chat, remove_contact_from_chat, send_text_msg, }; use crate::chatlist::Chatlist; @@ -1000,7 +1000,7 @@ async fn test_other_device_writes_to_mailinglist() -> Result<()> { chat::get_chat_id_by_grpid(&t, "delta.codespeak.net") .await? .unwrap(), - (first_chat.id, false, Blocked::Request) + (first_chat.id, Blocked::Request) ); receive_imf( @@ -2901,9 +2901,9 @@ async fn test_accept_outgoing() -> Result<()> { let bob1_chat = bob1.create_chat(&alice1).await; let sent = bob1.send_text(bob1_chat.id, "Hello!").await; - alice1.recv_msg(&sent).await; + let alice1_msg = alice1.recv_msg(&sent).await; alice2.recv_msg(&sent).await; - let alice1_msg = bob2.recv_msg(&sent).await; + bob2.recv_msg(&sent).await; assert_eq!(alice1_msg.text, "Hello!"); let alice1_chat = chat::Chat::load_from_db(&alice1, alice1_msg.chat_id).await?; assert!(alice1_chat.is_contact_request()); @@ -2944,7 +2944,7 @@ async fn test_outgoing_private_reply_multidevice() -> Result<()> { let charlie = tcm.charlie().await; // =============== Bob creates a group =============== - let group_id = chat::create_group_chat(&bob, ProtectionStatus::Unprotected, "Group").await?; + let group_id = chat::create_group(&bob, "Group").await?; chat::add_to_chat_contacts_table( &bob, time(), @@ -3042,9 +3042,7 @@ async fn test_auto_accept_protected_group_for_bots() -> Result<()> { bob.set_config(Config::Bot, Some("1")).await.unwrap(); mark_as_verified(alice, bob).await; mark_as_verified(bob, alice).await; - let group_id = alice - .create_group_with_members(ProtectionStatus::Protected, "Group", &[bob]) - .await; + let group_id = alice.create_group_with_members("Group", &[bob]).await; let sent = alice.send_text(group_id, "Hello!").await; let msg = bob.recv_msg(&sent).await; let chat = chat::Chat::load_from_db(bob, msg.chat_id).await?; @@ -3092,13 +3090,11 @@ async fn test_bot_accepts_another_group_after_qr_scan() -> Result<()> { let bob = &tcm.bob().await; bob.set_config(Config::Bot, Some("1")).await?; - let group_id = chat::create_group_chat(alice, ProtectionStatus::Protected, "Group").await?; + let group_id = chat::create_group(alice, "Group").await?; let qr = get_securejoin_qr(alice, Some(group_id)).await?; tcm.exec_securejoin_qr(bob, alice, &qr).await; - let group_id = alice - .create_group_with_members(ProtectionStatus::Protected, "Group", &[bob]) - .await; + let group_id = alice.create_group_with_members("Group", &[bob]).await; let sent = alice.send_text(group_id, "Hello!").await; let msg = bob.recv_msg(&sent).await; let chat = chat::Chat::load_from_db(bob, msg.chat_id).await?; @@ -3152,7 +3148,7 @@ async fn test_no_private_reply_to_blocked_account() -> Result<()> { let bob = tcm.bob().await; tcm.section("Bob creates a group"); - let group_id = chat::create_group_chat(&bob, ProtectionStatus::Unprotected, "Group").await?; + let group_id = chat::create_group(&bob, "Group").await?; chat::add_to_chat_contacts_table( &bob, time(), @@ -3228,11 +3224,7 @@ async fn test_blocked_contact_creates_group() -> Result<()> { chat.id.block(&alice).await?; let group_id = bob - .create_group_with_members( - ProtectionStatus::Unprotected, - "group name", - &[&alice, &fiona], - ) + .create_group_with_members("group name", &[&alice, &fiona]) .await; let sent = bob.send_text(group_id, "Heyho, I'm a spammer!").await; @@ -3253,7 +3245,7 @@ async fn test_blocked_contact_creates_group() -> Result<()> { assert_eq!(rcvd.chat_blocked, Blocked::Request); // In order not to lose context, Bob's message should also be shown in the group let msgs = chat::get_chat_msgs(&alice, rcvd.chat_id).await?; - assert_eq!(msgs.len(), 2); + assert_eq!(msgs.len(), 3); Ok(()) } @@ -3316,6 +3308,31 @@ async fn test_thunderbird_autocrypt() -> Result<()> { Ok(()) } +/// Tests that a message without an Autocrypt header is assigned to the key-contact +/// by using the signature Issuer Fingerprint. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_issuer_fingerprint() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let alice_contact_id = bob.add_or_lookup_contact_id(alice).await; + + let raw = include_bytes!("../../test-data/message/encrypted-signed.eml"); + let received_msg = receive_imf(bob, raw, false).await?.unwrap(); + + assert_eq!(received_msg.msg_ids.len(), 1); + let msg_id = received_msg.msg_ids[0]; + + let message = Message::load_from_db(bob, msg_id).await?; + assert!(message.get_showpadlock()); + + let from_id = message.from_id; + assert_eq!(from_id, alice_contact_id); + + Ok(()) +} + /// Tests reception of a message from Thunderbird with attached key. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_prefer_encrypt_mutual_if_encrypted() -> Result<()> { @@ -3703,7 +3720,7 @@ async fn test_unsigned_chat_group_hdr() -> Result<()> { let bob = &tcm.bob().await; let bob_addr = bob.get_config(Config::Addr).await?.unwrap(); let bob_id = alice.add_or_lookup_contact_id(bob).await; - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "foos").await?; + let alice_chat_id = create_group(alice, "foos").await?; add_contact_to_chat(alice, alice_chat_id, bob_id).await?; send_text_msg(alice, alice_chat_id, "populate".to_string()).await?; let sent_msg = alice.pop_sent_msg().await; @@ -3749,7 +3766,7 @@ async fn test_sync_member_list_on_rejoin() -> Result<()> { let bob_id = alice.add_or_lookup_contact_id(bob).await; let fiona_id = alice.add_or_lookup_contact_id(fiona).await; - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "foos").await?; + let alice_chat_id = create_group(alice, "foos").await?; add_contact_to_chat(alice, alice_chat_id, bob_id).await?; add_contact_to_chat(alice, alice_chat_id, fiona_id).await?; @@ -3787,7 +3804,7 @@ async fn test_ignore_outdated_membership_changes() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; let alice_bob_id = alice.add_or_lookup_contact_id(bob).await; - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "grp").await?; + let alice_chat_id = create_group(alice, "grp").await?; // Alice creates a group chat. Bob accepts it. add_contact_to_chat(alice, alice_chat_id, alice_bob_id).await?; @@ -3835,7 +3852,7 @@ async fn test_dont_recreate_contacts_on_add_remove() -> Result<()> { let fiona = &tcm.fiona().await; let charlie = &tcm.charlie().await; - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?; + let alice_chat_id = create_group(alice, "Group").await?; add_contact_to_chat( alice, @@ -3886,7 +3903,7 @@ async fn test_delayed_removal_is_ignored() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; let fiona = &tcm.fiona().await; - let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?; + let chat_id = create_group(alice, "Group").await?; let alice_bob = alice.add_or_lookup_contact_id(bob).await; let alice_fiona = alice.add_or_lookup_contact_id(fiona).await; // create chat with three members @@ -3939,7 +3956,7 @@ async fn test_dont_readd_with_normal_msg() -> Result<()> { let bob = &tcm.bob().await; let fiona = &tcm.fiona().await; - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?; + let alice_chat_id = create_group(alice, "Group").await?; add_contact_to_chat( alice, @@ -4177,7 +4194,7 @@ async fn test_member_left_does_not_create_chat() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?; + let alice_chat_id = create_group(alice, "Group").await?; add_contact_to_chat( alice, alice_chat_id, @@ -4205,7 +4222,7 @@ async fn test_recreate_member_list_on_missing_add_of_self() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?; + let alice_chat_id = create_group(alice, "Group").await?; add_contact_to_chat( alice, alice_chat_id, @@ -4249,7 +4266,7 @@ async fn test_recreate_member_list_on_missing_add_of_self() -> Result<()> { async fn test_keep_member_list_if_possibly_nomember() -> Result<()> { let alice = TestContext::new_alice().await; let bob = TestContext::new_bob().await; - let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group").await?; + let alice_chat_id = create_group(&alice, "Group").await?; add_contact_to_chat( &alice, alice_chat_id, @@ -4389,7 +4406,7 @@ async fn test_create_group_with_big_msg() -> Result<()> { let file_bytes = include_bytes!("../../test-data/image/screenshot.png"); - let bob_grp_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "Group").await?; + let bob_grp_id = create_group(&bob, "Group").await?; add_contact_to_chat(&bob, bob_grp_id, ba_contact).await?; let mut msg = Message::new(Viewtype::Image); msg.set_file_from_bytes(&bob, "a.jpg", file_bytes, None)?; @@ -4420,7 +4437,7 @@ async fn test_create_group_with_big_msg() -> Result<()> { // Now Bob can send encrypted messages to Alice. - let bob_grp_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "Group1").await?; + let bob_grp_id = create_group(&bob, "Group1").await?; add_contact_to_chat(&bob, bob_grp_id, ba_contact).await?; let mut msg = Message::new(Viewtype::Image); msg.set_file_from_bytes(&bob, "a.jpg", file_bytes, None)?; @@ -4461,7 +4478,7 @@ async fn test_partial_group_consistency() -> Result<()> { let bob = tcm.bob().await; let fiona = tcm.fiona().await; let bob_id = alice.add_or_lookup_contact_id(&bob).await; - let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foos").await?; + let alice_chat_id = create_group(&alice, "foos").await?; add_contact_to_chat(&alice, alice_chat_id, bob_id).await?; send_text_msg(&alice, alice_chat_id, "populate".to_string()).await?; @@ -4541,7 +4558,7 @@ async fn test_protected_group_add_remove_member_missing_key() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; mark_as_verified(alice, bob).await; - let group_id = create_group_chat(alice, ProtectionStatus::Protected, "Group").await?; + let group_id = create_group(alice, "Group").await?; let alice_bob_id = alice.add_or_lookup_contact(bob).await.id; add_contact_to_chat(alice, group_id, alice_bob_id).await?; alice.send_text(group_id, "Hello!").await; @@ -4638,7 +4655,7 @@ async fn test_unarchive_on_member_removal() -> Result<()> { let fiona = &tcm.fiona().await; let bob_id = alice.add_or_lookup_contact_id(bob).await; let fiona_id = alice.add_or_lookup_contact_id(fiona).await; - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "foos").await?; + let alice_chat_id = create_group(alice, "foos").await?; add_contact_to_chat(alice, alice_chat_id, bob_id).await?; add_contact_to_chat(alice, alice_chat_id, fiona_id).await?; @@ -4671,9 +4688,7 @@ async fn test_no_op_member_added_is_trash() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; let fiona = &tcm.fiona().await; - let alice_chat_id = alice - .create_group_with_members(ProtectionStatus::Unprotected, "foos", &[bob]) - .await; + let alice_chat_id = alice.create_group_with_members("foos", &[bob]).await; send_text_msg(alice, alice_chat_id, "populate".to_string()).await?; let msg = alice.pop_sent_msg().await; bob.recv_msg(&msg).await; @@ -4740,7 +4755,7 @@ async fn test_references() -> Result<()> { let bob = &tcm.bob().await; alice.set_config_bool(Config::BccSelf, true).await?; - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?; + let alice_chat_id = create_group(alice, "Group").await?; alice .send_text(alice_chat_id, "Hi! I created a group.") .await; @@ -4784,7 +4799,7 @@ async fn test_prefer_references_to_downloaded_msgs() -> Result<()> { let fiona = &tcm.fiona().await; let alice_bob_id = tcm.send_recv(bob, alice, "hi").await.from_id; let alice_fiona_id = tcm.send_recv(fiona, alice, "hi").await.from_id; - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?; + let alice_chat_id = create_group(alice, "Group").await?; add_contact_to_chat(alice, alice_chat_id, alice_bob_id).await?; // W/o fiona the test doesn't work -- the last message is assigned to the 1:1 chat due to // `is_probably_private_reply()`. @@ -4984,12 +4999,7 @@ async fn test_group_name_with_newline() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let chat_id = create_group_chat( - alice, - ProtectionStatus::Unprotected, - "Group\r\nwith\nnewlines", - ) - .await?; + let chat_id = create_group(alice, "Group\r\nwith\nnewlines").await?; add_contact_to_chat(alice, chat_id, alice.add_or_lookup_contact_id(bob).await).await?; send_text_msg(alice, chat_id, "populate".to_string()).await?; let bob_chat_id = bob.recv_msg(&alice.pop_sent_msg().await).await.chat_id; @@ -5007,7 +5017,7 @@ async fn test_rename_chat_on_missing_message() -> Result<()> { let alice = tcm.alice().await; let bob = tcm.bob().await; let charlie = tcm.charlie().await; - let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group").await?; + let chat_id = create_group(&alice, "Group").await?; add_to_chat_contacts_table( &alice, time(), @@ -5043,7 +5053,7 @@ async fn test_rename_chat_after_creating_invite() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; for populate_before_securejoin in [false, true] { - let alice_chat_id = create_group_chat(alice, ProtectionStatus::Protected, "Group").await?; + let alice_chat_id = create_group(alice, "Group").await?; let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?; SystemTime::shift(Duration::from_secs(60)); @@ -5073,7 +5083,7 @@ async fn test_two_group_securejoins() -> Result<()> { let bob = &tcm.bob().await; let fiona = &tcm.fiona().await; - let group_id = chat::create_group_chat(alice, ProtectionStatus::Protected, "Group").await?; + let group_id = chat::create_group(alice, "Group").await?; let qr = get_securejoin_qr(alice, Some(group_id)).await?; @@ -5098,8 +5108,7 @@ async fn test_unverified_member_msg() -> Result<()> { let bob = &tcm.bob().await; let fiona = &tcm.fiona().await; - let alice_chat_id = - chat::create_group_chat(alice, ProtectionStatus::Protected, "Group").await?; + let alice_chat_id = chat::create_group(alice, "Group").await?; let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?; tcm.exec_securejoin_qr(bob, alice, &qr).await; @@ -5108,27 +5117,44 @@ async fn test_unverified_member_msg() -> Result<()> { let fiona_chat_id = fiona.get_last_msg().await.chat_id; let fiona_sent_msg = fiona.send_text(fiona_chat_id, "Hi").await; - // The message can't be verified, but the user can re-download it. - let bob_msg = bob.recv_msg(&fiona_sent_msg).await; - assert_eq!(bob_msg.download_state, DownloadState::Available); - assert!( - bob_msg - .text - .contains("Re-download the message or see 'Info' for more details") - ); - - let alice_sent_msg = alice - .send_text(alice_chat_id, "Hi all, it's Alice introducing Fiona") - .await; - bob.recv_msg(&alice_sent_msg).await; - - // Now Bob has Fiona's key and can verify the message. + // The message is by non-verified member, + // but the checks have been removed + // and the message should be downloaded as usual. let bob_msg = bob.recv_msg(&fiona_sent_msg).await; assert_eq!(bob_msg.download_state, DownloadState::Done); assert_eq!(bob_msg.text, "Hi"); Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_dont_reverify_by_self_on_outgoing_msg() -> Result<()> { + let mut tcm = TestContextManager::new(); + let a0 = &tcm.alice().await; + let a1 = &tcm.alice().await; + let bob = &tcm.bob().await; + let fiona = &tcm.fiona().await; + + let bob_chat_id = chat::create_group(bob, "Group").await?; + let qr = get_securejoin_qr(bob, Some(bob_chat_id)).await?; + tcm.exec_securejoin_qr(fiona, bob, &qr).await; + tcm.exec_securejoin_qr(a0, bob, &qr).await; + tcm.exec_securejoin_qr(a1, bob, &qr).await; + + // Shift time by one week to trigger gossip. + SystemTime::shift(Duration::from_secs(7 * 24 * 3600)); + + let a0_chat_id = a0.get_last_msg().await.chat_id; + let a0_sent_msg = a0.send_text(a0_chat_id, "Hi").await; + a1.recv_msg(&a0_sent_msg).await; + let a1_bob_id = a1.add_or_lookup_contact_id(bob).await; + let a1_fiona = a1.add_or_lookup_contact(fiona).await; + assert_eq!( + a1_fiona.get_verifier_id(a1).await?.unwrap().unwrap(), + a1_bob_id + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_sanitize_filename_in_received() -> Result<()> { let alice = &TestContext::new_alice().await; @@ -5172,9 +5198,7 @@ async fn test_no_address_contact_added_into_group() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let alice_chat_id = alice - .create_group_with_members(ProtectionStatus::Unprotected, "Group", &[bob]) - .await; + let alice_chat_id = alice.create_group_with_members("Group", &[bob]).await; let bob_received_msg = bob .recv_msg(&alice.send_text(alice_chat_id, "Message").await) .await; @@ -5454,7 +5478,7 @@ async fn test_small_unencrypted_group() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let alice_chat_id = chat::create_group_ex(alice, None, "Unencrypted group").await?; + let alice_chat_id = chat::create_group_unencrypted(alice, "Unencrypted group").await?; let alice_bob_id = alice.add_or_lookup_address_contact_id(bob).await; add_contact_to_chat(alice, alice_chat_id, alice_bob_id).await?; send_text_msg(alice, alice_chat_id, "Hello!".to_string()).await?; @@ -5475,6 +5499,38 @@ async fn test_small_unencrypted_group() -> Result<()> { Ok(()) } +/// Tests that if the sender includes self +/// in the `To` field, we do not count +/// it as a third recipient in addition to ourselves +/// and the sender and do not create a group chat. +/// +/// This is a regression test. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_bcc_not_a_group() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + + let received = receive_imf( + alice, + b"From: \"\"\n\ + To: \n\ + Subject: Hello, this is not a group\n\ + Message-ID: \n\ + Chat-Version: 1.0\n\ + Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ + \n\ + hello\n", + false, + ) + .await? + .unwrap(); + + let received_chat = Chat::load_from_db(alice, received.chat_id).await?; + assert_eq!(received_chat.typ, Chattype::Single); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_lookup_key_contact_by_address_self() -> Result<()> { let mut tcm = TestContextManager::new(); @@ -5486,3 +5542,32 @@ async fn test_lookup_key_contact_by_address_self() -> Result<()> { ); Ok(()) } + +/// Tests reception of multipart/alternative +/// with three parts, one of which is a calendar. +/// +/// MS Exchange produces multipart/alternative +/// messages with three parts: +/// `text/plain`, `text/html` and `text/calendar`. +/// +/// We display `text/plain` part in this case, +/// but .ics file is available as an attachment. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_calendar_alternative() -> Result<()> { + let mut tcm = TestContextManager::new(); + let t = &tcm.alice().await; + let raw = include_bytes!("../../test-data/message/calendar-alternative.eml"); + let msg = receive_imf(t, raw, false).await?.unwrap(); + assert_eq!(msg.msg_ids.len(), 1); + + let calendar_msg = Message::load_from_db(t, msg.msg_ids[0]).await?; + assert_eq!(calendar_msg.text, "Subject was here – Hello!"); + assert_eq!(calendar_msg.viewtype, Viewtype::File); + assert_eq!(calendar_msg.get_filename().unwrap(), "calendar.ics"); + + assert!(calendar_msg.has_html()); + let html = calendar_msg.get_id().get_html(t).await.unwrap().unwrap(); + assert_eq!(html, "Hello!"); + + Ok(()) +} diff --git a/src/scheduler.rs b/src/scheduler.rs index 491840daff..bbf1ef0161 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -8,12 +8,12 @@ use async_channel::{self as channel, Receiver, Sender}; use futures::future::try_join_all; use futures_lite::FutureExt; use rand::Rng; -use tokio::sync::{RwLock, RwLockWriteGuard, oneshot}; +use tokio::sync::{RwLock, oneshot}; use tokio::task; use tokio_util::sync::CancellationToken; use tokio_util::task::TaskTracker; -use self::connectivity::ConnectivityStore; +pub(crate) use self::connectivity::ConnectivityStore; use crate::config::{self, Config}; use crate::constants; use crate::contact::{ContactId, RecentlySeenLoop}; @@ -53,32 +53,32 @@ impl SchedulerState { } /// Starts the scheduler if it is not yet started. - pub(crate) async fn start(&self, context: Context) { + pub(crate) async fn start(&self, context: &Context) { let mut inner = self.inner.write().await; match *inner { InnerSchedulerState::Started(_) => (), - InnerSchedulerState::Stopped => Self::do_start(inner, context).await, + InnerSchedulerState::Stopped => Self::do_start(&mut inner, context).await, InnerSchedulerState::Paused { ref mut started, .. } => *started = true, } + context.update_connectivities(&inner); } /// Starts the scheduler if it is not yet started. - async fn do_start(mut inner: RwLockWriteGuard<'_, InnerSchedulerState>, context: Context) { + async fn do_start(inner: &mut InnerSchedulerState, context: &Context) { info!(context, "starting IO"); // Notify message processing loop // to allow processing old messages after restart. context.new_msgs_notify.notify_one(); - let ctx = context.clone(); - match Scheduler::start(&context).await { + match Scheduler::start(context).await { Ok(scheduler) => { *inner = InnerSchedulerState::Started(scheduler); context.emit_event(EventType::ConnectivityChanged); } - Err(err) => error!(&ctx, "Failed to start IO: {:#}", err), + Err(err) => error!(context, "Failed to start IO: {:#}", err), } } @@ -87,18 +87,19 @@ impl SchedulerState { let mut inner = self.inner.write().await; match *inner { InnerSchedulerState::Started(_) => { - Self::do_stop(inner, context, InnerSchedulerState::Stopped).await + Self::do_stop(&mut inner, context, InnerSchedulerState::Stopped).await } InnerSchedulerState::Stopped => (), InnerSchedulerState::Paused { ref mut started, .. } => *started = false, } + context.update_connectivities(&inner); } /// Stops the scheduler if it is currently running. async fn do_stop( - mut inner: RwLockWriteGuard<'_, InnerSchedulerState>, + inner: &mut InnerSchedulerState, context: &Context, new_state: InnerSchedulerState, ) { @@ -122,7 +123,7 @@ impl SchedulerState { debug_logging.loop_handle.abort(); debug_logging.loop_handle.await.ok(); } - let prev_state = std::mem::replace(&mut *inner, new_state); + let prev_state = std::mem::replace(inner, new_state); context.emit_event(EventType::ConnectivityChanged); match prev_state { InnerSchedulerState::Started(scheduler) => scheduler.stop(context).await, @@ -138,7 +139,7 @@ impl SchedulerState { /// If in the meantime [`SchedulerState::start`] or [`SchedulerState::stop`] is called /// resume will do the right thing and restore the scheduler to the state requested by /// the last call. - pub(crate) async fn pause(&'_ self, context: Context) -> Result { + pub(crate) async fn pause(&'_ self, context: &Context) -> Result { { let mut inner = self.inner.write().await; match *inner { @@ -147,7 +148,7 @@ impl SchedulerState { started: true, pause_guards_count: NonZeroUsize::new(1).unwrap(), }; - Self::do_stop(inner, &context, new_state).await; + Self::do_stop(&mut inner, context, new_state).await; } InnerSchedulerState::Stopped => { *inner = InnerSchedulerState::Paused { @@ -164,9 +165,11 @@ impl SchedulerState { .ok_or_else(|| Error::msg("Too many pause guards active"))? } } + context.update_connectivities(&inner); } let (tx, rx) = oneshot::channel(); + let context = context.clone(); tokio::spawn(async move { rx.await.ok(); let mut inner = context.scheduler.inner.write().await; @@ -183,7 +186,7 @@ impl SchedulerState { } => { if *pause_guards_count == NonZeroUsize::new(1).unwrap() { match *started { - true => SchedulerState::do_start(inner, context.clone()).await, + true => SchedulerState::do_start(&mut inner, &context).await, false => *inner = InnerSchedulerState::Stopped, } } else { @@ -193,6 +196,7 @@ impl SchedulerState { } } } + context.update_connectivities(&inner); }); Ok(IoPausedGuard { sender: Some(tx) }) } @@ -202,7 +206,7 @@ impl SchedulerState { info!(context, "restarting IO"); if self.is_running().await { self.stop(context).await; - self.start(context.clone()).await; + self.start(context).await; } } @@ -223,7 +227,7 @@ impl SchedulerState { _ => return, }; drop(inner); - connectivity::idle_interrupted(inbox, oboxes).await; + connectivity::idle_interrupted(inbox, oboxes); } /// Indicate that the network likely is lost. @@ -240,7 +244,7 @@ impl SchedulerState { _ => return, }; drop(inner); - connectivity::maybe_network_lost(context, stores).await; + connectivity::maybe_network_lost(context, stores); } pub(crate) async fn interrupt_inbox(&self) { @@ -288,7 +292,7 @@ impl SchedulerState { } #[derive(Debug, Default)] -enum InnerSchedulerState { +pub(crate) enum InnerSchedulerState { Started(Scheduler), #[default] Stopped, @@ -565,7 +569,7 @@ async fn fetch_idle( // The folder is not configured. // For example, this happens if the server does not have Sent folder // but watching Sent folder is enabled. - connection.connectivity.set_not_configured(ctx).await; + connection.connectivity.set_not_configured(ctx); connection.idle_interrupt_receiver.recv().await.ok(); bail!("Cannot fetch folder {folder_meaning} because it is not configured"); }; @@ -655,7 +659,7 @@ async fn fetch_idle( .log_err(ctx) .ok(); - connection.connectivity.set_idle(ctx).await; + connection.connectivity.set_idle(ctx); ctx.emit_event(EventType::ImapInboxIdle); @@ -806,8 +810,8 @@ async fn smtp_loop( // Fake Idle info!(ctx, "SMTP fake idle started."); match &connection.last_send_error { - None => connection.connectivity.set_idle(&ctx).await, - Some(err) => connection.connectivity.set_err(&ctx, err).await, + None => connection.connectivity.set_idle(&ctx), + Some(err) => connection.connectivity.set_err(&ctx, err), } // If send_smtp_messages() failed, we set a timeout for the fake-idle so that @@ -928,7 +932,7 @@ impl Scheduler { // wait for all loops to be started if let Err(err) = try_join_all(start_recvs).await { - bail!("failed to start scheduler: {}", err); + bail!("failed to start scheduler: {err}"); } info!(ctx, "scheduler is running"); diff --git a/src/scheduler/connectivity.rs b/src/scheduler/connectivity.rs index 8f34258ba6..64dfccc7a0 100644 --- a/src/scheduler/connectivity.rs +++ b/src/scheduler/connectivity.rs @@ -4,7 +4,6 @@ use std::{iter::once, ops::Deref, sync::Arc}; use anyhow::Result; use humansize::{BINARY, format_size}; -use tokio::sync::Mutex; use crate::events::EventType; use crate::imap::{FolderMeaning, scan_folders::get_watched_folder_configs}; @@ -160,52 +159,51 @@ impl DetailedConnectivity { } #[derive(Clone, Default)] -pub(crate) struct ConnectivityStore(Arc>); +pub(crate) struct ConnectivityStore(Arc>); impl ConnectivityStore { - async fn set(&self, context: &Context, v: DetailedConnectivity) { + fn set(&self, context: &Context, v: DetailedConnectivity) { { - *self.0.lock().await = v; + *self.0.lock() = v; } context.emit_event(EventType::ConnectivityChanged); } - pub(crate) async fn set_err(&self, context: &Context, e: impl ToString) { - self.set(context, DetailedConnectivity::Error(e.to_string())) - .await; + pub(crate) fn set_err(&self, context: &Context, e: impl ToString) { + self.set(context, DetailedConnectivity::Error(e.to_string())); } - pub(crate) async fn set_connecting(&self, context: &Context) { - self.set(context, DetailedConnectivity::Connecting).await; + pub(crate) fn set_connecting(&self, context: &Context) { + self.set(context, DetailedConnectivity::Connecting); } - pub(crate) async fn set_working(&self, context: &Context) { - self.set(context, DetailedConnectivity::Working).await; + pub(crate) fn set_working(&self, context: &Context) { + self.set(context, DetailedConnectivity::Working); } - pub(crate) async fn set_preparing(&self, context: &Context) { - self.set(context, DetailedConnectivity::Preparing).await; + pub(crate) fn set_preparing(&self, context: &Context) { + self.set(context, DetailedConnectivity::Preparing); } - pub(crate) async fn set_not_configured(&self, context: &Context) { - self.set(context, DetailedConnectivity::NotConfigured).await; + pub(crate) fn set_not_configured(&self, context: &Context) { + self.set(context, DetailedConnectivity::NotConfigured); } - pub(crate) async fn set_idle(&self, context: &Context) { - self.set(context, DetailedConnectivity::Idle).await; + pub(crate) fn set_idle(&self, context: &Context) { + self.set(context, DetailedConnectivity::Idle); } - async fn get_detailed(&self) -> DetailedConnectivity { - self.0.lock().await.deref().clone() + fn get_detailed(&self) -> DetailedConnectivity { + self.0.lock().deref().clone() } - async fn get_basic(&self) -> Option { - self.0.lock().await.to_basic() + fn get_basic(&self) -> Option { + self.0.lock().to_basic() } - async fn get_all_work_done(&self) -> bool { - self.0.lock().await.all_work_done() + fn get_all_work_done(&self) -> bool { + self.0.lock().all_work_done() } } /// Set all folder states to InterruptingIdle in case they were `Idle` before. /// Called during `dc_maybe_network()` to make sure that `all_work_done()` /// returns false immediately after `dc_maybe_network()`. -pub(crate) async fn idle_interrupted(inbox: ConnectivityStore, oboxes: Vec) { - let mut connectivity_lock = inbox.0.lock().await; +pub(crate) fn idle_interrupted(inbox: ConnectivityStore, oboxes: Vec) { + let mut connectivity_lock = inbox.0.lock(); // For the inbox, we also have to set the connectivity to InterruptingIdle if it was // NotConfigured before: If all folders are NotConfigured, dc_get_connectivity() // returns Connected. But after dc_maybe_network(), dc_get_connectivity() must not @@ -219,7 +217,7 @@ pub(crate) async fn idle_interrupted(inbox: ConnectivityStore, oboxes: Vec) { +pub(crate) fn maybe_network_lost(context: &Context, stores: Vec) { for store in &stores { - let mut connectivity_lock = store.0.lock().await; + let mut connectivity_lock = store.0.lock(); if !matches!( *connectivity_lock, DetailedConnectivity::Uninitialized @@ -248,7 +246,7 @@ pub(crate) async fn maybe_network_lost(context: &Context, stores: Vec) -> fmt::Result { - if let Ok(guard) = self.0.try_lock() { + if let Some(guard) = self.0.try_lock() { write!(f, "ConnectivityStore {:?}", &*guard) } else { write!(f, "ConnectivityStore [LOCKED]") @@ -271,27 +269,29 @@ impl Context { /// e.g. in the title of the main screen. /// /// If the connectivity changes, a DC_EVENT_CONNECTIVITY_CHANGED will be emitted. - pub async fn get_connectivity(&self) -> Connectivity { - let lock = self.scheduler.inner.read().await; - let stores: Vec<_> = match *lock { - InnerSchedulerState::Started(ref sched) => sched - .boxes() - .map(|b| b.conn_state.state.connectivity.clone()) - .collect(), - _ => return Connectivity::NotConnected, - }; - drop(lock); - + pub fn get_connectivity(&self) -> Connectivity { + let stores = self.connectivities.lock().clone(); let mut connectivities = Vec::new(); for s in stores { - if let Some(connectivity) = s.get_basic().await { + if let Some(connectivity) = s.get_basic() { connectivities.push(connectivity); } } connectivities .into_iter() .min() - .unwrap_or(Connectivity::Connected) + .unwrap_or(Connectivity::NotConnected) + } + + pub(crate) fn update_connectivities(&self, sched: &InnerSchedulerState) { + let stores: Vec<_> = match sched { + InnerSchedulerState::Started(sched) => sched + .boxes() + .map(|b| b.conn_state.state.connectivity.clone()) + .collect(), + _ => Vec::new(), + }; + *self.connectivities.lock() = stores; } /// Get an overview of the current connectivity, and possibly more statistics. @@ -391,7 +391,7 @@ impl Context { let f = self.get_config(config).await.log_err(self).ok().flatten(); if let Some(foldername) = f { - let detailed = &state.get_detailed().await; + let detailed = &state.get_detailed(); ret += "
  • "; ret += &*detailed.to_icon(); ret += " "; @@ -405,7 +405,7 @@ impl Context { } if !folder_added && folder == &FolderMeaning::Inbox { - let detailed = &state.get_detailed().await; + let detailed = &state.get_detailed(); if let DetailedConnectivity::Error(_) = detailed { // On the inbox thread, we also do some other things like scan_folders and run jobs // so, maybe, the inbox is not watched, but something else went wrong @@ -427,7 +427,7 @@ impl Context { let outgoing_messages = stock_str::outgoing_messages(self).await; ret += &format!("

    {outgoing_messages}

    • "); - let detailed = smtp.get_detailed().await; + let detailed = smtp.get_detailed(); ret += &*detailed.to_icon(); ret += " "; ret += &*escaper::encode_minimal(&detailed.to_string_smtp(self).await); @@ -551,7 +551,7 @@ impl Context { drop(lock); for s in &stores { - if !s.get_all_work_done().await { + if !s.get_all_work_done() { return false; } } diff --git a/src/securejoin.rs b/src/securejoin.rs index 4fff0eca28..4e3bea5bb8 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -4,8 +4,7 @@ use anyhow::{Context as _, Error, Result, bail, ensure}; use deltachat_contact_tools::ContactAddress; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; -use crate::chat::{self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, get_chat_id_by_grpid}; -use crate::chatlist_events; +use crate::chat::{self, Chat, ChatId, ChatIdBlocked, get_chat_id_by_grpid}; use crate::config::Config; use crate::constants::{Blocked, Chattype, NON_ALPHANUMERIC_WITHOUT_DOT}; use crate::contact::mark_contact_id_as_verified; @@ -16,7 +15,6 @@ use crate::events::EventType; use crate::headerdef::HeaderDef; use crate::key::{DcKey, Fingerprint, load_self_public_key}; use crate::log::{error, info, warn}; -use crate::logged_debug_assert; use crate::message::{Message, Viewtype}; use crate::mimeparser::{MimeMessage, SystemMessage}; use crate::param::Param; @@ -24,6 +22,7 @@ use crate::qr::check_qr; use crate::securejoin::bob::JoinerProgress; use crate::sync::Sync::*; use crate::token; +use crate::tools::{create_id, time}; mod bob; mod qrinvite; @@ -32,16 +31,28 @@ use qrinvite::QrInvite; use crate::token::Namespace; -fn inviter_progress(context: &Context, contact_id: ContactId, progress: usize) { - logged_debug_assert!( - context, - progress <= 1000, - "inviter_progress: contact {contact_id}, progress={progress}, but value in range 0..1000 expected with: 0=error, 1..999=progress, 1000=success." - ); +fn inviter_progress( + context: &Context, + contact_id: ContactId, + chat_id: ChatId, + is_group: bool, +) -> Result<()> { + let chat_type = if is_group { + Chattype::Group + } else { + Chattype::Single + }; + + // No other values are used. + let progress = 1000; context.emit_event(EventType::SecurejoinInviterProgress { contact_id, + chat_id, + chat_type, progress, }); + + Ok(()) } /// Generates a Secure Join QR code. @@ -76,10 +87,21 @@ pub async fn get_securejoin_qr(context: &Context, group: Option) -> Resu let sync_token = token::lookup(context, Namespace::InviteNumber, grpid) .await? .is_none(); - // invitenumber will be used to allow starting the handshake, - // auth will be used to verify the fingerprint + // Invite number is used to request the inviter key. let invitenumber = token::lookup_or_new(context, Namespace::InviteNumber, grpid).await?; - let auth = token::lookup_or_new(context, Namespace::Auth, grpid).await?; + + // Auth token is used to verify the key-contact + // if the token is not old + // and add the contact to the group + // if there is an associated group ID. + // + // We always generate a new auth token + // because auth tokens "expire" + // and can only be used to join groups + // without verification afterwards. + let auth = create_id(); + token::save(context, Namespace::Auth, grpid, &auth, time()).await?; + let self_addr = context.get_primary_self_addr().await?; let self_name = context .get_config(Config::Displayname) @@ -210,7 +232,7 @@ async fn verify_sender_by_fingerprint( let contact = Contact::get_by_id(context, contact_id).await?; let is_verified = contact.fingerprint().is_some_and(|fp| &fp == fingerprint); if is_verified { - mark_contact_id_as_verified(context, contact_id, ContactId::SELF).await?; + mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?; } Ok(is_verified) } @@ -266,13 +288,13 @@ pub(crate) async fn handle_securejoin_handshake( info!(context, "Received secure-join message {step:?}."); - let join_vg = step.starts_with("vg-"); - if !matches!(step, "vg-request" | "vc-request") { let mut self_found = false; let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint(); for (addr, key) in &mime_message.gossiped_keys { - if key.dc_fingerprint() == self_fingerprint && context.is_self_addr(addr).await? { + if key.public_key.dc_fingerprint() == self_fingerprint + && context.is_self_addr(addr).await? + { self_found = true; break; } @@ -308,8 +330,6 @@ pub(crate) async fn handle_securejoin_handshake( return Ok(HandshakeMessage::Ignore); } - inviter_progress(context, contact_id, 300); - let from_addr = ContactAddress::new(&mime_message.from.addr)?; let autocrypt_fingerprint = mime_message.autocrypt_fingerprint.as_deref().unwrap_or(""); let (autocrypt_contact_id, _) = Contact::add_or_lookup_ex( @@ -369,7 +389,19 @@ pub(crate) async fn handle_securejoin_handshake( ); return Ok(HandshakeMessage::Ignore); }; - let Some(grpid) = token::auth_foreign_key(context, auth).await? else { + let Some((grpid, timestamp)) = context + .sql + .query_row_optional( + "SELECT foreign_key, timestamp FROM tokens WHERE namespc=? AND token=?", + (Namespace::Auth, auth), + |row| { + let foreign_key: String = row.get(0)?; + let timestamp: i64 = row.get(1)?; + Ok((foreign_key, timestamp)) + }, + ) + .await? + else { warn!( context, "Ignoring {step} message because of invalid auth code." @@ -387,7 +419,11 @@ pub(crate) async fn handle_securejoin_handshake( } }; - if !verify_sender_by_fingerprint(context, &fingerprint, contact_id).await? { + let sender_contact = Contact::get_by_id(context, contact_id).await?; + if sender_contact + .fingerprint() + .is_none_or(|fp| fp != fingerprint) + { warn!( context, "Ignoring {step} message because of fingerprint mismatch." @@ -395,45 +431,37 @@ pub(crate) async fn handle_securejoin_handshake( return Ok(HandshakeMessage::Ignore); } info!(context, "Fingerprint verified via Auth code.",); + + // Mark the contact as verified if auth code is 600 seconds old. + if time() < timestamp + 600 { + mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?; + } contact_id.regossip_keys(context).await?; ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited).await?; // for setup-contact, make Alice's one-to-one chat with Bob visible // (secure-join-information are shown in the group chat) - if !join_vg { + if grpid.is_empty() { ChatId::create_for_contact(context, contact_id).await?; } context.emit_event(EventType::ContactsChanged(Some(contact_id))); - inviter_progress(context, contact_id, 600); if let Some(group_chat_id) = group_chat_id { // Join group. - secure_connection_established( - context, - contact_id, - group_chat_id, - mime_message.timestamp_sent, - ) - .await?; chat::add_contact_to_chat_ex(context, Nosync, group_chat_id, contact_id, true) .await?; - inviter_progress(context, contact_id, 800); - inviter_progress(context, contact_id, 1000); + let is_group = true; + inviter_progress(context, contact_id, group_chat_id, is_group)?; // IMAP-delete the message to avoid handling it by another device and adding the // member twice. Another device will know the member's key from Autocrypt-Gossip. Ok(HandshakeMessage::Done) } else { + let chat_id = info_chat_id(context, contact_id).await?; // Setup verified contact. - secure_connection_established( - context, - contact_id, - info_chat_id(context, contact_id).await?, - mime_message.timestamp_sent, - ) - .await?; send_alice_handshake_msg(context, contact_id, "vc-contact-confirm") .await .context("failed sending vc-contact-confirm message")?; - inviter_progress(context, contact_id, 1000); + let is_group = false; + inviter_progress(context, contact_id, chat_id, is_group)?; Ok(HandshakeMessage::Ignore) // "Done" would delete the message and break multi-device (the key from Autocrypt-header is needed) } } @@ -542,21 +570,28 @@ pub(crate) async fn observe_securejoin_on_other_device( return Ok(HandshakeMessage::Ignore); }; - if key.dc_fingerprint() != contact_fingerprint { + if key.public_key.dc_fingerprint() != contact_fingerprint { // Fingerprint does not match, ignore. warn!(context, "Fingerprint does not match."); return Ok(HandshakeMessage::Ignore); } - mark_contact_id_as_verified(context, contact_id, ContactId::SELF).await?; - - ChatId::set_protection_for_contact(context, contact_id, mime_message.timestamp_sent).await?; + mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?; - if step == "vg-member-added" { - inviter_progress(context, contact_id, 800); - } if step == "vg-member-added" || step == "vc-contact-confirm" { - inviter_progress(context, contact_id, 1000); + let is_group = mime_message + .get_header(HeaderDef::ChatGroupMemberAdded) + .is_some(); + + // We don't know the chat ID + // as we may not know about the group yet. + // + // Event is mostly used for bots + // which only have a single device + // and tests which don't care about the chat ID, + // so we pass invalid chat ID here. + let chat_id = ChatId::new(0); + inviter_progress(context, contact_id, chat_id, is_group)?; } if step == "vg-request-with-auth" || step == "vc-request-with-auth" { @@ -573,28 +608,6 @@ pub(crate) async fn observe_securejoin_on_other_device( } } -async fn secure_connection_established( - context: &Context, - contact_id: ContactId, - chat_id: ChatId, - timestamp: i64, -) -> Result<()> { - let private_chat_id = ChatIdBlocked::get_for_contact(context, contact_id, Blocked::Yes) - .await? - .id; - private_chat_id - .set_protection( - context, - ProtectionStatus::Protected, - timestamp, - Some(contact_id), - ) - .await?; - context.emit_event(EventType::ChatModified(chat_id)); - chatlist_events::emit_chatlist_item_changed(context, chat_id); - Ok(()) -} - /* ****************************************************************************** * Tools: Misc. ******************************************************************************/ @@ -604,17 +617,19 @@ fn encrypted_and_signed( mimeparser: &MimeMessage, expected_fingerprint: &Fingerprint, ) -> bool { - if !mimeparser.was_encrypted() { + if let Some(signature) = mimeparser.signature.as_ref() { + if signature == expected_fingerprint { + true + } else { + warn!( + context, + "Message does not match expected fingerprint {expected_fingerprint}.", + ); + false + } + } else { warn!(context, "Message not encrypted.",); false - } else if !mimeparser.signatures.contains(expected_fingerprint) { - warn!( - context, - "Message does not match expected fingerprint {}.", expected_fingerprint, - ); - false - } else { - true } } diff --git a/src/securejoin/bob.rs b/src/securejoin/bob.rs index 5392f94692..2d7046f5d4 100644 --- a/src/securejoin/bob.rs +++ b/src/securejoin/bob.rs @@ -4,7 +4,7 @@ use anyhow::{Context as _, Result}; use super::HandshakeMessage; use super::qrinvite::QrInvite; -use crate::chat::{self, ChatId, ProtectionStatus, is_contact_in_chat}; +use crate::chat::{self, ChatId, is_contact_in_chat}; use crate::constants::{Blocked, Chattype}; use crate::contact::Origin; use crate::context::Context; @@ -74,16 +74,6 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::RequestWithAuth) .await?; - // Mark 1:1 chat as verified already. - chat_id - .set_protection( - context, - ProtectionStatus::Protected, - time(), - Some(invite.contact_id()), - ) - .await?; - context.emit_event(EventType::SecurejoinJoinerProgress { contact_id: invite.contact_id(), progress: JoinerProgress::RequestWithAuthSent.to_usize(), @@ -123,21 +113,19 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul let ts_sort = chat_id .calc_sort_timestamp(context, 0, sort_to_bottom, received, incoming) .await?; - if chat_id.is_protected(context).await? == ProtectionStatus::Unprotected { - let ts_start = time(); - chat::add_info_msg_with_cmd( - context, - chat_id, - &stock_str::securejoin_wait(context).await, - SystemMessage::SecurejoinWait, - ts_sort, - Some(ts_start), - None, - None, - None, - ) - .await?; - } + let ts_start = time(); + chat::add_info_msg_with_cmd( + context, + chat_id, + &stock_str::securejoin_wait(context).await, + SystemMessage::SecurejoinWait, + ts_sort, + Some(ts_start), + None, + None, + None, + ) + .await?; Ok(chat_id) } } @@ -215,15 +203,6 @@ pub(super) async fn handle_auth_required( } } - chat_id - .set_protection( - context, - ProtectionStatus::Protected, - message.timestamp_sent, - Some(invite.contact_id()), - ) - .await?; - context.emit_event(EventType::SecurejoinJoinerProgress { contact_id: invite.contact_id(), progress: JoinerProgress::RequestWithAuthSent.to_usize(), @@ -348,7 +327,7 @@ async fn joining_chat_id( QrInvite::Contact { .. } => Ok(alice_chat_id), QrInvite::Group { grpid, name, .. } => { let group_chat_id = match chat::get_chat_id_by_grpid(context, grpid).await? { - Some((chat_id, _protected, _blocked)) => { + Some((chat_id, _blocked)) => { chat_id.unblock_ex(context, Nosync).await?; chat_id } @@ -359,7 +338,6 @@ async fn joining_chat_id( grpid, name, Blocked::Not, - ProtectionStatus::Unprotected, // protection is added later as needed None, create_smeared_timestamp(context), ) diff --git a/src/securejoin/securejoin_tests.rs b/src/securejoin/securejoin_tests.rs index b5f3fcd84a..1c33b115e9 100644 --- a/src/securejoin/securejoin_tests.rs +++ b/src/securejoin/securejoin_tests.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use deltachat_contact_tools::EmailAddress; use super::*; @@ -5,18 +7,17 @@ use crate::chat::{CantSendReason, remove_contact_from_chat}; use crate::chatlist::Chatlist; use crate::constants::Chattype; use crate::key::self_fingerprint; +use crate::mimeparser::{GossipedKey, SystemMessage}; use crate::receive_imf::receive_imf; use crate::stock_str::{self, messages_e2e_encrypted}; use crate::test_utils::{ TestContext, TestContextManager, TimeShiftFalsePositiveNote, get_chat_msg, }; use crate::tools::SystemTime; -use std::time::Duration; #[derive(PartialEq)] enum SetupContactCase { Normal, - CheckProtectionTimestamp, WrongAliceGossip, AliceIsBot, AliceHasName, @@ -27,11 +28,6 @@ async fn test_setup_contact() { test_setup_contact_ex(SetupContactCase::Normal).await } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_setup_contact_protection_timestamp() { - test_setup_contact_ex(SetupContactCase::CheckProtectionTimestamp).await -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_setup_contact_wrong_alice_gossip() { test_setup_contact_ex(SetupContactCase::WrongAliceGossip).await @@ -71,11 +67,6 @@ async fn test_setup_contact_ex(case: SetupContactCase) { } _ => alice_auto_submitted_hdr = "Auto-Submitted: auto-replied", }; - for t in [&alice, &bob] { - t.set_config_bool(Config::VerifiedOneOnOneChats, true) - .await - .unwrap(); - } assert_eq!( Chatlist::try_load(&alice, 0, None, None) @@ -168,10 +159,6 @@ async fn test_setup_contact_ex(case: SetupContactCase) { assert!(sent.payload.contains("Auto-Submitted: auto-replied")); assert!(!sent.payload.contains("Bob Examplenet")); let mut msg = alice.parse_msg(&sent).await; - let vc_request_with_auth_ts_sent = msg - .get_header(HeaderDef::Date) - .and_then(|value| mailparse::dateparse(value).ok()) - .unwrap(); assert!(msg.was_encrypted()); assert_eq!( msg.get_header(HeaderDef::SecureJoin).unwrap(), @@ -185,7 +172,10 @@ async fn test_setup_contact_ex(case: SetupContactCase) { ); if case == SetupContactCase::WrongAliceGossip { - let wrong_pubkey = load_self_public_key(&bob).await.unwrap(); + let wrong_pubkey = GossipedKey { + public_key: load_self_public_key(&bob).await.unwrap(), + verified: false, + }; let alice_pubkey = msg .gossiped_keys .insert(alice_addr.to_string(), wrong_pubkey) @@ -215,10 +205,6 @@ async fn test_setup_contact_ex(case: SetupContactCase) { assert_eq!(contact_bob.is_verified(&alice).await.unwrap(), false); assert_eq!(contact_bob.get_authname(), ""); - if case == SetupContactCase::CheckProtectionTimestamp { - SystemTime::shift(Duration::from_secs(3600)); - } - tcm.section("Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm"); alice.recv_msg_trash(&sent).await; assert_eq!(contact_bob.is_verified(&alice).await.unwrap(), true); @@ -248,9 +234,6 @@ async fn test_setup_contact_ex(case: SetupContactCase) { assert!(msg.is_info()); let expected_text = messages_e2e_encrypted(&alice).await; assert_eq!(msg.get_text(), expected_text); - if case == SetupContactCase::CheckProtectionTimestamp { - assert_eq!(msg.timestamp_sort, vc_request_with_auth_ts_sent + 1); - } } // Make sure Alice hasn't yet sent their name to Bob. @@ -293,10 +276,10 @@ async fn test_setup_contact_ex(case: SetupContactCase) { let mut i = 0..msg_cnt; let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await; assert!(msg.is_info()); - assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await); + assert_eq!(msg.get_text(), messages_e2e_encrypted(&bob).await); let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await; assert!(msg.is_info()); - assert_eq!(msg.get_text(), messages_e2e_encrypted(&bob).await); + assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -361,6 +344,30 @@ async fn test_setup_contact_bob_knows_alice() -> Result<()> { alice.recv_msg_trash(&sent).await; assert_eq!(contact_bob.is_verified(alice).await?, true); + // Check Alice signalled success via the SecurejoinInviterProgress event. + let event = alice + .evtracker + .get_matching(|evt| { + matches!( + evt, + EventType::SecurejoinInviterProgress { progress: 1000, .. } + ) + }) + .await; + match event { + EventType::SecurejoinInviterProgress { + contact_id, + chat_type, + progress, + .. + } => { + assert_eq!(contact_id, contact_bob.id); + assert_eq!(chat_type, Chattype::Single); + assert_eq!(progress, 1000); + } + _ => unreachable!(), + } + let sent = alice.pop_sent_msg().await; let msg = bob.parse_msg(&sent).await; assert!(msg.was_encrypted()); @@ -425,8 +432,7 @@ async fn test_secure_join() -> Result<()> { assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 0); assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 0); - let alice_chatid = - chat::create_group_chat(&alice, ProtectionStatus::Protected, "the chat").await?; + let alice_chatid = chat::create_group(&alice, "the chat").await?; tcm.section("Step 1: Generate QR-code, secure-join implied by chatid"); let qr = get_securejoin_qr(&alice, Some(alice_chatid)).await.unwrap(); @@ -511,6 +517,31 @@ async fn test_secure_join() -> Result<()> { alice.recv_msg_trash(&sent).await; assert_eq!(contact_bob.is_verified(&alice).await?, true); + // Check Alice signalled success via the SecurejoinInviterProgress event. + let event = alice + .evtracker + .get_matching(|evt| { + matches!( + evt, + EventType::SecurejoinInviterProgress { progress: 1000, .. } + ) + }) + .await; + match event { + EventType::SecurejoinInviterProgress { + contact_id, + chat_type, + chat_id, + progress, + } => { + assert_eq!(contact_id, contact_bob.id); + assert_eq!(chat_type, Chattype::Group); + assert_eq!(chat_id, alice_chatid); + assert_eq!(progress, 1000); + } + _ => unreachable!(), + } + let sent = alice.pop_sent_msg().await; let msg = bob.parse_msg(&sent).await; assert!(msg.was_encrypted()); @@ -535,7 +566,7 @@ async fn test_secure_join() -> Result<()> { Blocked::Yes, "Alice's 1:1 chat with Bob is not hidden" ); - // There should be 3 messages in the chat: + // There should be 2 messages in the chat: // - The ChatProtectionEnabled message // - You added member bob@example.net let msg = get_chat_msg(&alice, alice_chatid, 0, 2).await; @@ -571,7 +602,6 @@ async fn test_secure_join() -> Result<()> { } let bob_chat = Chat::load_from_db(&bob.ctx, bob_chatid).await?; - assert!(bob_chat.is_protected()); assert!(bob_chat.typ == Chattype::Group); // On this "happy path", Alice and Bob get only a group-chat where all information are added to. @@ -619,7 +649,7 @@ async fn test_unknown_sender() -> Result<()> { tcm.execute_securejoin(&alice, &bob).await; let alice_chat_id = alice - .create_group_with_members(ProtectionStatus::Protected, "Group with Bob", &[&bob]) + .create_group_with_members("Group with Bob", &[&bob]) .await; let sent = alice.send_text(alice_chat_id, "Hi!").await; @@ -634,7 +664,7 @@ async fn test_unknown_sender() -> Result<()> { // The message from Bob is delivered late, Bob is already removed. let msg = alice.recv_msg(&sent).await; assert_eq!(msg.text, "Hi hi!"); - assert_eq!(msg.error.unwrap(), "Unknown sender for this chat."); + assert_eq!(msg.get_override_sender_name().unwrap(), "bob@example.net"); Ok(()) } @@ -649,11 +679,6 @@ async fn test_lost_contact_confirm() { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; - for t in [&alice, &bob] { - t.set_config_bool(Config::VerifiedOneOnOneChats, true) - .await - .unwrap(); - } let qr = get_securejoin_qr(&alice, None).await.unwrap(); join_securejoin(&bob.ctx, &qr).await.unwrap(); @@ -690,10 +715,8 @@ async fn test_parallel_securejoin() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let alice_chat1_id = - chat::create_group_chat(alice, ProtectionStatus::Protected, "First chat").await?; - let alice_chat2_id = - chat::create_group_chat(alice, ProtectionStatus::Protected, "Second chat").await?; + let alice_chat1_id = chat::create_group(alice, "First chat").await?; + let alice_chat2_id = chat::create_group(alice, "Second chat").await?; let qr1 = get_securejoin_qr(alice, Some(alice_chat1_id)).await?; let qr2 = get_securejoin_qr(alice, Some(alice_chat2_id)).await?; @@ -819,3 +842,120 @@ async fn test_wrong_auth_token() -> Result<()> { Ok(()) } + +/// Tests that scanning a QR code week later +/// allows Bob to establish a contact with Alice, +/// but does not mark Bob as verified for Alice. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_expired_contact_auth_token() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + // Alice creates a QR code. + let qr = get_securejoin_qr(alice, None).await?; + + // One week passes, QR code expires. + SystemTime::shift(Duration::from_secs(7 * 24 * 3600)); + + // Bob scans the QR code. + join_securejoin(bob, &qr).await?; + + // vc-request + alice.recv_msg_trash(&bob.pop_sent_msg().await).await; + + // vc-auth-requried + bob.recv_msg_trash(&alice.pop_sent_msg().await).await; + + // vc-request-with-auth + alice.recv_msg_trash(&bob.pop_sent_msg().await).await; + + // Bob should not be verified for Alice. + let contact_bob = alice.add_or_lookup_contact_no_key(bob).await; + assert_eq!(contact_bob.is_verified(alice).await.unwrap(), false); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_expired_group_auth_token() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let alice_chat_id = chat::create_group(alice, "Group").await?; + + // Alice creates a group QR code. + let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap(); + + // One week passes, QR code expires. + SystemTime::shift(Duration::from_secs(7 * 24 * 3600)); + + // Bob scans the QR code. + join_securejoin(bob, &qr).await?; + + // vg-request + alice.recv_msg_trash(&bob.pop_sent_msg().await).await; + + // vg-auth-requried + bob.recv_msg_trash(&alice.pop_sent_msg().await).await; + + // vg-request-with-auth + alice.recv_msg_trash(&bob.pop_sent_msg().await).await; + + // vg-member-added + let bob_member_added_msg = bob.recv_msg(&alice.pop_sent_msg().await).await; + assert!(bob_member_added_msg.is_info()); + assert_eq!( + bob_member_added_msg.get_info_type(), + SystemMessage::MemberAddedToGroup + ); + + // Bob should not be verified for Alice. + let contact_bob = alice.add_or_lookup_contact_no_key(bob).await; + assert_eq!(contact_bob.is_verified(alice).await.unwrap(), false); + + Ok(()) +} + +/// Tests that old token is considered expired +/// even if sync message just arrived. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_expired_synced_auth_token() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let alice2 = &tcm.alice().await; + let bob = &tcm.bob().await; + + alice.set_config_bool(Config::SyncMsgs, true).await?; + alice2.set_config_bool(Config::SyncMsgs, true).await?; + + // Alice creates a QR code on the second device. + let qr = get_securejoin_qr(alice2, None).await?; + + alice2.send_sync_msg().await.unwrap(); + let sync_msg = alice2.pop_sent_sync_msg().await; + + // One week passes, QR code expires. + SystemTime::shift(Duration::from_secs(7 * 24 * 3600)); + + alice.recv_msg_trash(&sync_msg).await; + + // Bob scans the QR code. + join_securejoin(bob, &qr).await?; + + // vc-request + alice.recv_msg_trash(&bob.pop_sent_msg().await).await; + + // vc-auth-requried + bob.recv_msg_trash(&alice.pop_sent_msg().await).await; + + // vc-request-with-auth + alice.recv_msg_trash(&bob.pop_sent_msg().await).await; + + // Bob should not be verified for Alice. + let contact_bob = alice.add_or_lookup_contact_no_key(bob).await; + assert_eq!(contact_bob.is_verified(alice).await.unwrap(), false); + + Ok(()) +} diff --git a/src/smtp.rs b/src/smtp.rs index 100b567c24..76908096b3 100644 --- a/src/smtp.rs +++ b/src/smtp.rs @@ -87,7 +87,7 @@ impl Smtp { return Ok(()); } - self.connectivity.set_connecting(context).await; + self.connectivity.set_connecting(context); let lp = ConfiguredLoginParam::load(context) .await? .context("Not configured")?; @@ -187,7 +187,7 @@ pub(crate) async fn smtp_send( info!(context, "SMTP-sending out mime message:\n{message}"); } - smtp.connectivity.set_working(context).await; + smtp.connectivity.set_working(context); if let Err(err) = smtp .connect_configured(context) @@ -242,7 +242,7 @@ pub(crate) async fn smtp_send( // Yandex error "554 5.7.1 [2] Message rejected under suspicion of SPAM; https://ya.cc/..." // should definitely go here, because user has to open the link to // resume message sending. - SendResult::Failure(format_err!("Permanent SMTP error: {}", err)) + SendResult::Failure(format_err!("Permanent SMTP error: {err}")) } } async_smtp::error::Error::Transient(ref response) => { @@ -471,7 +471,7 @@ pub(crate) async fn send_msg_to_smtp( } Ok(()) } - SendResult::Failure(err) => Err(format_err!("{}", err)), + SendResult::Failure(err) => Err(format_err!("{err}")), } } @@ -586,7 +586,7 @@ async fn send_mdn_rfc724_mid( let addr = contact.get_addr(); let recipient = async_smtp::EmailAddress::new(addr.to_string()) - .map_err(|err| format_err!("invalid recipient: {} {:?}", addr, err))?; + .map_err(|err| format_err!("invalid recipient: {addr} {err:?}"))?; let recipients = vec![recipient]; match smtp_send(context, &recipients, &body, smtp, None).await { diff --git a/src/smtp/connect.rs b/src/smtp/connect.rs index 8da6f15f0e..f7b35d6579 100644 --- a/src/smtp/connect.rs +++ b/src/smtp/connect.rs @@ -12,20 +12,20 @@ use crate::login_param::{ConnectionCandidate, ConnectionSecurity}; use crate::net::dns::{lookup_host_with_cache, update_connect_timestamp}; use crate::net::proxy::ProxyConfig; use crate::net::session::SessionBufStream; -use crate::net::tls::wrap_tls; +use crate::net::tls::{TlsSessionStore, wrap_tls}; use crate::net::{ connect_tcp_inner, connect_tls_inner, run_connection_attempts, update_connection_history, }; use crate::oauth2::get_oauth2_access_token; use crate::tools::time; -/// Converts port number to ALPN list. -fn alpn(port: u16) -> &'static [&'static str] { +/// Converts port number to ALPN. +fn alpn(port: u16) -> &'static str { if port == 465 { // Do not request ALPN on standard port. - &[] + "" } else { - &["smtp"] + "smtp" } } @@ -69,7 +69,7 @@ pub(crate) async fn connect_and_auth( .await .context("SMTP failed to get OAUTH2 access token")?; if access_token.is_none() { - bail!("SMTP OAuth 2 error {}", addr); + bail!("SMTP OAuth 2 error {addr}"); } ( async_smtp::authentication::Credentials::new( @@ -109,8 +109,12 @@ async fn connection_attempt( "Attempting SMTP connection to {host} ({resolved_addr})." ); let res = match security { - ConnectionSecurity::Tls => connect_secure(resolved_addr, host, strict_tls).await, - ConnectionSecurity::Starttls => connect_starttls(resolved_addr, host, strict_tls).await, + ConnectionSecurity::Tls => { + connect_secure(resolved_addr, host, strict_tls, &context.tls_session_store).await + } + ConnectionSecurity::Starttls => { + connect_starttls(resolved_addr, host, strict_tls, &context.tls_session_store).await + } ConnectionSecurity::Plain => connect_insecure(resolved_addr).await, }; match res { @@ -226,7 +230,15 @@ async fn connect_secure_proxy( let proxy_stream = proxy_config .connect(context, hostname, port, strict_tls) .await?; - let tls_stream = wrap_tls(strict_tls, hostname, alpn(port), proxy_stream).await?; + let tls_stream = wrap_tls( + strict_tls, + hostname, + port, + alpn(port), + proxy_stream, + &context.tls_session_store, + ) + .await?; let mut buffered_stream = BufStream::new(tls_stream); skip_smtp_greeting(&mut buffered_stream).await?; let session_stream: Box = Box::new(buffered_stream); @@ -249,9 +261,16 @@ async fn connect_starttls_proxy( skip_smtp_greeting(&mut buffered_stream).await?; let transport = new_smtp_transport(buffered_stream).await?; let tcp_stream = transport.starttls().await?.into_inner(); - let tls_stream = wrap_tls(strict_tls, hostname, &[], tcp_stream) - .await - .context("STARTTLS upgrade failed")?; + let tls_stream = wrap_tls( + strict_tls, + hostname, + port, + "", + tcp_stream, + &context.tls_session_store, + ) + .await + .context("STARTTLS upgrade failed")?; let buffered_stream = BufStream::new(tls_stream); let session_stream: Box = Box::new(buffered_stream); Ok(session_stream) @@ -274,8 +293,16 @@ async fn connect_secure( addr: SocketAddr, hostname: &str, strict_tls: bool, + tls_session_store: &TlsSessionStore, ) -> Result> { - let tls_stream = connect_tls_inner(addr, hostname, strict_tls, alpn(addr.port())).await?; + let tls_stream = connect_tls_inner( + addr, + hostname, + strict_tls, + alpn(addr.port()), + tls_session_store, + ) + .await?; let mut buffered_stream = BufStream::new(tls_stream); skip_smtp_greeting(&mut buffered_stream).await?; let session_stream: Box = Box::new(buffered_stream); @@ -286,6 +313,7 @@ async fn connect_starttls( addr: SocketAddr, host: &str, strict_tls: bool, + tls_session_store: &TlsSessionStore, ) -> Result> { let tcp_stream = connect_tcp_inner(addr).await?; @@ -294,9 +322,16 @@ async fn connect_starttls( skip_smtp_greeting(&mut buffered_stream).await?; let transport = new_smtp_transport(buffered_stream).await?; let tcp_stream = transport.starttls().await?.into_inner(); - let tls_stream = wrap_tls(strict_tls, host, &[], tcp_stream) - .await - .context("STARTTLS upgrade failed")?; + let tls_stream = wrap_tls( + strict_tls, + host, + addr.port(), + "", + tcp_stream, + tls_session_store, + ) + .await + .context("STARTTLS upgrade failed")?; let buffered_stream = BufStream::new(tls_stream); let session_stream: Box = Box::new(buffered_stream); diff --git a/src/sql.rs b/src/sql.rs index 16077af5ee..1a9ed5c874 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use anyhow::{Context as _, Result, bail}; +use anyhow::{Context as _, Result, bail, ensure}; use rusqlite::{Connection, OpenFlags, Row, config::DbConfig, types::ValueRef}; use tokio::sync::RwLock; @@ -13,7 +13,6 @@ use crate::config::Config; use crate::constants::DC_CHAT_ID_TRASH; use crate::context::Context; use crate::debug_logging::set_debug_logging_xdc; -use crate::ensure_and_debug_assert; use crate::ephemeral::start_ephemeral_timers; use crate::imex::BLOBS_BACKUP_NAME; use crate::location::delete_orphaned_poi_locations; @@ -24,7 +23,7 @@ use crate::net::http::http_cache_cleanup; use crate::net::prune_connection_history; use crate::param::{Param, Params}; use crate::stock_str; -use crate::tools::{SystemTime, delete_file, time}; +use crate::tools::{SystemTime, Time, delete_file, time, time_elapsed}; /// Extension to [`rusqlite::ToSql`] trait /// which also includes [`Send`] and [`Sync`]. @@ -180,7 +179,7 @@ impl Sql { /// Creates a new connection pool. fn new_pool(dbfile: &Path, passphrase: String) -> Result { - let mut connections = Vec::new(); + let mut connections = Vec::with_capacity(Self::N_DB_CONNECTIONS); for _ in 0..Self::N_DB_CONNECTIONS { let connection = new_connection(dbfile, &passphrase)?; connections.push(connection); @@ -642,28 +641,74 @@ impl Sql { } /// Runs a checkpoint operation in TRUNCATE mode, so the WAL file is truncated to 0 bytes. - pub(crate) async fn wal_checkpoint(&self) -> Result<()> { - let lock = self.pool.read().await; - let pool = lock.as_ref().context("No SQL connection pool")?; - let mut conns = Vec::new(); + pub(crate) async fn wal_checkpoint(context: &Context) -> Result<()> { + let t_start = Time::now(); + let lock = context.sql.pool.read().await; + let Some(pool) = lock.as_ref() else { + // No db connections, nothing to checkpoint. + return Ok(()); + }; + + // Do as much work as possible without blocking anybody. let query_only = true; + let conn = pool.get(query_only).await?; + tokio::task::block_in_place(|| { + // Execute some transaction causing the WAL file to be opened so that the + // `wal_checkpoint()` can proceed, otherwise it fails when called the first time, + // see https://sqlite.org/forum/forumpost/7512d76a05268fc8. + conn.query_row("PRAGMA table_list", [], |_| Ok(()))?; + conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |_| Ok(())) + })?; + + // Kick out writers. + const _: () = assert!(Sql::N_DB_CONNECTIONS > 1, "Deadlock possible"); + let _write_lock = pool.write_lock().await; + let t_writers_blocked = Time::now(); + // Ensure that all readers use the most recent database snapshot (are at the end of WAL) so + // that `wal_checkpoint(FULL)` isn't blocked. We could use `PASSIVE` as well, but it's + // documented poorly, https://www.sqlite.org/pragma.html#pragma_wal_checkpoint and + // https://www.sqlite.org/c3ref/wal_checkpoint_v2.html don't tell how it interacts with new + // readers. + let mut read_conns = Vec::with_capacity(Self::N_DB_CONNECTIONS - 1); + for _ in 0..(Self::N_DB_CONNECTIONS - 1) { + read_conns.push(pool.get(query_only).await?); + } + read_conns.clear(); + // Checkpoint the remaining WAL pages without blocking readers. + let (pages_total, pages_checkpointed) = tokio::task::block_in_place(|| { + conn.query_row("PRAGMA wal_checkpoint(FULL)", [], |row| { + let pages_total: i64 = row.get(1)?; + let pages_checkpointed: i64 = row.get(2)?; + Ok((pages_total, pages_checkpointed)) + }) + })?; + if pages_checkpointed < pages_total { + warn!( + context, + "Cannot checkpoint whole WAL. Pages total: {pages_total}, checkpointed: {pages_checkpointed}. Make sure there are no external connections running transactions.", + ); + } // Kick out readers to avoid blocking/SQLITE_BUSY. for _ in 0..(Self::N_DB_CONNECTIONS - 1) { - conns.push(pool.get(query_only).await?); + read_conns.push(pool.get(query_only).await?); } - let conn = pool.get(query_only).await?; - tokio::task::block_in_place(move || { - // Execute some transaction causing the WAL file to be opened so that the - // `wal_checkpoint()` can proceed, otherwise it fails when called the first time, see - // https://sqlite.org/forum/forumpost/7512d76a05268fc8. - conn.query_row("PRAGMA table_list", [], |_row| Ok(()))?; + let t_readers_blocked = Time::now(); + tokio::task::block_in_place(|| { let blocked = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { let blocked: i64 = row.get(0)?; Ok(blocked) })?; - ensure_and_debug_assert!(blocked == 0,); + ensure!(blocked == 0); Ok(()) - }) + })?; + info!( + context, + "wal_checkpoint: Total time: {:?}. Writers blocked for: {:?}. Readers blocked for: {:?}.", + time_elapsed(&t_start), + time_elapsed(&t_writers_blocked), + time_elapsed(&t_readers_blocked), + ); + Ok(()) } } @@ -792,8 +837,9 @@ pub async fn housekeeping(context: &Context) -> Result<()> { // bigger than 200M) and also make sure we truncate the WAL periodically. Auto-checkponting does // not normally truncate the WAL (unless the `journal_size_limit` pragma is set), see // https://www.sqlite.org/wal.html. - if let Err(err) = context.sql.wal_checkpoint().await { + if let Err(err) = Sql::wal_checkpoint(context).await { warn!(context, "wal_checkpoint() failed: {err:#}."); + debug_assert!(false); } context diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index 4a7c40e911..54aac6506b 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -19,7 +19,7 @@ use crate::key::DcKey; use crate::log::{info, warn}; use crate::login_param::ConfiguredLoginParam; use crate::message::MsgId; -use crate::provider::get_provider_by_domain; +use crate::provider::get_provider_info; use crate::sql::Sql; use crate::tools::{Time, inc_and_check, time_elapsed}; @@ -382,7 +382,7 @@ UPDATE chats SET protected=1, type=120 WHERE type=130;"#, context .set_config_internal( Config::ConfiguredProvider, - get_provider_by_domain(&domain).map(|provider| provider.id), + get_provider_info(&domain).map(|provider| provider.id), ) .await?; } else { @@ -1261,6 +1261,16 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint); .await?; } + inc_and_check(&mut migration_version, 134)?; + if dbversion < migration_version { + // Reset all indirect verifications. + sql.execute_migration( + "UPDATE contacts SET verifier=0 WHERE verifier!=1", + migration_version, + ) + .await?; + } + let new_version = sql .get_raw_config_int(VERSION_CFG) .await? diff --git a/src/sql/migrations/migrations_tests.rs b/src/sql/migrations/migrations_tests.rs index d3ed58898f..0517f12e51 100644 --- a/src/sql/migrations/migrations_tests.rs +++ b/src/sql/migrations/migrations_tests.rs @@ -122,7 +122,7 @@ async fn test_key_contacts_migration_email2() -> Result<()> { .await? .is_empty() ); - let pgp_bob = Contact::get_by_id(&t, ContactId::new(11)).await?; + let pgp_bob = Contact::get_by_id(&t, ContactId::new(11001)).await?; assert_eq!(pgp_bob.is_key_contact(), true); assert_eq!(pgp_bob.origin, Origin::Hidden); @@ -159,7 +159,10 @@ async fn test_key_contacts_migration_verified() -> Result<()> { INSERT INTO chats_contacts VALUES(10,10,1745609547,0); "#, )?)).await?; - t.sql.run_migrations(&t).await?; + + STOP_MIGRATIONS_AT + .scope(133, t.sql.run_migrations(&t)) + .await?; // Hidden address-contact can't be looked up. assert!( diff --git a/src/sql/pool.rs b/src/sql/pool.rs index e1fc4dc74c..01f19b7515 100644 --- a/src/sql/pool.rs +++ b/src/sql/pool.rs @@ -67,7 +67,7 @@ struct InnerPool { /// /// This mutex is locked when write connection /// is outside the pool. - write_mutex: Arc>, + pub(crate) write_mutex: Arc>, } impl InnerPool { @@ -96,13 +96,13 @@ impl InnerPool { .pop() .context("Got a permit when there are no connections in the pool")? }; - conn.pragma_update(None, "query_only", "1")?; let conn = PooledConnection { pool: Arc::downgrade(&self), conn: Some(conn), _permit: permit, _write_mutex_guard: None, }; + conn.pragma_update(None, "query_only", "1")?; Ok(conn) } else { // We get write guard first to avoid taking a permit @@ -119,13 +119,13 @@ impl InnerPool { "Got a permit and write lock when there are no connections in the pool", )? }; - conn.pragma_update(None, "query_only", "0")?; let conn = PooledConnection { pool: Arc::downgrade(&self), conn: Some(conn), _permit: permit, _write_mutex_guard: Some(write_mutex_guard), }; + conn.pragma_update(None, "query_only", "0")?; Ok(conn) } } @@ -195,4 +195,12 @@ impl Pool { pub async fn get(&self, query_only: bool) -> Result { Arc::clone(&self.inner).get(query_only).await } + + /// Returns a mutex guard guaranteeing that there are no concurrent write connections. + /// + /// NB: Make sure you're not holding all connections when calling this, otherwise it deadlocks + /// if there is a concurrent writer waiting for available connection. + pub(crate) async fn write_lock(&self) -> OwnedMutexGuard<()> { + Arc::clone(&self.inner.write_mutex).lock_owned().await + } } diff --git a/src/stock_str.rs b/src/stock_str.rs index acc3099e0f..2f778324da 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -11,7 +11,7 @@ use tokio::sync::RwLock; use crate::accounts::Accounts; use crate::blob::BlobObject; -use crate::chat::{self, Chat, ChatId, ProtectionStatus}; +use crate::chat::{self, Chat, ChatId}; use crate::config::Config; use crate::contact::{Contact, ContactId, Origin}; use crate::context::Context; @@ -123,9 +123,6 @@ pub enum StockMessage { however, of course, if they like, you may point them to πŸ‘‰ https://get.delta.chat"))] WelcomeMessage = 71, - #[strum(props(fallback = "Unknown sender for this chat."))] - UnknownSenderForChat = 72, - #[strum(props(fallback = "Message from %1$s"))] SubjectForNewContact = 73, @@ -133,12 +130,6 @@ pub enum StockMessage { #[strum(props(fallback = "Failed to send message to %1$s."))] FailedSendingTo = 74, - #[strum(props(fallback = "Video chat invitation"))] - VideochatInvitation = 82, - - #[strum(props(fallback = "You are invited to a video chat, click %1$s to join."))] - VideochatInviteMsgBody = 83, - #[strum(props(fallback = "Error:\n\nβ€œ%1$s”"))] ConfigurationFailed = 84, @@ -282,7 +273,7 @@ pub enum StockMessage { #[strum(props(fallback = "Member %1$s removed by %2$s."))] MsgDelMemberBy = 131, - #[strum(props(fallback = "You left."))] + #[strum(props(fallback = "You left the group."))] MsgYouLeftGroup = 132, #[strum(props(fallback = "Group left by %1$s."))] @@ -427,6 +418,27 @@ Help keeping us to keep Delta Chat independent and make it more awesome in the f https://delta.chat/donate"))] DonationRequest = 193, + + #[strum(props(fallback = "Outgoing call"))] + OutgoingCall = 194, + + #[strum(props(fallback = "Incoming call"))] + IncomingCall = 195, + + #[strum(props(fallback = "Declined call"))] + DeclinedCall = 196, + + #[strum(props(fallback = "Canceled call"))] + CanceledCall = 197, + + #[strum(props(fallback = "Missed call"))] + MissedCall = 198, + + #[strum(props(fallback = "You left the channel."))] + MsgYouLeftBroadcast = 200, + + #[strum(props(fallback = "Scan to join channel %1$s"))] + SecureJoinBrodcastQRDescription = 201, } impl StockMessage { @@ -699,7 +711,7 @@ pub(crate) async fn msg_group_left_remote(context: &Context) -> String { translated(context, StockMessage::MsgILeftGroup).await } -/// Stock string: `You left.` or `Group left by %1$s.`. +/// Stock string: `You left the group.` or `Group left by %1$s.`. pub(crate) async fn msg_group_left_local(context: &Context, by_contact: ContactId) -> String { if by_contact == ContactId::SELF { translated(context, StockMessage::MsgYouLeftGroup).await @@ -710,6 +722,11 @@ pub(crate) async fn msg_group_left_local(context: &Context, by_contact: ContactI } } +/// Stock string: `You left the channel.` +pub(crate) async fn msg_you_left_broadcast(context: &Context) -> String { + translated(context, StockMessage::MsgYouLeftBroadcast).await +} + /// Stock string: `You reacted %1$s to "%2$s"` or `%1$s reacted %2$s to "%3$s"`. pub(crate) async fn msg_reacted( context: &Context, @@ -804,6 +821,31 @@ pub(crate) async fn donation_request(context: &Context) -> String { translated(context, StockMessage::DonationRequest).await } +/// Stock string: `Outgoing call`. +pub(crate) async fn outgoing_call(context: &Context) -> String { + translated(context, StockMessage::OutgoingCall).await +} + +/// Stock string: `Incoming call`. +pub(crate) async fn incoming_call(context: &Context) -> String { + translated(context, StockMessage::IncomingCall).await +} + +/// Stock string: `Declined call`. +pub(crate) async fn declined_call(context: &Context) -> String { + translated(context, StockMessage::DeclinedCall).await +} + +/// Stock string: `Canceled call`. +pub(crate) async fn canceled_call(context: &Context) -> String { + translated(context, StockMessage::CanceledCall).await +} + +/// Stock string: `Missed call`. +pub(crate) async fn missed_call(context: &Context) -> String { + translated(context, StockMessage::MissedCall).await +} + /// Stock string: `Scan to chat with %1$s`. pub(crate) async fn setup_contact_qr_description( context: &Context, @@ -820,13 +862,20 @@ pub(crate) async fn setup_contact_qr_description( .replace1(&name) } -/// Stock string: `Scan to join %1$s`. +/// Stock string: `Scan to join group %1$s`. pub(crate) async fn secure_join_group_qr_description(context: &Context, chat: &Chat) -> String { translated(context, StockMessage::SecureJoinGroupQRDescription) .await .replace1(chat.get_name()) } +/// Stock string: `Scan to join channel %1$s`. +pub(crate) async fn secure_join_broadcast_qr_description(context: &Context, chat: &Chat) -> String { + translated(context, StockMessage::SecureJoinBrodcastQRDescription) + .await + .replace1(chat.get_name()) +} + /// Stock string: `%1$s verified.`. #[allow(dead_code)] pub(crate) async fn contact_verified(context: &Context, contact: &Contact) -> String { @@ -909,11 +958,6 @@ pub(crate) async fn welcome_message(context: &Context) -> String { translated(context, StockMessage::WelcomeMessage).await } -/// Stock string: `Unknown sender for this chat.`. -pub(crate) async fn unknown_sender_for_chat(context: &Context) -> String { - translated(context, StockMessage::UnknownSenderForChat).await -} - /// Stock string: `Message from %1$s`. // TODO: This can compute `self_name` itself instead of asking the caller to do this. pub(crate) async fn subject_for_new_contact(context: &Context, self_name: &str) -> String { @@ -1009,18 +1053,6 @@ pub(crate) async fn msg_ephemeral_timer_year(context: &Context, by_contact: Cont } } -/// Stock string: `Video chat invitation`. -pub(crate) async fn videochat_invitation(context: &Context) -> String { - translated(context, StockMessage::VideochatInvitation).await -} - -/// Stock string: `You are invited to a video chat, click %1$s to join.`. -pub(crate) async fn videochat_invite_msg_body(context: &Context, url: &str) -> String { - translated(context, StockMessage::VideochatInviteMsgBody) - .await - .replace1(url) -} - /// Stock string: `Error:\n\nβ€œ%1$s”`. pub(crate) async fn configuration_failed(context: &Context, details: &str) -> String { translated(context, StockMessage::ConfigurationFailed) @@ -1051,13 +1083,6 @@ pub(crate) async fn messages_e2e_encrypted(context: &Context) -> String { translated(context, StockMessage::ChatProtectionEnabled).await } -/// Stock string: `%1$s sent a message from another device.` -pub(crate) async fn chat_protection_disabled(context: &Context, contact_id: ContactId) -> String { - translated(context, StockMessage::ChatProtectionDisabled) - .await - .replace1(&contact_id.get_stock_name(context).await) -} - /// Stock string: `Reply`. pub(crate) async fn reply_noun(context: &Context) -> String { translated(context, StockMessage::ReplyNoun).await @@ -1302,26 +1327,6 @@ impl Context { Ok(()) } - /// Returns a stock message saying that protection status has changed. - pub(crate) async fn stock_protection_msg( - &self, - protect: ProtectionStatus, - contact_id: Option, - ) -> String { - match protect { - ProtectionStatus::Unprotected => { - if let Some(contact_id) = contact_id { - chat_protection_disabled(self, contact_id).await - } else { - // In a group chat, it's not possible to downgrade verification. - // In a 1:1 chat, the `contact_id` always has to be provided. - "[Error] No contact_id given".to_string() - } - } - ProtectionStatus::Protected => messages_e2e_encrypted(self).await, - } - } - pub(crate) async fn update_device_chats(&self) -> Result<()> { if self.get_config_bool(Config::Bot).await? { return Ok(()); diff --git a/src/summary.rs b/src/summary.rs index 6601e89825..90ddd1e35e 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -4,6 +4,7 @@ use std::borrow::Cow; use std::fmt; use std::str; +use crate::calls::{CallState, call_state}; use crate::chat::Chat; use crate::constants::Chattype; use crate::contact::{Contact, ContactId}; @@ -97,7 +98,7 @@ impl Summary { let prefix = if msg.state == MessageState::OutDraft { Some(SummaryPrefix::Draft(stock_str::draft(context).await)) } else if msg.from_id == ContactId::SELF { - if msg.is_info() { + if msg.is_info() || msg.viewtype == Viewtype::Call { None } else { Some(SummaryPrefix::Me(stock_str::self_msg(context).await)) @@ -210,12 +211,6 @@ impl Message { type_file = self.get_filename(); append_text = true } - Viewtype::VideochatInvitation => { - emoji = None; - type_name = Some(stock_str::videochat_invitation(context).await); - type_file = None; - append_text = false; - } Viewtype::Webxdc => { emoji = None; type_name = None; @@ -233,6 +228,26 @@ impl Message { type_file = self.param.get(Param::Summary1).map(|s| s.to_string()); append_text = true; } + Viewtype::Call => { + let call_state = call_state(context, self.id) + .await + .unwrap_or(CallState::Alerting); + emoji = Some("πŸ“ž"); + type_name = Some(match call_state { + CallState::Alerting | CallState::Active | CallState::Completed { .. } => { + if self.from_id == ContactId::SELF { + stock_str::outgoing_call(context).await + } else { + stock_str::incoming_call(context).await + } + } + CallState::Missed => stock_str::missed_call(context).await, + CallState::Declined => stock_str::declined_call(context).await, + CallState::Canceled => stock_str::canceled_call(context).await, + }); + type_file = None; + append_text = false + } Viewtype::Text | Viewtype::Unknown => { emoji = None; if self.param.get_cmd() == SystemMessage::LocationOnly { @@ -407,13 +422,6 @@ mod tests { .unwrap(); assert_summary_texts(&msg, ctx, "πŸ“Ž foo.bar \u{2013} bla bla").await; // file name is added for files - let file = write_file_to_blobdir(&d).await; - let mut msg = Message::new(Viewtype::VideochatInvitation); - msg.set_text(some_text.clone()); - msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None) - .unwrap(); - assert_summary_texts(&msg, ctx, "Video chat invitation").await; // text is not added for videochat invitations - let mut msg = Message::new(Viewtype::Vcard); msg.set_file_from_bytes(ctx, "foo.vcf", b"", None).unwrap(); chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap(); diff --git a/src/sync.rs b/src/sync.rs index 90e302f06b..26ff540501 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -253,13 +253,19 @@ impl Context { /// If an error is returned, the caller shall not try over because some sync items could be /// already executed. Sync items are considered independent and executed in the given order but /// regardless of whether executing of the previous items succeeded. - pub(crate) async fn execute_sync_items(&self, items: &SyncItems) { + pub(crate) async fn execute_sync_items(&self, items: &SyncItems, timestamp_sent: i64) { info!(self, "executing {} sync item(s)", items.items.len()); for item in &items.items { + // Limit the timestamp to ensure it is not in the future. + // + // `sent_timestamp` should be already corrected + // if the `Date` header is in the future. + let timestamp = std::cmp::min(item.timestamp, timestamp_sent); + match &item.data { SyncDataOrUnknown::SyncData(data) => match data { - AddQrToken(token) => self.add_qr_token(token).await, - DeleteQrToken(token) => self.delete_qr_token(token).await, + AddQrToken(token) => self.add_qr_token(token, timestamp).await, + DeleteQrToken(token) => self.delete_qr_token(token, timestamp).await, AlterChat { id, action } => self.sync_alter_chat(id, action).await, SyncData::Config { key, val } => self.sync_config(key, val).await, SyncData::SaveMessage { src, dest } => self.save_message(src, dest).await, @@ -284,16 +290,30 @@ impl Context { } } - async fn add_qr_token(&self, token: &QrTokenData) -> Result<()> { + async fn add_qr_token(&self, token: &QrTokenData, timestamp: i64) -> Result<()> { let grpid = token.grpid.as_deref(); - token::save(self, Namespace::InviteNumber, grpid, &token.invitenumber).await?; - token::save(self, Namespace::Auth, grpid, &token.auth).await?; + token::save( + self, + Namespace::InviteNumber, + grpid, + &token.invitenumber, + timestamp, + ) + .await?; + token::save(self, Namespace::Auth, grpid, &token.auth, timestamp).await?; Ok(()) } - async fn delete_qr_token(&self, token: &QrTokenData) -> Result<()> { - token::delete(self, Namespace::InviteNumber, &token.invitenumber).await?; - token::delete(self, Namespace::Auth, &token.auth).await?; + async fn delete_qr_token(&self, token: &QrTokenData, timestamp: i64) -> Result<()> { + self.sql + .execute( + "DELETE FROM tokens + WHERE foreign_key IN + (SELECT foreign_key FROM tokens + WHERE token=? OR token=? AND timestamp <= ?)", + (&token.invitenumber, &token.auth, timestamp), + ) + .await?; Ok(()) } @@ -332,7 +352,7 @@ mod tests { use anyhow::bail; use super::*; - use crate::chat::{Chat, ProtectionStatus, remove_contact_from_chat}; + use crate::chat::{Chat, remove_contact_from_chat}; use crate::chatlist::Chatlist; use crate::contact::{Contact, Origin}; use crate::securejoin::get_securejoin_qr; @@ -543,6 +563,7 @@ mod tests { assert!(!token::exists(&t, Namespace::Auth, "yip-auth").await?); + let timestamp_sent = time(); let sync_items = t .parse_sync_items( r#"{"items":[ @@ -557,15 +578,15 @@ mod tests { .to_string(), ) ?; - t.execute_sync_items(&sync_items).await; + t.execute_sync_items(&sync_items, timestamp_sent).await; assert!( Contact::lookup_id_by_addr(&t, "bob@example.net", Origin::Unknown) .await? .is_none() ); - assert!(token::exists(&t, Namespace::InviteNumber, "yip-in").await?); - assert!(token::exists(&t, Namespace::Auth, "yip-auth").await?); + assert!(!token::exists(&t, Namespace::InviteNumber, "yip-in").await?); + assert!(!token::exists(&t, Namespace::Auth, "yip-auth").await?); assert!(!token::exists(&t, Namespace::Auth, "non-existent").await?); assert!(!token::exists(&t, Namespace::Auth, "directly deleted").await?); @@ -707,8 +728,7 @@ mod tests { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; alice.set_config_bool(Config::SyncMsgs, true).await?; - let alice_chatid = - chat::create_group_chat(alice, ProtectionStatus::Protected, "the chat").await?; + let alice_chatid = chat::create_group(alice, "the chat").await?; let qr = get_securejoin_qr(alice, Some(alice_chatid)).await?; // alice2 syncs the QR code token. diff --git a/src/test_utils.rs b/src/test_utils.rs index 6900cc0d24..91904333aa 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -21,8 +21,7 @@ use tokio::runtime::Handle; use tokio::{fs, task}; use crate::chat::{ - self, Chat, ChatId, ChatIdBlocked, MessageListOptions, ProtectionStatus, - add_to_chat_contacts_table, create_group_chat, + self, Chat, ChatId, ChatIdBlocked, MessageListOptions, add_to_chat_contacts_table, create_group, }; use crate::chatlist::Chatlist; use crate::config::Config; @@ -35,7 +34,7 @@ use crate::context::Context; use crate::events::{Event, EventEmitter, EventType, Events}; use crate::key::{self, DcKey, DcSecretKey, self_fingerprint}; use crate::log::warn; -use crate::message::{Message, MessageState, MsgId, Viewtype, update_msg_state}; +use crate::message::{Message, MessageState, MsgId, update_msg_state}; use crate::mimeparser::{MimeMessage, SystemMessage}; use crate::pgp::KeyPair; use crate::receive_imf::receive_imf; @@ -84,6 +83,7 @@ impl TestContextManager { pub async fn alice(&mut self) -> TestContext { TestContext::builder() .configure_alice() + .with_id_offset(1000) .with_log_sink(self.log_sink.clone()) .build(Some(&mut self.used_names)) .await @@ -92,6 +92,7 @@ impl TestContextManager { pub async fn bob(&mut self) -> TestContext { TestContext::builder() .configure_bob() + .with_id_offset(2000) .with_log_sink(self.log_sink.clone()) .build(Some(&mut self.used_names)) .await @@ -100,6 +101,7 @@ impl TestContextManager { pub async fn charlie(&mut self) -> TestContext { TestContext::builder() .configure_charlie() + .with_id_offset(3000) .with_log_sink(self.log_sink.clone()) .build(Some(&mut self.used_names)) .await @@ -108,6 +110,7 @@ impl TestContextManager { pub async fn dom(&mut self) -> TestContext { TestContext::builder() .configure_dom() + .with_id_offset(4000) .with_log_sink(self.log_sink.clone()) .build(Some(&mut self.used_names)) .await @@ -116,6 +119,7 @@ impl TestContextManager { pub async fn elena(&mut self) -> TestContext { TestContext::builder() .configure_elena() + .with_id_offset(5000) .with_log_sink(self.log_sink.clone()) .build(Some(&mut self.used_names)) .await @@ -124,6 +128,7 @@ impl TestContextManager { pub async fn fiona(&mut self) -> TestContext { TestContext::builder() .configure_fiona() + .with_id_offset(6000) .with_log_sink(self.log_sink.clone()) .build(Some(&mut self.used_names)) .await @@ -186,8 +191,8 @@ impl TestContextManager { msg, to.name() )); - let chat = from.create_chat(to).await; - let sent = from.send_text(chat.id, msg).await; + let chat_id = from.create_chat_id(to).await; + let sent = from.send_text(chat_id, msg).await; to.recv_msg(&sent).await } @@ -263,6 +268,11 @@ pub struct TestContextBuilder { /// so the caller should store the LogSink elsewhere to /// prevent it from being dropped immediately. log_sink: Option, + + /// Offset for chat-,message-,contact ids. + /// + /// This makes tests fail where ids from different accounts were mixed up. + id_offset: Option, } impl TestContextBuilder { @@ -328,6 +338,14 @@ impl TestContextBuilder { self } + /// Adds an offset for chat-, message-, contact IDs. + /// + /// This makes it harder to accidentally mix up IDs from different accounts. + pub fn with_id_offset(mut self, offset: u32) -> Self { + self.id_offset = Some(offset); + self + } + /// Builds the [`TestContext`]. pub async fn build(self, used_names: Option<&mut BTreeSet>) -> TestContext { if let Some(key_pair) = self.key_pair { @@ -360,6 +378,22 @@ impl TestContextBuilder { key::store_self_keypair(&test_context, &key_pair) .await .expect("Failed to save key"); + + if let Some(offset) = self.id_offset { + test_context + .ctx + .sql + .execute( + "UPDATE sqlite_sequence SET seq = ? + WHERE name = 'contacts' + OR name = 'chats' + OR name = 'msgs'", + (offset,), + ) + .await + .expect("Failed set id offset"); + } + test_context } else { TestContext::new_internal(None, self.log_sink).await @@ -409,21 +443,33 @@ impl TestContext { /// /// This is a shortcut which configures alice@example.org with a fixed key. pub async fn new_alice() -> Self { - Self::builder().configure_alice().build(None).await + Self::builder() + .configure_alice() + .with_id_offset(11000) + .build(None) + .await } /// Creates a new configured [`TestContext`]. /// /// This is a shortcut which configures bob@example.net with a fixed key. pub async fn new_bob() -> Self { - Self::builder().configure_bob().build(None).await + Self::builder() + .configure_bob() + .with_id_offset(12000) + .build(None) + .await } /// Creates a new configured [`TestContext`]. /// /// This is a shortcut which configures fiona@example.net with a fixed key. pub async fn new_fiona() -> Self { - Self::builder().configure_fiona().build(None).await + Self::builder() + .configure_fiona() + .with_id_offset(13000) + .build(None) + .await } /// Print current chat state. @@ -575,6 +621,13 @@ impl TestContext { update_msg_state(&self.ctx, msg_id, MessageState::OutDelivered) .await .expect("failed to update message state"); + self.sql + .execute( + "UPDATE msgs SET timestamp_sent=? WHERE id=?", + (time(), msg_id), + ) + .await + .expect("Failed to update timestamp_sent"); } let payload_headers = payload.split("\r\n\r\n").next().unwrap().lines(); @@ -852,14 +905,23 @@ impl TestContext { Chat::load_from_db(&self.ctx, chat_id).await.unwrap() } + /// Creates or returns an existing 1:1 [`ChatId`] with another account. + /// + /// This first creates a contact by exporting a vCard from the `other` + /// and importing it into `self`, + /// then creates a 1:1 chat with this contact. + pub async fn create_chat_id(&self, other: &TestContext) -> ChatId { + let contact_id = self.add_or_lookup_contact_id(other).await; + ChatId::create_for_contact(self, contact_id).await.unwrap() + } + /// Creates or returns an existing 1:1 [`Chat`] with another account. /// /// This first creates a contact by exporting a vCard from the `other` /// and importing it into `self`, /// then creates a 1:1 chat with this contact. pub async fn create_chat(&self, other: &TestContext) -> Chat { - let contact_id = self.add_or_lookup_contact_id(other).await; - let chat_id = ChatId::create_for_contact(self, contact_id).await.unwrap(); + let chat_id = self.create_chat_id(other).await; Chat::load_from_db(self, chat_id).await.unwrap() } @@ -997,7 +1059,7 @@ impl TestContext { }; writeln!( res, - "{}#{}: {} [{}]{}{}{} {}", + "{}#{}: {} [{}]{}{}{}", sel_chat.typ, sel_chat.get_id(), sel_chat.get_name(), @@ -1015,11 +1077,6 @@ impl TestContext { }, _ => "".to_string(), }, - if sel_chat.is_protected() { - "πŸ›‘οΈ" - } else { - "" - }, ) .unwrap(); @@ -1050,11 +1107,10 @@ impl TestContext { pub async fn create_group_with_members( &self, - protect: ProtectionStatus, chat_name: &str, members: &[&TestContext], ) -> ChatId { - let chat_id = create_group_chat(self, protect, chat_name).await.unwrap(); + let chat_id = create_group(self, chat_name).await.unwrap(); let mut to_add = vec![]; for member in members { let contact_id = self.add_or_lookup_contact_id(member).await; @@ -1236,9 +1292,8 @@ impl SentMessage<'_> { /// /// The keypair was created using the crate::key::tests::gen_key test. pub fn alice_keypair() -> KeyPair { - let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/alice-secret.asc")) - .unwrap() - .0; + let secret = + key::SignedSecretKey::from_asc(include_str!("../test-data/key/alice-secret.asc")).unwrap(); let public = secret.split_public_key().unwrap(); KeyPair { public, secret } } @@ -1247,9 +1302,8 @@ pub fn alice_keypair() -> KeyPair { /// /// Like [alice_keypair] but a different key and identity. pub fn bob_keypair() -> KeyPair { - let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/bob-secret.asc")) - .unwrap() - .0; + let secret = + key::SignedSecretKey::from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap(); let public = secret.split_public_key().unwrap(); KeyPair { public, secret } } @@ -1260,8 +1314,7 @@ pub fn bob_keypair() -> KeyPair { pub fn charlie_keypair() -> KeyPair { let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/charlie-secret.asc")) - .unwrap() - .0; + .unwrap(); let public = secret.split_public_key().unwrap(); KeyPair { public, secret } } @@ -1270,9 +1323,8 @@ pub fn charlie_keypair() -> KeyPair { /// /// Like [alice_keypair] but a different key and identity. pub fn dom_keypair() -> KeyPair { - let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/dom-secret.asc")) - .unwrap() - .0; + let secret = + key::SignedSecretKey::from_asc(include_str!("../test-data/key/dom-secret.asc")).unwrap(); let public = secret.split_public_key().unwrap(); KeyPair { public, secret } } @@ -1281,9 +1333,8 @@ pub fn dom_keypair() -> KeyPair { /// /// Like [alice_keypair] but a different key and identity. pub fn elena_keypair() -> KeyPair { - let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/elena-secret.asc")) - .unwrap() - .0; + let secret = + key::SignedSecretKey::from_asc(include_str!("../test-data/key/elena-secret.asc")).unwrap(); let public = secret.split_public_key().unwrap(); KeyPair { public, secret } } @@ -1292,9 +1343,8 @@ pub fn elena_keypair() -> KeyPair { /// /// Like [alice_keypair] but a different key and identity. pub fn fiona_keypair() -> KeyPair { - let secret = key::SignedSecretKey::from_asc(include_str!("../test-data/key/fiona-secret.asc")) - .unwrap() - .0; + let secret = + key::SignedSecretKey::from_asc(include_str!("../test-data/key/fiona-secret.asc")).unwrap(); let public = secret.split_public_key().unwrap(); KeyPair { public, secret } } @@ -1420,7 +1470,7 @@ fn print_logevent(logevent: &LogEvent) { /// Saves the other account's public key as verified pub(crate) async fn mark_as_verified(this: &TestContext, other: &TestContext) { let contact_id = this.add_or_lookup_contact_id(other).await; - mark_contact_id_as_verified(this, contact_id, ContactId::SELF) + mark_contact_id_as_verified(this, contact_id, Some(ContactId::SELF)) .await .unwrap(); } @@ -1430,8 +1480,7 @@ pub(crate) async fn mark_as_verified(this: &TestContext, other: &TestContext) { pub(crate) async fn sync(alice0: &TestContext, alice1: &TestContext) { alice0.send_sync_msg().await.unwrap(); let sync_msg = alice0.pop_sent_sync_msg().await; - let no_msg = alice1.recv_msg_opt(&sync_msg).await; - assert!(no_msg.is_none()); + alice1.recv_msg_trash(&sync_msg).await; } /// Pretty-print an event to stdout @@ -1526,7 +1575,7 @@ async fn write_msg(context: &Context, prefix: &str, msg: &Message, buf: &mut Str let msgtext = msg.get_text(); writeln!( buf, - "{}{}{}{}: {} (Contact#{}): {} {}{}{}{}{}", + "{}{}{}{}: {} (Contact#{}): {} {}{}{}{}", prefix, msg.get_id(), if msg.get_showpadlock() { "πŸ”’" } else { "" }, @@ -1554,15 +1603,6 @@ async fn write_msg(context: &Context, prefix: &str, msg: &Message, buf: &mut Str } else { "" }, - if msg.get_viewtype() == Viewtype::VideochatInvitation { - format!( - "[VIDEOCHAT-INVITATION: {}, type={}]", - msg.get_videochat_url().unwrap_or_default(), - msg.get_videochat_type().unwrap_or_default() - ) - } else { - "".to_string() - }, if msg.is_forwarded() { "[FORWARDED]" } else { @@ -1630,4 +1670,32 @@ mod tests { let runtime = tokio::runtime::Runtime::new().expect("unable to create tokio runtime"); runtime.block_on(TestContext::new()); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_id_offset() { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + let fiona = tcm.fiona().await; + + // chat ids + let alice_bob_chat = alice.create_chat(&bob).await; + let bob_alice_chat = bob.create_chat(&alice).await; + assert_ne!(alice_bob_chat.id, bob_alice_chat.id); + + // contact ids + let alice_fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await; + let fiona_fiona_contact_id = bob.add_or_lookup_contact_id(&fiona).await; + assert_ne!(alice_fiona_contact_id, fiona_fiona_contact_id); + + // message ids + let alice_group_id = alice + .create_group_with_members("test group", &[&bob, &fiona]) + .await; + let alice_sent_msg = alice.send_text(alice_group_id, "testing").await; + let bob_received_id = bob.recv_msg(&alice_sent_msg).await; + assert_ne!(alice_sent_msg.sender_msg_id, bob_received_id.id); + let fiona_received_id = fiona.recv_msg(&alice_sent_msg).await; + assert_ne!(bob_received_id.id, fiona_received_id.id); + } } diff --git a/src/tests/aeap.rs b/src/tests/aeap.rs index 55c5342f9b..f3270f5310 100644 --- a/src/tests/aeap.rs +++ b/src/tests/aeap.rs @@ -9,7 +9,7 @@ use anyhow::Result; -use crate::chat::{self, Chat, ChatId, ProtectionStatus}; +use crate::chat::{self, Chat, ChatId}; use crate::contact::{Contact, ContactId}; use crate::message::Message; use crate::receive_imf::receive_imf; @@ -90,24 +90,12 @@ async fn check_aeap_transition(chat_for_transition: ChatForTransition, verified: } let mut groups = vec![ - chat::create_group_chat(bob, chat::ProtectionStatus::Unprotected, "Group 0") - .await - .unwrap(), - chat::create_group_chat(bob, chat::ProtectionStatus::Unprotected, "Group 1") - .await - .unwrap(), + chat::create_group(bob, "Group 0").await.unwrap(), + chat::create_group(bob, "Group 1").await.unwrap(), ]; if verified { - groups.push( - chat::create_group_chat(bob, chat::ProtectionStatus::Protected, "Group 2") - .await - .unwrap(), - ); - groups.push( - chat::create_group_chat(bob, chat::ProtectionStatus::Protected, "Group 3") - .await - .unwrap(), - ); + groups.push(chat::create_group(bob, "Group 2").await.unwrap()); + groups.push(chat::create_group(bob, "Group 3").await.unwrap()); } let alice_contact = bob.add_or_lookup_contact_id(alice).await; @@ -201,8 +189,7 @@ async fn test_aeap_replay_attack() -> Result<()> { tcm.send_recv_accept(&alice, &bob, "Hi").await; tcm.send_recv(&bob, &alice, "Hi back").await; - let group = - chat::create_group_chat(&bob, chat::ProtectionStatus::Unprotected, "Group 0").await?; + let group = chat::create_group(&bob, "Group 0").await?; let bob_alice_contact = bob.add_or_lookup_contact_id(&alice).await; let bob_fiona_contact = bob.add_or_lookup_contact_id(&fiona).await; @@ -217,10 +204,12 @@ async fn test_aeap_replay_attack() -> Result<()> { // Fiona gets the message, replaces the From addr... let sent = sent .payload() - .replace("From: ", "From: ") - .replace("addr=alice@example.org;", "addr=fiona@example.net;"); + .replace("From: ", "From: "); sent.find("From: ").unwrap(); // Assert that it worked - sent.find("addr=fiona@example.net;").unwrap(); // Assert that it worked + + // Autocrypt header is protected, nothing to replace outside. + // In the signed part we cannot replace it without breaking the signature. + assert!(!sent.contains("addr=alice@example.org;")); tcm.section("Fiona replaced the From addr and forwards the message to Bob"); receive_imf(&bob, sent.as_bytes(), false).await?.unwrap(); @@ -243,16 +232,13 @@ async fn test_write_to_alice_after_aeap() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; - let alice_grp_id = chat::create_group_chat(alice, ProtectionStatus::Protected, "Group").await?; + let alice_grp_id = chat::create_group(alice, "Group").await?; let qr = get_securejoin_qr(alice, Some(alice_grp_id)).await?; tcm.exec_securejoin_qr(bob, alice, &qr).await; let bob_alice_contact = bob.add_or_lookup_contact(alice).await; assert!(bob_alice_contact.is_verified(bob).await?); let bob_alice_chat = bob.create_chat(alice).await; - assert!(bob_alice_chat.is_protected()); - let bob_unprotected_grp_id = bob - .create_group_with_members(ProtectionStatus::Unprotected, "Group", &[alice]) - .await; + let bob_unprotected_grp_id = bob.create_group_with_members("Group", &[alice]).await; tcm.change_addr(alice, "alice@someotherdomain.xyz").await; let sent = alice.send_text(alice_grp_id, "Hello!").await; @@ -260,7 +246,6 @@ async fn test_write_to_alice_after_aeap() -> Result<()> { assert!(bob_alice_contact.is_verified(bob).await?); let bob_alice_chat = Chat::load_from_db(bob, bob_alice_chat.id).await?; - assert!(bob_alice_chat.is_protected()); let mut msg = Message::new_text("hi".to_string()); chat::send_msg(bob, bob_alice_chat.id, &mut msg).await?; diff --git a/src/tests/verified_chats.rs b/src/tests/verified_chats.rs index 84088b6ada..c120d73baa 100644 --- a/src/tests/verified_chats.rs +++ b/src/tests/verified_chats.rs @@ -2,9 +2,7 @@ use anyhow::Result; use pretty_assertions::assert_eq; use crate::chat::resend_msgs; -use crate::chat::{ - self, Chat, ProtectionStatus, add_contact_to_chat, remove_contact_from_chat, send_msg, -}; +use crate::chat::{self, Chat, add_contact_to_chat, remove_contact_from_chat, send_msg}; use crate::config::Config; use crate::constants::Chattype; use crate::contact::{Contact, ContactId}; @@ -35,12 +33,11 @@ async fn check_verified_oneonone_chat_protection_not_broken(by_classical_email: let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; - enable_verified_oneonone_chats(&[&alice, &bob]).await; tcm.execute_securejoin(&alice, &bob).await; - assert_verified(&alice, &bob, ProtectionStatus::Protected).await; - assert_verified(&bob, &alice, ProtectionStatus::Protected).await; + assert_verified(&alice, &bob).await; + assert_verified(&bob, &alice).await; if by_classical_email { tcm.section("Bob uses a classical MUA to send a message to Alice"); @@ -60,7 +57,7 @@ async fn check_verified_oneonone_chat_protection_not_broken(by_classical_email: .unwrap(); let contact = alice.add_or_lookup_contact(&bob).await; assert_eq!(contact.is_verified(&alice).await.unwrap(), true); - assert_verified(&alice, &bob, ProtectionStatus::Protected).await; + assert_verified(&alice, &bob).await; } else { tcm.section("Bob sets up another Delta Chat device"); let bob2 = tcm.unconfigured().await; @@ -72,7 +69,7 @@ async fn check_verified_oneonone_chat_protection_not_broken(by_classical_email: .await; let contact = alice.add_or_lookup_contact(&bob2).await; assert_eq!(contact.is_verified(&alice).await.unwrap(), false); - assert_verified(&alice, &bob, ProtectionStatus::Protected).await; + assert_verified(&alice, &bob).await; } tcm.section("Bob sends another message from DC"); @@ -80,7 +77,7 @@ async fn check_verified_oneonone_chat_protection_not_broken(by_classical_email: tcm.send_recv(&bob, &alice, "Using DC again").await; // Bob's chat is marked as verified again - assert_verified(&alice, &bob, ProtectionStatus::Protected).await; + assert_verified(&alice, &bob).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -89,25 +86,20 @@ async fn test_create_verified_oneonone_chat() -> Result<()> { let alice = tcm.alice().await; let bob = tcm.bob().await; let fiona = tcm.fiona().await; - enable_verified_oneonone_chats(&[&alice, &bob, &fiona]).await; tcm.execute_securejoin(&alice, &bob).await; tcm.execute_securejoin(&bob, &fiona).await; - assert_verified(&alice, &bob, ProtectionStatus::Protected).await; - assert_verified(&bob, &alice, ProtectionStatus::Protected).await; - assert_verified(&bob, &fiona, ProtectionStatus::Protected).await; - assert_verified(&fiona, &bob, ProtectionStatus::Protected).await; + assert_verified(&alice, &bob).await; + assert_verified(&bob, &alice).await; + assert_verified(&bob, &fiona).await; + assert_verified(&fiona, &bob).await; let group_id = bob - .create_group_with_members( - ProtectionStatus::Protected, - "Group with everyone", - &[&alice, &fiona], - ) + .create_group_with_members("Group with everyone", &[&alice, &fiona]) .await; assert_eq!( get_chat_msg(&bob, group_id, 0, 1).await.get_info_type(), - SystemMessage::ChatProtectionEnabled + SystemMessage::ChatE2ee ); { @@ -119,7 +111,7 @@ async fn test_create_verified_oneonone_chat() -> Result<()> { get_chat_msg(&fiona, msg.chat_id, 0, 2) .await .get_info_type(), - SystemMessage::ChatProtectionEnabled + SystemMessage::ChatE2ee ); } @@ -127,50 +119,22 @@ async fn test_create_verified_oneonone_chat() -> Result<()> { let alice_fiona_contact = alice.add_or_lookup_contact(&fiona).await; assert!(alice_fiona_contact.is_verified(&alice).await.unwrap(),); - // Alice should have a hidden protected chat with Fiona - { - let chat = alice.get_chat(&fiona).await; - assert!(chat.is_protected()); - - let msg = get_chat_msg(&alice, chat.id, 0, 1).await; - let expected_text = stock_str::messages_e2e_encrypted(&alice).await; - assert_eq!(msg.text, expected_text); - } - - // Fiona should have a hidden protected chat with Alice - { - let chat = fiona.get_chat(&alice).await; - assert!(chat.is_protected()); - - let msg0 = get_chat_msg(&fiona, chat.id, 0, 1).await; - let expected_text = stock_str::messages_e2e_encrypted(&fiona).await; - assert_eq!(msg0.text, expected_text); - } - tcm.section("Fiona reinstalls DC"); drop(fiona); let fiona_new = tcm.unconfigured().await; - enable_verified_oneonone_chats(&[&fiona_new]).await; fiona_new.configure_addr("fiona@example.net").await; e2ee::ensure_secret_key_exists(&fiona_new).await?; tcm.send_recv(&fiona_new, &alice, "I have a new device") .await; - // Alice gets a new unprotected chat with new Fiona contact. + // Alice gets a new chat with new Fiona contact. { let chat = alice.get_chat(&fiona_new).await; - assert!(!chat.is_protected()); let msg = get_chat_msg(&alice, chat.id, 1, E2EE_INFO_MSGS + 1).await; assert_eq!(msg.text, "I have a new device"); - - // After recreating the chat, it should still be unprotected - chat.id.delete(&alice).await?; - - let chat = alice.create_chat(&fiona_new).await; - assert!(!chat.is_protected()); } Ok(()) @@ -181,10 +145,9 @@ async fn test_missing_key_reexecute_securejoin() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; - enable_verified_oneonone_chats(&[alice, bob]).await; let chat_id = tcm.execute_securejoin(bob, alice).await; let chat = Chat::load_from_db(bob, chat_id).await?; - assert!(chat.is_protected()); + assert!(chat.can_send(bob).await?); bob.sql .execute( "DELETE FROM public_keys WHERE fingerprint=?", @@ -195,44 +158,12 @@ async fn test_missing_key_reexecute_securejoin() -> Result<()> { .hex(),), ) .await?; - let chat_id = tcm.execute_securejoin(bob, alice).await; let chat = Chat::load_from_db(bob, chat_id).await?; - assert!(chat.is_protected()); - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_create_unverified_oneonone_chat() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = tcm.alice().await; - let bob = tcm.bob().await; - enable_verified_oneonone_chats(&[&alice, &bob]).await; - - // A chat with an unknown contact should be created unprotected - let chat = alice.create_chat(&bob).await; - assert!(!chat.is_protected()); + assert!(!chat.can_send(bob).await?); - receive_imf( - &alice, - b"From: Bob \n\ - To: alice@example.org\n\ - Message-ID: <1234-2@example.org>\n\ - \n\ - hello\n", - false, - ) - .await?; - - chat.id.delete(&alice).await.unwrap(); - // Now Bob is a known contact, new chats should still be created unprotected - let chat = alice.create_chat(&bob).await; - assert!(!chat.is_protected()); - - tcm.send_recv(&bob, &alice, "hi").await; - chat.id.delete(&alice).await.unwrap(); - // Now we have a public key, new chats should still be created unprotected - let chat = alice.create_chat(&bob).await; - assert!(!chat.is_protected()); + let chat_id = tcm.execute_securejoin(bob, alice).await; + let chat = Chat::load_from_db(bob, chat_id).await?; + assert!(chat.can_send(bob).await?); Ok(()) } @@ -246,12 +177,10 @@ async fn test_degrade_verified_oneonone_chat() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; - enable_verified_oneonone_chats(&[&alice, &bob]).await; mark_as_verified(&alice, &bob).await; let alice_chat = alice.create_chat(&bob).await; - assert!(alice_chat.is_protected()); receive_imf( &alice, @@ -267,7 +196,7 @@ async fn test_degrade_verified_oneonone_chat() -> Result<()> { let msg0 = get_chat_msg(&alice, alice_chat.id, 0, 1).await; let enabled = stock_str::messages_e2e_encrypted(&alice).await; assert_eq!(msg0.text, enabled); - assert_eq!(msg0.param.get_cmd(), SystemMessage::ChatProtectionEnabled); + assert_eq!(msg0.param.get_cmd(), SystemMessage::ChatE2ee); let email_chat = alice.get_email_chat(&bob).await; assert!(!email_chat.is_encrypted(&alice).await?); @@ -361,7 +290,6 @@ async fn test_mdn_doesnt_disable_verification() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; - enable_verified_oneonone_chats(&[&alice, &bob]).await; bob.set_config_bool(Config::MdnsEnabled, true).await?; // Alice & Bob verify each other @@ -376,7 +304,7 @@ async fn test_mdn_doesnt_disable_verification() -> Result<()> { let body = rendered_msg.message; receive_imf(&alice, body.as_bytes(), false).await.unwrap(); - assert_verified(&alice, &bob, ProtectionStatus::Protected).await; + assert_verified(&alice, &bob).await; Ok(()) } @@ -386,13 +314,12 @@ async fn test_outgoing_mua_msg() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; - enable_verified_oneonone_chats(&[&alice, &bob]).await; mark_as_verified(&alice, &bob).await; mark_as_verified(&bob, &alice).await; tcm.send_recv_accept(&bob, &alice, "Heyho from DC").await; - assert_verified(&alice, &bob, ProtectionStatus::Protected).await; + assert_verified(&alice, &bob).await; let sent = receive_imf( &alice, @@ -423,7 +350,6 @@ async fn test_outgoing_encrypted_msg() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let bob = &tcm.bob().await; - enable_verified_oneonone_chats(&[alice]).await; mark_as_verified(alice, bob).await; let chat_id = alice.create_chat(bob).await.id; @@ -449,7 +375,6 @@ async fn test_reply() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; - enable_verified_oneonone_chats(&[&alice, &bob]).await; if verified { mark_as_verified(&alice, &bob).await; @@ -492,7 +417,6 @@ async fn test_message_from_old_dc_setup() -> Result<()> { let alice = &tcm.alice().await; let bob_old = &tcm.unconfigured().await; - enable_verified_oneonone_chats(&[alice, bob_old]).await; bob_old.configure_addr("bob@example.net").await; mark_as_verified(bob_old, alice).await; let chat = bob_old.create_chat(alice).await; @@ -503,13 +427,12 @@ async fn test_message_from_old_dc_setup() -> Result<()> { tcm.section("Bob reinstalls DC"); let bob = &tcm.bob().await; - enable_verified_oneonone_chats(&[bob]).await; mark_as_verified(alice, bob).await; mark_as_verified(bob, alice).await; tcm.send_recv(bob, alice, "Now i have it!").await; - assert_verified(alice, bob, ProtectionStatus::Protected).await; + assert_verified(alice, bob).await; let msg = alice.recv_msg(&sent_old).await; assert!(msg.get_showpadlock()); @@ -518,8 +441,6 @@ async fn test_message_from_old_dc_setup() -> Result<()> { // The outdated Bob's Autocrypt header isn't applied // and the message goes to another chat, so the verification preserves. assert!(contact.is_verified(alice).await.unwrap()); - let chat = alice.get_chat(bob).await; - assert!(chat.is_protected()); Ok(()) } @@ -535,58 +456,53 @@ async fn test_verify_then_verify_again() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = tcm.alice().await; let bob = tcm.bob().await; - enable_verified_oneonone_chats(&[&alice, &bob]).await; mark_as_verified(&alice, &bob).await; mark_as_verified(&bob, &alice).await; alice.create_chat(&bob).await; - assert_verified(&alice, &bob, ProtectionStatus::Protected).await; + assert_verified(&alice, &bob).await; tcm.section("Bob reinstalls DC"); drop(bob); let bob_new = tcm.unconfigured().await; - enable_verified_oneonone_chats(&[&bob_new]).await; bob_new.configure_addr("bob@example.net").await; e2ee::ensure_secret_key_exists(&bob_new).await?; tcm.execute_securejoin(&bob_new, &alice).await; - assert_verified(&alice, &bob_new, ProtectionStatus::Protected).await; + assert_verified(&alice, &bob_new).await; Ok(()) } -/// Tests that on the second device of a protected group creator the first message is -/// `SystemMessage::ChatProtectionEnabled` and the second one is the message populating the group. +/// Tests that on the second device of a group creator the first message is +/// `SystemMessage::ChatE2ee` and the second one is the message populating the group. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_create_protected_grp_multidev() -> Result<()> { +async fn test_create_grp_multidev() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; let alice1 = &tcm.alice().await; - let group_id = alice - .create_group_with_members(ProtectionStatus::Protected, "Group", &[]) - .await; + let group_id = alice.create_group_with_members("Group", &[]).await; assert_eq!( get_chat_msg(alice, group_id, 0, 1).await.get_info_type(), - SystemMessage::ChatProtectionEnabled + SystemMessage::ChatE2ee ); let sent = alice.send_text(group_id, "Hey").await; // This time shift is necessary to reproduce the bug when the original message is sorted over - // the "protection enabled" message so that these messages have different timestamps. + // the "Messages are end-to-end encrypted" message so that these messages have different timestamps. SystemTime::shift(std::time::Duration::from_secs(3600)); let msg = alice1.recv_msg(&sent).await; let group1 = Chat::load_from_db(alice1, msg.chat_id).await?; assert_eq!(group1.get_type(), Chattype::Group); - assert!(group1.is_protected()); assert_eq!( chat::get_chat_contacts(alice1, group1.id).await?, vec![ContactId::SELF] ); assert_eq!( get_chat_msg(alice1, group1.id, 0, 2).await.get_info_type(), - SystemMessage::ChatProtectionEnabled + SystemMessage::ChatE2ee ); assert_eq!(get_chat_msg(alice1, group1.id, 1, 2).await.id, msg.id); @@ -599,7 +515,6 @@ async fn test_verified_member_added_reordering() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; let fiona = &tcm.fiona().await; - enable_verified_oneonone_chats(&[alice, bob, fiona]).await; let alice_fiona_contact_id = alice.add_or_lookup_contact_id(fiona).await; @@ -607,10 +522,8 @@ async fn test_verified_member_added_reordering() -> Result<()> { tcm.execute_securejoin(bob, alice).await; tcm.execute_securejoin(fiona, alice).await; - // Alice creates protected group with Bob. - let alice_chat_id = alice - .create_group_with_members(ProtectionStatus::Protected, "Group", &[bob]) - .await; + // Alice creates a group with Bob. + let alice_chat_id = alice.create_group_with_members("Group", &[bob]).await; let alice_sent_group_promotion = alice.send_text(alice_chat_id, "I created a group").await; let msg = bob.recv_msg(&alice_sent_group_promotion).await; let bob_chat_id = msg.chat_id; @@ -629,15 +542,13 @@ async fn test_verified_member_added_reordering() -> Result<()> { // "Member added" message, so unverified group is created. let fiona_received_message = fiona.recv_msg(&bob_sent_message).await; let fiona_chat = Chat::load_from_db(fiona, fiona_received_message.chat_id).await?; + assert!(!fiona_chat.can_send(fiona).await?); assert_eq!(fiona_received_message.get_text(), "Hi"); - assert_eq!(fiona_chat.is_protected(), false); // Fiona receives late "Member added" message // and the chat becomes protected. fiona.recv_msg(&alice_sent_member_added).await; - let fiona_chat = Chat::load_from_db(fiona, fiona_received_message.chat_id).await?; - assert_eq!(fiona_chat.is_protected(), true); Ok(()) } @@ -651,7 +562,6 @@ async fn test_no_unencrypted_name_if_encrypted() -> Result<()> { bob.set_config(Config::Displayname, Some("Bob Smith")) .await?; if verified { - enable_verified_oneonone_chats(&[&bob]).await; mark_as_verified(&bob, &alice).await; } else { tcm.send_recv_accept(&alice, &bob, "hi").await; @@ -681,9 +591,7 @@ async fn test_verified_lost_member_added() -> Result<()> { tcm.execute_securejoin(bob, alice).await; tcm.execute_securejoin(fiona, alice).await; - let alice_chat_id = alice - .create_group_with_members(ProtectionStatus::Protected, "Group", &[bob]) - .await; + let alice_chat_id = alice.create_group_with_members("Group", &[bob]).await; let alice_sent = alice.send_text(alice_chat_id, "Hi!").await; let bob_chat_id = bob.recv_msg(&alice_sent).await.chat_id; assert_eq!(chat::get_chat_contacts(bob, bob_chat_id).await?.len(), 2); @@ -744,9 +652,7 @@ async fn test_verified_chat_editor_reordering() -> Result<()> { tcm.execute_securejoin(alice, bob).await; tcm.section("Alice creates a protected group with Bob"); - let alice_chat_id = alice - .create_group_with_members(ProtectionStatus::Protected, "Group", &[bob]) - .await; + let alice_chat_id = alice.create_group_with_members("Group", &[bob]).await; let alice_sent = alice.send_text(alice_chat_id, "Hi!").await; let bob_chat_id = bob.recv_msg(&alice_sent).await.chat_id; @@ -799,28 +705,123 @@ async fn test_verified_chat_editor_reordering() -> Result<()> { tcm.section("Charlie receives member added message"); charlie.recv_msg(&sent_member_added_msg).await; + charlie + .golden_test_chat( + charlie_received_xdc.chat_id, + "verified_chats_editor_reordering", + ) + .await; + Ok(()) +} + +/// Tests that already verified contact +/// does not get a new "verifier" +/// via gossip. +/// +/// Directly verifying is still possible. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_no_reverification() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + let charlie = &tcm.charlie().await; + let fiona = &tcm.fiona().await; + + tcm.execute_securejoin(alice, bob).await; + tcm.execute_securejoin(alice, charlie).await; + tcm.execute_securejoin(alice, fiona).await; + + tcm.section("Alice creates a protected group with Bob, Charlie and Fiona"); + let alice_chat_id = alice + .create_group_with_members("Group", &[bob, charlie, fiona]) + .await; + let alice_sent = alice.send_text(alice_chat_id, "Hi!").await; + let bob_rcvd_msg = bob.recv_msg(&alice_sent).await; + let bob_alice_id = bob_rcvd_msg.from_id; + + // Charlie is verified by Alice for Bob. + let bob_charlie_contact = bob.add_or_lookup_contact(charlie).await; + assert_eq!( + bob_charlie_contact + .get_verifier_id(bob) + .await? + .unwrap() + .unwrap(), + bob_alice_id + ); + + let fiona_rcvd_msg = fiona.recv_msg(&alice_sent).await; + let fiona_chat_id = fiona_rcvd_msg.chat_id; + let fiona_sent = fiona.send_text(fiona_chat_id, "Post by Fiona").await; + bob.recv_msg(&fiona_sent).await; + + // Charlie should still be verified by Alice, not by Fiona. + let bob_charlie_contact = bob.add_or_lookup_contact(charlie).await; + assert_eq!( + bob_charlie_contact + .get_verifier_id(bob) + .await? + .unwrap() + .unwrap(), + bob_alice_id + ); + + // Bob can still verify Charlie directly. + tcm.execute_securejoin(bob, charlie).await; + let bob_charlie_contact = bob.add_or_lookup_contact(charlie).await; + assert_eq!( + bob_charlie_contact + .get_verifier_id(bob) + .await? + .unwrap() + .unwrap(), + ContactId::SELF + ); Ok(()) } -// ============== Helper Functions ============== +/// Tests that if our second device observes +/// us gossiping a verification, +/// it is not treated as direct verification. +/// +/// Direct verifications should only happen +/// as a result of SecureJoin. +/// If we see our second device gossiping +/// a verification of some contact, +/// it may be indirect verification, +/// so we should mark the contact as verified, +/// but with unknown verifier. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_no_direct_verification_via_bcc() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let alice2 = &tcm.alice().await; + let bob = &tcm.bob().await; -async fn assert_verified(this: &TestContext, other: &TestContext, protected: ProtectionStatus) { - let contact = this.add_or_lookup_contact(other).await; - assert_eq!(contact.is_verified(this).await.unwrap(), true); + mark_as_verified(alice, bob).await; + + let alice_chat_id = alice.create_chat_id(bob).await; + let alice_sent_msg = alice.send_text(alice_chat_id, "Hello!").await; + alice2.recv_msg(&alice_sent_msg).await; + + // Alice 2 observes Alice 1 gossiping verification for Bob. + // Alice 2 does not know if Alice 1 has verified Bob directly though. + let alice2_bob_contact = alice2.add_or_lookup_contact(bob).await; + assert_eq!(alice2_bob_contact.is_verified(alice2).await?, true); - let chat = this.get_chat(other).await; + // There is some verifier, but it is unknown to Alice's second device. assert_eq!( - chat.is_protected(), - protected == ProtectionStatus::Protected + alice2_bob_contact.get_verifier_id(alice2).await?, + Some(None) ); - assert_eq!(chat.is_protection_broken(), false); + + Ok(()) } -async fn enable_verified_oneonone_chats(test_contexts: &[&TestContext]) { - for t in test_contexts { - t.set_config_bool(Config::VerifiedOneOnOneChats, true) - .await - .unwrap() - } +// ============== Helper Functions ============== + +async fn assert_verified(this: &TestContext, other: &TestContext) { + let contact = this.add_or_lookup_contact(other).await; + assert_eq!(contact.is_verified(this).await.unwrap(), true); } diff --git a/src/token.rs b/src/token.rs index a5bdfc0681..1a189a151f 100644 --- a/src/token.rs +++ b/src/token.rs @@ -28,12 +28,13 @@ pub async fn save( namespace: Namespace, foreign_key: Option<&str>, token: &str, + timestamp: i64, ) -> Result<()> { context .sql .execute( "INSERT INTO tokens (namespc, foreign_key, token, timestamp) VALUES (?, ?, ?, ?)", - (namespace, foreign_key.unwrap_or(""), token, time()), + (namespace, foreign_key.unwrap_or(""), token, timestamp), ) .await?; Ok(()) @@ -71,7 +72,8 @@ pub async fn lookup_or_new( } let token = create_id(); - save(context, namespace, foreign_key, &token).await?; + let timestamp = time(); + save(context, namespace, foreign_key, &token, timestamp).await?; Ok(token) } @@ -86,31 +88,14 @@ pub async fn exists(context: &Context, namespace: Namespace, token: &str) -> Res Ok(exists) } -/// Looks up foreign key by auth token. +/// Resets all tokens corresponding to the `foreign_key`. /// -/// Returns None if auth token is not valid. -/// Returns an empty string if the token corresponds to "setup contact" rather than group join. -pub async fn auth_foreign_key(context: &Context, token: &str) -> Result> { +/// `foreign_key` is a group ID to reset all group tokens +/// or empty string to reset all setup contact tokens. +pub async fn delete(context: &Context, foreign_key: &str) -> Result<()> { context .sql - .query_row_optional( - "SELECT foreign_key FROM tokens WHERE namespc=? AND token=?", - (Namespace::Auth, token), - |row| { - let foreign_key: String = row.get(0)?; - Ok(foreign_key) - }, - ) - .await -} - -pub async fn delete(context: &Context, namespace: Namespace, token: &str) -> Result<()> { - context - .sql - .execute( - "DELETE FROM tokens WHERE namespc=? AND token=?;", - (namespace, token), - ) + .execute("DELETE FROM tokens WHERE foreign_key=?", (foreign_key,)) .await?; Ok(()) } diff --git a/src/tools.rs b/src/tools.rs index 59cca8d158..e7dc108cfb 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -753,6 +753,14 @@ pub(crate) fn buf_decompress(buf: &[u8]) -> Result> { Ok(mem::take(decompressor.get_mut())) } +/// Returns the given `&str` if already lowercased to avoid allocation, otherwise lowercases it. +pub(crate) fn to_lowercase(s: &str) -> Cow<'_, str> { + match s.chars().all(char::is_lowercase) { + true => Cow::Borrowed(s), + false => Cow::Owned(s.to_lowercase()), + } +} + /// Increments `*t` and checks that it equals to `expected` after that. pub(crate) fn inc_and_check( t: &mut T, diff --git a/src/tools/tools_tests.rs b/src/tools/tools_tests.rs index 728f0e47ad..907af1868b 100644 --- a/src/tools/tools_tests.rs +++ b/src/tools/tools_tests.rs @@ -3,6 +3,7 @@ use proptest::prelude::*; use super::*; use crate::chatlist::Chatlist; +use crate::test_utils::TimeShiftFalsePositiveNote; use crate::{chat, test_utils}; use crate::{receive_imf::receive_imf, test_utils::TestContext}; @@ -431,14 +432,16 @@ async fn test_maybe_warn_on_bad_time() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_maybe_warn_on_outdated() { + let _n = TimeShiftFalsePositiveNote; + let t = TestContext::new().await; let timestamp_now: i64 = time(); - // in about 6 months, the app should not be outdated - // (if this fails, provider-db is not updated since 6 months) + // in about 3 months, the app should not be outdated. + // "90 days" has proven to be too short at some point - user were informed but there was no update maybe_warn_on_outdated( &t, - timestamp_now + 180 * 24 * 60 * 60, + timestamp_now + 90 * 24 * 60 * 60, get_release_timestamp(), ) .await; diff --git a/src/webxdc.rs b/src/webxdc.rs index fdfa5d44c8..af032419f8 100644 --- a/src/webxdc.rs +++ b/src/webxdc.rs @@ -279,7 +279,7 @@ impl Context { }; if !valid { - bail!("{} is not a valid webxdc file", filename); + bail!("{filename} is not a valid webxdc file"); } Ok(()) @@ -837,8 +837,8 @@ fn parse_webxdc_manifest(bytes: &[u8]) -> Result { } async fn get_blob(archive: &mut SeekZipFileReader>, name: &str) -> Result> { - let (i, _) = find_zip_entry(archive.file(), name) - .ok_or_else(|| anyhow!("no entry found for {}", name))?; + let (i, _) = + find_zip_entry(archive.file(), name).ok_or_else(|| anyhow!("no entry found for {name}"))?; let mut reader = archive.reader_with_entry(i).await?; let mut buf = Vec::new(); reader.read_to_end_checked(&mut buf).await?; diff --git a/src/webxdc/maps_integration.rs b/src/webxdc/maps_integration.rs index 7f398e6e06..3ee931a599 100644 --- a/src/webxdc/maps_integration.rs +++ b/src/webxdc/maps_integration.rs @@ -169,7 +169,7 @@ pub(crate) async fn intercept_get_updates( #[cfg(test)] mod tests { - use crate::chat::{ChatId, ProtectionStatus, create_group_chat}; + use crate::chat::{ChatId, create_group}; use crate::chatlist::Chatlist; use crate::contact::Contact; use crate::message::Message; @@ -231,7 +231,7 @@ mod tests { assert_eq!(msg.chat_id, bob_chat_id); // Integrate Webxdc into another group - let group_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let group_id = create_group(&t, "foo").await?; let integration_id = t.init_webxdc_integration(Some(group_id)).await?.unwrap(); let locations = location::get_range(&t, Some(group_id), None, 0, 0).await?; diff --git a/src/webxdc/webxdc_tests.rs b/src/webxdc/webxdc_tests.rs index 35039459ed..cc205f3fc9 100644 --- a/src/webxdc/webxdc_tests.rs +++ b/src/webxdc/webxdc_tests.rs @@ -5,8 +5,8 @@ use serde_json::json; use super::*; use crate::chat::{ - ChatId, ProtectionStatus, add_contact_to_chat, create_broadcast, create_group_chat, - forward_msgs, remove_contact_from_chat, resend_msgs, send_msg, send_text_msg, + ChatId, add_contact_to_chat, create_broadcast, create_group, forward_msgs, + remove_contact_from_chat, resend_msgs, send_msg, send_text_msg, }; use crate::chatlist::Chatlist; use crate::config::Config; @@ -78,7 +78,7 @@ async fn send_webxdc_instance(t: &TestContext, chat_id: ChatId) -> Result Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; // send as .xdc file let instance = send_webxdc_instance(&t, chat_id).await?; @@ -97,7 +97,7 @@ async fn test_send_webxdc_instance() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_send_invalid_webxdc() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; // sending invalid .xdc as file is possible, but must not result in Viewtype::Webxdc let mut instance = create_webxdc_instance( @@ -126,7 +126,7 @@ async fn test_send_invalid_webxdc() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_set_draft_invalid_webxdc() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let mut instance = create_webxdc_instance( &t, @@ -143,7 +143,7 @@ async fn test_set_draft_invalid_webxdc() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_send_special_webxdc_format() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; // chess.xdc is failing for some zip-versions, see #3476, if we know more details about why, we can have a nicer name for the test :) let mut instance = create_webxdc_instance( @@ -164,7 +164,7 @@ async fn test_send_special_webxdc_format() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_forward_webxdc_instance() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let instance = send_webxdc_instance(&t, chat_id).await?; t.send_webxdc_status_update( instance.id, @@ -177,7 +177,7 @@ async fn test_forward_webxdc_instance() -> Result<()> { .await?, r#"[{"payload":42,"info":"foo","document":"doc","summary":"bar","serial":1,"max_serial":1}]"# ); - assert_eq!(chat_id.get_msg_cnt(&t).await?, 2); // instance and info + assert_eq!(chat_id.get_msg_cnt(&t).await?, 3); // "Messages are end-to-end encrypted", instance and info let info = Message::load_from_db(&t, instance.id) .await? .get_webxdc_info(&t) @@ -194,7 +194,7 @@ async fn test_forward_webxdc_instance() -> Result<()> { .await?, "[]" ); - assert_eq!(chat_id.get_msg_cnt(&t).await?, 3); // two instances, only one info + assert_eq!(chat_id.get_msg_cnt(&t).await?, 4); // "Messages are end-to-end encrypted", two instances, only one info let info = Message::load_from_db(&t, instance2.id) .await? .get_webxdc_info(&t) @@ -213,16 +213,16 @@ async fn test_resend_webxdc_instance_and_info() -> Result<()> { // Alice uses webxdc in a group alice.set_config_bool(Config::BccSelf, false).await?; - let alice_grp = create_group_chat(&alice, ProtectionStatus::Unprotected, "grp").await?; + let alice_grp = create_group(&alice, "grp").await?; let alice_instance = send_webxdc_instance(&alice, alice_grp).await?; - assert_eq!(alice_grp.get_msg_cnt(&alice).await?, 1); + assert_eq!(alice_grp.get_msg_cnt(&alice).await?, 2); alice .send_webxdc_status_update( alice_instance.id, r#"{"payload":7,"info": "i","summary":"s"}"#, ) .await?; - assert_eq!(alice_grp.get_msg_cnt(&alice).await?, 2); + assert_eq!(alice_grp.get_msg_cnt(&alice).await?, 3); assert!(alice.get_last_msg_in(alice_grp).await.is_info()); // Alice adds Bob and resends already used webxdc @@ -232,7 +232,7 @@ async fn test_resend_webxdc_instance_and_info() -> Result<()> { alice.add_or_lookup_contact_id(&bob).await, ) .await?; - assert_eq!(alice_grp.get_msg_cnt(&alice).await?, 3); + assert_eq!(alice_grp.get_msg_cnt(&alice).await?, 4); resend_msgs(&alice, &[alice_instance.id]).await?; let sent1 = alice.pop_sent_msg().await; alice.flush_status_updates().await?; @@ -395,7 +395,7 @@ async fn test_webxdc_update_for_not_downloaded_instance() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_delete_webxdc_instance() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let instance = send_webxdc_instance(&t, chat_id).await?; let now = tools::time(); t.receive_status_update( @@ -428,7 +428,7 @@ async fn test_delete_webxdc_instance() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_delete_chat_with_webxdc() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let instance = send_webxdc_instance(&t, chat_id).await?; let now = tools::time(); t.receive_status_update( @@ -461,7 +461,7 @@ async fn test_delete_chat_with_webxdc() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_delete_webxdc_draft() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let mut instance = create_webxdc_instance( &t, @@ -498,7 +498,7 @@ async fn test_delete_webxdc_draft() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_create_status_update_record() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let instance = send_webxdc_instance(&t, chat_id).await?; assert_eq!( @@ -633,7 +633,7 @@ async fn test_create_status_update_record() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_receive_status_update() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let instance = send_webxdc_instance(&t, chat_id).await?; let now = tools::time(); @@ -904,7 +904,7 @@ async fn test_send_big_webxdc_status_update() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_render_webxdc_status_update_object() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "a chat").await?; + let chat_id = create_group(&t, "a chat").await?; let mut instance = create_webxdc_instance( &t, "minimal.xdc", @@ -932,7 +932,7 @@ async fn test_render_webxdc_status_update_object() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_render_webxdc_status_update_object_range() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "a chat").await?; + let chat_id = create_group(&t, "a chat").await?; let instance = send_webxdc_instance(&t, chat_id).await?; t.send_webxdc_status_update(instance.id, r#"{"payload": 1}"#) .await?; @@ -979,7 +979,7 @@ async fn test_render_webxdc_status_update_object_range() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_pop_status_update() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "a chat").await?; + let chat_id = create_group(&t, "a chat").await?; let instance1 = send_webxdc_instance(&t, chat_id).await?; let instance2 = send_webxdc_instance(&t, chat_id).await?; let instance3 = send_webxdc_instance(&t, chat_id).await?; @@ -1109,7 +1109,7 @@ async fn test_draft_and_send_webxdc_status_update() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_send_webxdc_status_update_to_non_webxdc() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let msg_id = send_text_msg(&t, chat_id, "ho!".to_string()).await?; assert!( t.send_webxdc_status_update(msg_id, r#"{"foo":"bar"}"#) @@ -1122,7 +1122,7 @@ async fn test_send_webxdc_status_update_to_non_webxdc() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_webxdc_blob() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let instance = send_webxdc_instance(&t, chat_id).await?; let buf = instance.get_webxdc_blob(&t, "index.html").await?; @@ -1141,7 +1141,7 @@ async fn test_get_webxdc_blob() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_webxdc_blob_default_icon() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let instance = send_webxdc_instance(&t, chat_id).await?; let buf = instance.get_webxdc_blob(&t, WEBXDC_DEFAULT_ICON).await?; @@ -1153,7 +1153,7 @@ async fn test_get_webxdc_blob_default_icon() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_webxdc_blob_with_absolute_paths() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let instance = send_webxdc_instance(&t, chat_id).await?; let buf = instance.get_webxdc_blob(&t, "/index.html").await?; @@ -1166,7 +1166,7 @@ async fn test_get_webxdc_blob_with_absolute_paths() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_webxdc_blob_with_subdirs() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let mut instance = create_webxdc_instance( &t, "some-files.xdc", @@ -1265,7 +1265,7 @@ async fn test_parse_webxdc_manifest_source_code_url() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_webxdc_min_api_too_large() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "chat").await?; + let chat_id = create_group(&t, "chat").await?; let mut instance = create_webxdc_instance( &t, "with-min-api-1001.xdc", @@ -1283,7 +1283,7 @@ async fn test_webxdc_min_api_too_large() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_webxdc_info() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let instance = send_webxdc_instance(&t, chat_id).await?; let info = instance.get_webxdc_info(&t).await?; @@ -1363,7 +1363,7 @@ async fn test_get_webxdc_info() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_webxdc_self_addr() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&t, "foo").await?; let instance = send_webxdc_instance(&t, chat_id).await?; let info1 = instance.get_webxdc_info(&t).await?; @@ -1601,17 +1601,17 @@ async fn test_webxdc_info_msg_cleanup_series() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_webxdc_info_msg_no_cleanup_on_interrupted_series() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "c").await?; + let chat_id = create_group(&t, "c").await?; let instance = send_webxdc_instance(&t, chat_id).await?; t.send_webxdc_status_update(instance.id, r#"{"info":"i1", "payload":1}"#) .await?; - assert_eq!(chat_id.get_msg_cnt(&t).await?, 2); + assert_eq!(chat_id.get_msg_cnt(&t).await?, E2EE_INFO_MSGS + 2); send_text_msg(&t, chat_id, "msg between info".to_string()).await?; - assert_eq!(chat_id.get_msg_cnt(&t).await?, 3); + assert_eq!(chat_id.get_msg_cnt(&t).await?, E2EE_INFO_MSGS + 3); t.send_webxdc_status_update(instance.id, r#"{"info":"i2", "payload":2}"#) .await?; - assert_eq!(chat_id.get_msg_cnt(&t).await?, 4); + assert_eq!(chat_id.get_msg_cnt(&t).await?, E2EE_INFO_MSGS + 4); Ok(()) } @@ -1623,7 +1623,7 @@ async fn test_webxdc_no_internet_access() -> Result<()> { let t = TestContext::new_alice().await; let self_id = t.get_self_chat().await.id; let single_id = t.create_chat_with_contact("bob", "bob@e.com").await.id; - let group_id = create_group_chat(&t, ProtectionStatus::Unprotected, "chat").await?; + let group_id = create_group(&t, "chat").await?; let broadcast_id = create_broadcast(&t, "Channel".to_string()).await?; for chat_id in [self_id, single_id, group_id, broadcast_id] { @@ -1655,7 +1655,7 @@ async fn test_webxdc_no_internet_access() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_webxdc_chatlist_summary() -> Result<()> { let t = TestContext::new_alice().await; - let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "chat").await?; + let chat_id = create_group(&t, "chat").await?; let mut instance = create_webxdc_instance( &t, "with-minimal-manifest.xdc", @@ -1727,7 +1727,7 @@ async fn test_webxdc_reject_updates_from_non_groupmembers() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; let contact_bob = alice.add_or_lookup_contact_id(bob).await; - let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?; + let chat_id = create_group(alice, "Group").await?; add_contact_to_chat(alice, chat_id, contact_bob).await?; let instance = send_webxdc_instance(alice, chat_id).await?; bob.recv_msg(&alice.pop_sent_msg().await).await; @@ -1758,7 +1758,7 @@ async fn test_webxdc_reject_updates_from_non_groupmembers() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_webxdc_delete_event() -> Result<()> { let alice = TestContext::new_alice().await; - let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?; + let chat_id = create_group(&alice, "foo").await?; let instance = send_webxdc_instance(&alice, chat_id).await?; message::delete_msgs(&alice, &[instance.id]).await?; alice @@ -1912,7 +1912,7 @@ async fn test_webxdc_notify_one() -> Result<()> { let fiona = tcm.fiona().await; let grp_id = alice - .create_group_with_members(ProtectionStatus::Unprotected, "grp", &[&bob, &fiona]) + .create_group_with_members("grp", &[&bob, &fiona]) .await; let alice_instance = send_webxdc_instance(&alice, grp_id).await?; let sent1 = alice.pop_sent_msg().await; @@ -1958,7 +1958,7 @@ async fn test_webxdc_notify_multiple() -> Result<()> { let fiona = tcm.fiona().await; let grp_id = alice - .create_group_with_members(ProtectionStatus::Unprotected, "grp", &[&bob, &fiona]) + .create_group_with_members("grp", &[&bob, &fiona]) .await; let alice_instance = send_webxdc_instance(&alice, grp_id).await?; let sent1 = alice.pop_sent_msg().await; @@ -2001,9 +2001,7 @@ async fn test_webxdc_no_notify_self() -> Result<()> { let alice = tcm.alice().await; let alice2 = tcm.alice().await; - let grp_id = alice - .create_group_with_members(ProtectionStatus::Unprotected, "grp", &[]) - .await; + let grp_id = alice.create_group_with_members("grp", &[]).await; let alice_instance = send_webxdc_instance(&alice, grp_id).await?; let sent1 = alice.pop_sent_msg().await; let alice2_instance = alice2.recv_msg(&sent1).await; @@ -2043,7 +2041,7 @@ async fn test_webxdc_notify_all() -> Result<()> { let fiona = tcm.fiona().await; let grp_id = alice - .create_group_with_members(ProtectionStatus::Unprotected, "grp", &[&bob, &fiona]) + .create_group_with_members("grp", &[&bob, &fiona]) .await; let alice_instance = send_webxdc_instance(&alice, grp_id).await?; let sent1 = alice.pop_sent_msg().await; @@ -2083,7 +2081,7 @@ async fn test_webxdc_notify_bob_and_all() -> Result<()> { let fiona = tcm.fiona().await; let grp_id = alice - .create_group_with_members(ProtectionStatus::Unprotected, "grp", &[&bob, &fiona]) + .create_group_with_members("grp", &[&bob, &fiona]) .await; let alice_instance = send_webxdc_instance(&alice, grp_id).await?; let sent1 = alice.pop_sent_msg().await; @@ -2117,7 +2115,7 @@ async fn test_webxdc_notify_all_and_bob() -> Result<()> { let fiona = tcm.fiona().await; let grp_id = alice - .create_group_with_members(ProtectionStatus::Unprotected, "grp", &[&bob, &fiona]) + .create_group_with_members("grp", &[&bob, &fiona]) .await; let alice_instance = send_webxdc_instance(&alice, grp_id).await?; let sent1 = alice.pop_sent_msg().await; @@ -2149,9 +2147,7 @@ async fn test_webxdc_href() -> Result<()> { let alice = tcm.alice().await; let bob = tcm.bob().await; - let grp_id = alice - .create_group_with_members(ProtectionStatus::Unprotected, "grp", &[&bob]) - .await; + let grp_id = alice.create_group_with_members("grp", &[&bob]).await; let instance = send_webxdc_instance(&alice, grp_id).await?; let sent1 = alice.pop_sent_msg().await; @@ -2181,9 +2177,7 @@ async fn test_webxdc_href() -> Result<()> { async fn test_self_addr_consistency() -> Result<()> { let mut tcm = TestContextManager::new(); let alice = &tcm.alice().await; - let alice_chat = alice - .create_group_with_members(ProtectionStatus::Unprotected, "No friends :(", &[]) - .await; + let alice_chat = alice.create_group_with_members("No friends :(", &[]).await; let mut instance = create_webxdc_instance( alice, "minimal.xdc", @@ -2195,6 +2189,5 @@ async fn test_self_addr_consistency() -> Result<()> { let sent = alice.send_msg(alice_chat, &mut instance).await; let db_msg = Message::load_from_db(alice, sent.sender_msg_id).await?; assert_eq!(db_msg.get_webxdc_self_addr(alice).await?, self_addr); - assert_eq!(alice_chat.get_msg_cnt(alice).await?, 1); Ok(()) } diff --git a/test-data/golden/chat_test_parallel_member_remove b/test-data/golden/chat_test_parallel_member_remove index b2442e855a..894f275de7 100644 --- a/test-data/golden/chat_test_parallel_member_remove +++ b/test-data/golden/chat_test_parallel_member_remove @@ -1,8 +1,8 @@ -Group#Chat#10: Group chat [3 member(s)] +Group#Chat#2001: Group chat [3 member(s)] -------------------------------------------------------------------------------- -Msg#10: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO] -Msg#11πŸ”’: (Contact#Contact#10): Hi! I created a group. [FRESH] -Msg#12πŸ”’: Me (Contact#Contact#Self): You left. [INFO] √ -Msg#13πŸ”’: (Contact#Contact#10): Member charlie@example.net added by alice@example.org. [FRESH][INFO] -Msg#14πŸ”’: (Contact#Contact#10): What a silence! [FRESH] +Msg#2001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO] +Msg#2002πŸ”’: (Contact#Contact#2001): Hi! I created a group. [FRESH] +Msg#2003πŸ”’: Me (Contact#Contact#Self): You left the group. [INFO] √ +Msg#2004πŸ”’: (Contact#Contact#2001): Member charlie@example.net added by alice@example.org. [FRESH][INFO] +Msg#2005πŸ”’: (Contact#Contact#2001): What a silence! [FRESH] -------------------------------------------------------------------------------- diff --git a/test-data/golden/receive_imf_delayed_removal_is_ignored b/test-data/golden/receive_imf_delayed_removal_is_ignored index 43c918f7f2..3333fb8338 100644 --- a/test-data/golden/receive_imf_delayed_removal_is_ignored +++ b/test-data/golden/receive_imf_delayed_removal_is_ignored @@ -1,9 +1,10 @@ -Group#Chat#10: Group [5 member(s)] +Group#Chat#1001: Group [5 member(s)] -------------------------------------------------------------------------------- -Msg#10πŸ”’: Me (Contact#Contact#Self): populate √ -Msg#11: info (Contact#Contact#Info): Member dom@example.net added. [NOTICED][INFO] -Msg#12: info (Contact#Contact#Info): Member fiona@example.net removed. [NOTICED][INFO] -Msg#13πŸ”’: (Contact#Contact#10): Member elena@example.net added by bob@example.net. [FRESH][INFO] -Msg#14πŸ”’: Me (Contact#Contact#Self): You added member fiona@example.net. [INFO] o -Msg#15πŸ”’: (Contact#Contact#10): Member fiona@example.net removed by bob@example.net. [FRESH][INFO] +Msg#1001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO] +Msg#1002πŸ”’: Me (Contact#Contact#Self): populate √ +Msg#1003: info (Contact#Contact#Info): Member dom@example.net added. [NOTICED][INFO] +Msg#1004: info (Contact#Contact#Info): Member fiona@example.net removed. [NOTICED][INFO] +Msg#1005πŸ”’: (Contact#Contact#1001): Member elena@example.net added by bob@example.net. [FRESH][INFO] +Msg#1006πŸ”’: Me (Contact#Contact#Self): You added member fiona@example.net. [INFO] o +Msg#1007πŸ”’: (Contact#Contact#1001): Member fiona@example.net removed by bob@example.net. [FRESH][INFO] -------------------------------------------------------------------------------- diff --git a/test-data/golden/receive_imf_older_message_from_2nd_device b/test-data/golden/receive_imf_older_message_from_2nd_device index abc38ad339..b833432873 100644 --- a/test-data/golden/receive_imf_older_message_from_2nd_device +++ b/test-data/golden/receive_imf_older_message_from_2nd_device @@ -1,5 +1,5 @@ -Single#Chat#10: bob@example.net [bob@example.net] Icon: 4138c52e5bc1c576cda7dd44d088c07.png +Single#Chat#1001: bob@example.net [bob@example.net] Icon: 4138c52e5bc1c576cda7dd44d088c07.png -------------------------------------------------------------------------------- -Msg#10: Me (Contact#Contact#Self): We share this account √ -Msg#11: Me (Contact#Contact#Self): I'm Alice too √ +Msg#1001: Me (Contact#Contact#Self): We share this account √ +Msg#1002: Me (Contact#Contact#Self): I'm Alice too √ -------------------------------------------------------------------------------- diff --git a/test-data/golden/test_old_message_5 b/test-data/golden/test_old_message_5 index 624838a43c..92bd8ac863 100644 --- a/test-data/golden/test_old_message_5 +++ b/test-data/golden/test_old_message_5 @@ -1,5 +1,5 @@ -Single#Chat#10: Bob [bob@example.net] Icon: 4138c52e5bc1c576cda7dd44d088c07.png +Single#Chat#11001: Bob [bob@example.net] Icon: 4138c52e5bc1c576cda7dd44d088c07.png -------------------------------------------------------------------------------- -Msg#10: Me (Contact#Contact#Self): Happy birthday, Bob! √ -Msg#11: (Contact#Contact#10): Happy birthday to me, Alice! [FRESH] +Msg#11001: Me (Contact#Contact#Self): Happy birthday, Bob! √ +Msg#11002: (Contact#Contact#11001): Happy birthday to me, Alice! [FRESH] -------------------------------------------------------------------------------- diff --git a/test-data/golden/test_outgoing_encrypted_msg b/test-data/golden/test_outgoing_encrypted_msg index 06cecece6a..4b26297c2d 100644 --- a/test-data/golden/test_outgoing_encrypted_msg +++ b/test-data/golden/test_outgoing_encrypted_msg @@ -1,5 +1,5 @@ -Single#Chat#10: bob@example.net [KEY bob@example.net] πŸ›‘οΈ +Single#Chat#1001: bob@example.net [KEY bob@example.net] -------------------------------------------------------------------------------- -Msg#10: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO πŸ›‘οΈ] -Msg#11πŸ”’: Me (Contact#Contact#Self): Test – This is encrypted, signed, and has an Autocrypt Header without prefer-encrypt=mutual. √ +Msg#1001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO] +Msg#1002πŸ”’: Me (Contact#Contact#Self): Test – This is encrypted, signed, and has an Autocrypt Header without prefer-encrypt=mutual. √ -------------------------------------------------------------------------------- diff --git a/test-data/golden/test_outgoing_mua_msg b/test-data/golden/test_outgoing_mua_msg index 5d4a0f2ee9..5940262b13 100644 --- a/test-data/golden/test_outgoing_mua_msg +++ b/test-data/golden/test_outgoing_mua_msg @@ -1,4 +1,4 @@ -Single#Chat#11: bob@example.net [bob@example.net] Icon: 4138c52e5bc1c576cda7dd44d088c07.png +Single#Chat#1002: bob@example.net [bob@example.net] Icon: 4138c52e5bc1c576cda7dd44d088c07.png -------------------------------------------------------------------------------- -Msg#12: Me (Contact#Contact#Self): One classical MUA message √ +Msg#1003: Me (Contact#Contact#Self): One classical MUA message √ -------------------------------------------------------------------------------- diff --git a/test-data/golden/test_outgoing_mua_msg_pgp b/test-data/golden/test_outgoing_mua_msg_pgp index 1a128d5286..3c4c163f4b 100644 --- a/test-data/golden/test_outgoing_mua_msg_pgp +++ b/test-data/golden/test_outgoing_mua_msg_pgp @@ -1,6 +1,6 @@ -Single#Chat#10: bob@example.net [KEY bob@example.net] πŸ›‘οΈ +Single#Chat#1001: bob@example.net [KEY bob@example.net] -------------------------------------------------------------------------------- -Msg#10: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO πŸ›‘οΈ] -Msg#11πŸ”’: (Contact#Contact#10): Heyho from DC [FRESH] -Msg#13πŸ”’: Me (Contact#Contact#Self): Sending with DC again √ +Msg#1001: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO] +Msg#1002πŸ”’: (Contact#Contact#1001): Heyho from DC [FRESH] +Msg#1004πŸ”’: Me (Contact#Contact#Self): Sending with DC again √ -------------------------------------------------------------------------------- diff --git a/test-data/golden/two_group_securejoins b/test-data/golden/two_group_securejoins index 3684034fd7..5e20c36b6c 100644 --- a/test-data/golden/two_group_securejoins +++ b/test-data/golden/two_group_securejoins @@ -1,9 +1,9 @@ -Group#Chat#11: Group [3 member(s)] πŸ›‘οΈ +Group#Chat#6002: Group [3 member(s)] -------------------------------------------------------------------------------- -Msg#11: info (Contact#Contact#Info): alice@example.org invited you to join this group. +Msg#6004: info (Contact#Contact#Info): alice@example.org invited you to join this group. Waiting for the device of alice@example.org to reply… [NOTICED][INFO] -Msg#13: info (Contact#Contact#Info): alice@example.org replied, waiting for being added to the group… [NOTICED][INFO] -Msg#17: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO πŸ›‘οΈ] -Msg#18πŸ”’: (Contact#Contact#10): Member Me added by alice@example.org. [FRESH][INFO] +Msg#6006: info (Contact#Contact#Info): alice@example.org replied, waiting for being added to the group… [NOTICED][INFO] +Msg#6003: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO] +Msg#6008πŸ”’: (Contact#Contact#6001): Member Me added by alice@example.org. [FRESH][INFO] -------------------------------------------------------------------------------- diff --git a/test-data/golden/verified_chats_editor_reordering b/test-data/golden/verified_chats_editor_reordering new file mode 100644 index 0000000000..3bf488c00c --- /dev/null +++ b/test-data/golden/verified_chats_editor_reordering @@ -0,0 +1,11 @@ +Group#Chat#3002: Group [3 member(s)] +-------------------------------------------------------------------------------- +Msg#3004: info (Contact#Contact#Info): alice@example.org invited you to join this group. + +Waiting for the device of alice@example.org to reply… [NOTICED][INFO] +Msg#3006: info (Contact#Contact#Info): alice@example.org replied, waiting for being added to the group… [NOTICED][INFO] +Msg#3003: info (Contact#Contact#Info): Messages are end-to-end encrypted. [NOTICED][INFO] +Msg#3008πŸ”’: (Contact#Contact#3002): [FRESH] +Msg#3009: info (Contact#Contact#Info): Member bob@example.net added. [NOTICED][INFO] +Msg#3010πŸ”’: (Contact#Contact#3001): Member Me added by alice@example.org. [FRESH][INFO] +-------------------------------------------------------------------------------- diff --git a/test-data/image/1000x1000-bad-exif.jpg b/test-data/image/1000x1000-bad-exif.jpg new file mode 100644 index 0000000000..e39d6bf40d Binary files /dev/null and b/test-data/image/1000x1000-bad-exif.jpg differ diff --git a/test-data/image/logo-exif.png b/test-data/image/logo-exif.png new file mode 100644 index 0000000000..2942931f07 Binary files /dev/null and b/test-data/image/logo-exif.png differ diff --git a/test-data/message/calendar-alternative.eml b/test-data/message/calendar-alternative.eml new file mode 100644 index 0000000000..fce24e0dd4 --- /dev/null +++ b/test-data/message/calendar-alternative.eml @@ -0,0 +1,30 @@ +From: Bob +To: Alice +Subject: Subject was here +Date: Mon, 11 Aug 2025 10:15:52 +0000 +Message-ID: + +Content-Type: multipart/alternative; + boundary="_000_DU2PR10MB7741CB7551F025C98CE1B0C0EE28ADU2PR10MB7741EURP_" +MIME-Version: 1.0 + +--_000_DU2PR10MB7741CB7551F025C98CE1B0C0EE28ADU2PR10MB7741EURP_ +Content-Type: text/plain; charset="utf-8" + +Hello! +--_000_DU2PR10MB7741CB7551F025C98CE1B0C0EE28ADU2PR10MB7741EURP_ +Content-Type: text/html; charset="utf-8" + +Hello! +--_000_DU2PR10MB7741CB7551F025C98CE1B0C0EE28ADU2PR10MB7741EURP_ +Content-Type: text/calendar; charset="utf-8"; method=REQUEST + +BEGIN:VCALENDAR +METHOD:REQUEST +PRODID:Microsoft Exchange Server 2010 +VERSION:2.0 +... +END:VCALENDAR + +--_000_DU2PR10MB7741CB7551F025C98CE1B0C0EE28ADU2PR10MB7741EURP_-- + diff --git a/test-data/message/encrypted-signed.eml b/test-data/message/encrypted-signed.eml new file mode 100644 index 0000000000..a399b9957f --- /dev/null +++ b/test-data/message/encrypted-signed.eml @@ -0,0 +1,52 @@ +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="18566fe03178296e_f40510c3b59ace3_ecf843ae3ccd2b17" +MIME-Version: 1.0 +From: alice@example.org +To: bob@example.net +Subject: [...] +Date: Mon, 28 Jul 2025 14:15:14 +0000 +Message-ID: <48b9e9cc-2bae-4d41-89b4-a409e2c60c28@localhost> +References: <48b9e9cc-2bae-4d41-89b4-a409e2c60c28@localhost> +Chat-Version: 1.0 + + +--18566fe03178296e_f40510c3b59ace3_ecf843ae3ccd2b17 +Content-Type: application/pgp-encrypted; charset="utf-8" +Content-Description: PGP/MIME version identification +Content-Transfer-Encoding: 7bit + +Version: 1 + +--18566fe03178296e_f40510c3b59ace3_ecf843ae3ccd2b17 +Content-Type: application/octet-stream; name="encrypted.asc"; + charset="utf-8" +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc"; +Content-Transfer-Encoding: 7bit + +-----BEGIN PGP MESSAGE----- + +wV4D5tq63hTeebASAQdAaI0Cw7tTd10Oz0I1C1Ds5NpF/m6zXlx73Pxzib6Q2Qow +36Fc9KiSZx+vXlw9mZ9zYpumIY/svkYcTRdZwohPWe4TL7iRC8gTZ43/VGZvb+1k +wcBMA+PY3JvEjuMiAQf+MCZLm1vgocYO0xRz2J9Z9QGqScfxBMhYToZbFTx4DAha +hejVmW9AGnNqm8yky2DJpqT3oy//D261HX+xzLfkfWFgHzKud7NtMN6II/d1jyqO +A+K0LjSLPcR3aWl8g30+6bGhHCwc2spP3gIk1aE8S/1c/yQPxD7mqNSkYrY6BgHP ++5Z7ocrud4RKVQayZsWpiiY962w1GJii1h4zE+xhkqFERr1OqepQB7CDf4FBsGr0 +qarJtcWCGqd9ksr/wn8Ew9MPHz+8ooCSlXzJ7Uac4VshUm9dXzO3NSS6MgTME0av +WRlRiKuGCmrS4dyf/Tdj1yMB3jJ52SxdEq0yYYLKvdLBWAF9JU7sQp3x4oivpr5U +wv5OP/tbfRVA2zqkOHlMhBAXaLOsiJrHYh3ieJLwfWSmEQARLE7sBgD5fqre1v+D +Xpof8R42j9MQ3St/+nPDsLpbZ2a2RKBl16C8IYnmK1CYwh5lEK533HMHeSLka/ng +soK2HqJxrhkxYpm5OPWN8liSdKlQ8mZXISGNPo8KEWFlPqONqj88UudpQiPCh2qw +aeFC2Y1EkQQGpiTq1GuTQfG8zO4wa3FTW1wOELsvYozszS1Oc1exH767pS+ozNWa +xk8J7ekEFvR5b3TUK2a/ucmRgXuuowCxxV6EiNeRqGa3SfQnQWnMkqsVIeIiARDt +QMbRCrrAjJ+dQEekviq/hqq9+DRLuX0hOybe8aYjJttg7NLgaM04V4QuvWeVQa2g +2IBR2Nw1pcvbyDRkTLsQ2AlR/ig5VPf+4oqN2nBuXgPzply9LU5JyhW9GNIhH+N1 +K6w8o5o4ebUx0ldaTjBnrFMUqih4nE9qrrb0UYMcQO3HVAueQii0CZqhJy/n/6U6 +cAhnh/fTI1wMoYuYR7cLdf+9P7dHcQPdt3FQA/NrRpv31tyzzKNeBr0VdvoT0uwX +sv0nh+ACjnum7H8yCyoe1tIqUPW0byslROuHAfMwp7rsnBEGJ4AqFYNIdpfSFuia +LsZmODaUG1n6XkamfKPin+Z5Mo8/bK0KwE317Zc/aQD/okvGv4SWsQmW +=hj9/ +-----END PGP MESSAGE----- + + +--18566fe03178296e_f40510c3b59ace3_ecf843ae3ccd2b17-- diff --git a/test-data/message/thunderbird_with_multiple_autocrypts.eml b/test-data/message/thunderbird_with_multiple_autocrypts.eml new file mode 100644 index 0000000000..7683a6a9d7 --- /dev/null +++ b/test-data/message/thunderbird_with_multiple_autocrypts.eml @@ -0,0 +1,134 @@ +Message-ID: <0bb9ffe1-2596-d997-95b4-1fef8cc4808e@example.org> +Date: Thu, 24 Nov 2022 20:05:57 +0100 +MIME-Version: 1.0 +User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 + Thunderbird/102.4.2 +From: Alice +To: bob@example.net +Content-Language: en-US +Autocrypt: addr=alice@example.org; + keydata=xsBNBF4wx1cBCADOwLS/xCd8iKDWUsyTfVzWby+ZGKPpamPTvdj0GFgnf0B1EBaA5//PjA + zbK5iKio6QNEmZagzJPkXPByJcAIRUm0T16tqDtCvxm+H93YEXpHi/XWOeJw9kohATSqUtsRO0pFJe + DvPiMTmQrEmHYoWDSQBfCrowZdvnMAlbJ9JjYOngcMeTxc0jxmPs5s17yFC+1OWu4fwWCyUM3wy1Jz + dKTcDWryrSkvmgFdUqJ7pJDk1HFTt+x9tvQlK3un9BXiRwv0u0zDSuI8eDH/dRLA4UL9Pq6vmJmBam + e1BPsE1PA7VzeTSJR2ooJXMT6o2AmH8PPUfRkv3OiWuh7LM5FSpHABEBAAHNETxib2JAZXhhbXBsZS + 5uZXQ+wsCOBBABCAA4BQJo0CE8FiEEzMtaqfbhFByUMWXx2xixjLz3BIcCGwMCHgAECwkIBwYVCAkK + CwIDFgIBAScCGQEACgkQ2xixjLz3BIciNwgAnPIoh9FWEm5p/SH/KqHfkpctf/47WlNxxFTFGpda/4 + zKpNgAQmMJdZ0UXeBfYn8nY7SWO5Yv/mpQ4eqwvu5meX0X+Vl9XjUcse8tbdSioC+CSwymFdmucrKo + A3gjRXh6r/HxcoWRtJc1+yL8B6gvbToKL7r71yeDedTs/fvFk0cZgpVBs9YJZaBq6OwSaZcWY4McSI + lVysb6Hv02MoLJidP8AOza+A2wRQQ0Xe9mxhP8sZnsnAhQBpD4rN619tXuwWLl+idwAXFxNGamURaz + l1LFDN8AgSM0pEgBBT4aHdRWoWXluVs6eVOt2lQza3/rcUU08RYIhdYj9EkTTDe4kM7ATQReMMdXAQ + gAogeBLbIjaeJII3W2pxsu+SEusQkJVykbGYDtqyXV+XBZisY4GE0kTawK5mqSh+rDqquCxDgYWBRT + nGZwEKohnj2NG75pjfyVPhYMUdJt7+Ya1oiFvZlgrrOj1btjevq53yFtQolMN+X2oS8mlf9jSzIyPC + eDxJk1N1gxaAATg3ByAyB1Td0wDdFPp48ni8qzfyGZeYicvJlx74YnOaja2lnI/y+I9LsmmqiOgI8H + cbmH1od5qSnVjhcpBoTEA15YLIEkSE3C00Q5USlDS3EVg/IOu3FXnLl7v0hQ/jXyv88eycfpSfFcbM + Hot9VtJ4TIPIoSX7DQ+uU2SXJKiZNkVQARAQABwsB2BBgBCAAgBQJo0CE8AhsMFiEEzMtaqfbhFByU + MWXx2xixjLz3BIcACgkQ2xixjLz3BIcYpAf+Jpa5wK0dzwcoFOiie6gRBPooC33LsUA7AK5qJ1NplF + m9Yax3JPSGPmLcN1NbsJfDIlxnfnvqHBQgBQU87OCPynnATkXY/OXQzOFd8UODKetFYyE3kyVSI69L + Dx2YmhafQcpzQ2o/keDynb6VznLEOja7kPyRhzFml/HBdoY5MILo2BKrrMWI7vopRFBbKEIjvdxJAo + Yx97oTVsTwlhOIcGKo3dTPBsQfbk760BM1V1bdB/Us9Vi4l/yKX59Pbt9kqYP524HNPQOtUAYG5qUP + r6gG6EFSt7XE5PbZ621X0yH5D+KJt8F5d4/bLRLNdzuyZP/x9rKq1MUUjRxNes2xSg== +Autocrypt: addr=alice@example.org; _valid=yes; keydata= + xjMEXlh13RYJKwYBBAHaRw8BAQdAzfVIAleCXMJrq8VeLlEVof6ITCviMktKjmcBKAu4m5DN + GUFsaWNlIDxhbGljZUBleGFtcGxlLm9yZz7CkAQTFggAOBYhBC5vossjtTLXKGNLWGSwj2Gp + 7ZRDBQJeWHXdAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEGSwj2Gp7ZRDE3oA/i4M + CyDMTsjWqDZoQwX/A/GoTO2/V0wKPhjJJy/8m2pMAPkBjOnGOtx2SZpQvJGTa9h804RY6iDr + RuI8A/8tEEXAA844BF5Ydd0SCisGAQQBl1UBBQEBB0AG7cjWy2SFAU8KnltlubVW67rFiyfp + 01JrRe6Xqy22HQMBCAfCeAQYFggAIBYhBC5vossjtTLXKGNLWGSwj2Gp7ZRDBQJeWHXdAhsM + AAoJEGSwj2Gp7ZRDLo8BAObE8GnsGVwKzNqCvHeWgJsqhjS3C6gvSlV3tEm9XmF6AQDXucIy + VfoBwoyMh2h6cSn/ATn5QJb35pgo+ivp3jsMAg== +Autocrypt: addr=alice@example.org; valid=no; + keydata=xsBNBF4wx1cBCADOwLS/xCd8iKDWUsyTfVzWby+ZGKPpamPTvdj0GFgnf0B1EBaA5//PjA + zbK5iKio6QNEmZagzJPkXPByJcAIRUm0T16tqDtCvxm+H93YEXpHi/XWOeJw9kohATSqUtsRO0pFJe + DvPiMTmQrEmHYoWDSQBfCrowZdvnMAlbJ9JjYOngcMeTxc0jxmPs5s17yFC+1OWu4fwWCyUM3wy1Jz + dKTcDWryrSkvmgFdUqJ7pJDk1HFTt+x9tvQlK3un9BXiRwv0u0zDSuI8eDH/dRLA4UL9Pq6vmJmBam + e1BPsE1PA7VzeTSJR2ooJXMT6o2AmH8PPUfRkv3OiWuh7LM5FSpHABEBAAHNETxib2JAZXhhbXBsZS + 5uZXQ+wsCOBBABCAA4BQJo0CE8FiEEzMtaqfbhFByUMWXx2xixjLz3BIcCGwMCHgAECwkIBwYVCAkK + CwIDFgIBAScCGQEACgkQ2xixjLz3BIciNwgAnPIoh9FWEm5p/SH/KqHfkpctf/47WlNxxFTFGpda/4 + zKpNgAQmMJdZ0UXeBfYn8nY7SWO5Yv/mpQ4eqwvu5meX0X+Vl9XjUcse8tbdSioC+CSwymFdmucrKo + A3gjRXh6r/HxcoWRtJc1+yL8B6gvbToKL7r71yeDedTs/fvFk0cZgpVBs9YJZaBq6OwSaZcWY4McSI + lVysb6Hv02MoLJidP8AOza+A2wRQQ0Xe9mxhP8sZnsnAhQBpD4rN619tXuwWLl+idwAXFxNGamURaz + l1LFDN8AgSM0pEgBBT4aHdRWoWXluVs6eVOt2lQza3/rcUU08RYIhdYj9EkTTDe4kM7ATQReMMdXAQ + gAogeBLbIjaeJII3W2pxsu+SEusQkJVykbGYDtqyXV+XBZisY4GE0kTawK5mqSh+rDqquCxDgYWBRT + nGZwEKohnj2NG75pjfyVPhYMUdJt7+Ya1oiFvZlgrrOj1btjevq53yFtQolMN+X2oS8mlf9jSzIyPC + eDxJk1N1gxaAATg3ByAyB1Td0wDdFPp48ni8qzfyGZeYicvJlx74YnOaja2lnI/y+I9LsmmqiOgI8H + cbmH1od5qSnVjhcpBoTEA15YLIEkSE3C00Q5USlDS3EVg/IOu3FXnLl7v0hQ/jXyv88eycfpSfFcbM + Hot9VtJ4TIPIoSX7DQ+uU2SXJKiZNkVQARAQABwsB2BBgBCAAgBQJo0CE8AhsMFiEEzMtaqfbhFByU + MWXx2xixjLz3BIcACgkQ2xixjLz3BIcYpAf+Jpa5wK0dzwcoFOiie6gRBPooC33LsUA7AK5qJ1NplF + m9Yax3JPSGPmLcN1NbsJfDIlxnfnvqHBQgBQU87OCPynnATkXY/OXQzOFd8UODKetFYyE3kyVSI69L + Dx2YmhafQcpzQ2o/keDynb6VznLEOja7kPyRhzFml/HBdoY5MILo2BKrrMWI7vopRFBbKEIjvdxJAo + Yx97oTVsTwlhOIcGKo3dTPBsQfbk760BM1V1bdB/Us9Vi4l/yKX59Pbt9kqYP524HNPQOtUAYG5qUP + r6gG6EFSt7XE5PbZ621X0yH5D+KJt8F5d4/bLRLNdzuyZP/x9rKq1MUUjRxNes2xSg== +Subject: ... +Content-Type: multipart/encrypted; + protocol="application/pgp-encrypted"; + boundary="------------EOdOT2kJUL5hgCilmIhYyVZg" + +This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156) +--------------EOdOT2kJUL5hgCilmIhYyVZg +Content-Type: application/pgp-encrypted +Content-Description: PGP/MIME version identification + +Version: 1 + +--------------EOdOT2kJUL5hgCilmIhYyVZg +Content-Type: application/octet-stream; name="encrypted.asc" +Content-Description: OpenPGP encrypted message +Content-Disposition: inline; filename="encrypted.asc" + +-----BEGIN PGP MESSAGE----- + +wV4D5tq63hTeebASAQdA1dVUsUjGZCOIfCnYtVdmOvKs/BNovI3sG8w1IH4ymTMwAZzgwVbGS5KL ++e1VTD5mUTeVSEYe1cd3VozH4KbNJa1tBlcO0nzGwCPpsTVDMoxIwcBMA+PY3JvEjuMiAQf/d2yj +t0+GyaptwX26bgSqo6vj21W8mcWS5vXOi8wjGwRbPaKKjS4kq1xDOz04eHrE8HUPD8otcXoI8CLz +etJpRbFs0XJP4Cozbsr72dgoWhozRg/iSpBndxWOddTl7Yqo8m/fyhU5uzKZ41m2T8mha6KkKWD8 +QecGdOgieYBucNBjHwWc71p9G6jTnzfy4S4GtGS2gwOSMxpwO7HxpKzsHI4POqFSQbxrl/YRwWSC +f5WqyYcerasIiR/fnOIw8lnvCeQ5rB90eGEDR70YFGt0t4rFBjfGrSPUiWYaTaC1Zvpd+t5sy7zy +FpsS2/aTkwP/UpGqmtFaD/brSouRf9hijNLI0QFTaVmSoI3BKzF8B4zwvtEbOLZjyDb+Va/fZJ3w +nYd2Q/5PPPL+pE4pWKN+jl0TZNzAaqBgvggXomgUqQ7QiksUzym+yuFKrJX0RF2awdrgjQIxjnda +Qp3UFphnFTyYUJpIU9iewjOfVxgPzv7PyuCHYwoP3kh7MJZ6bgbDmOkeFSnjEDJpdf1m9xC9LlBL +beC8scmPs6kx9GARBYSHvyPQ025gN3+XEHh4OrTxHZ91U3IlTfd2kACwOOAXEuhItSHmcNOV0K4M +nI2PH6gW8HgBkWlAPm40K4jUyo3nl1usDiI6ouvYqvW7YUc2hTtPTej1l2/mS57tTt+PFurKs555 +5R9DD/xg9Nx7OuQKy5bIdlXM20UmwuZTOhRJ5kpHFRzLxaHDbSzW+orhRW4llJSevBSAH3cLOjIQ +gh87j+MxG9j0TD2K2A0rcUcxdrnflw+mxcDVaL4payeqmOa+bJyhlftTqH+vqq5DhR68rX5VW+z7 +riqH3o8VbvO2y0XSpYHf1jowkfJj3vr8pynAUIv1dbylUSF5wtrHvzWOprw4bNrdtwQNRNy+JcVF +dUKeNmHaL6XOe4LUWpiI11beRyCpAG52khMCEAO3Q6+4e24cEipbu6suSOtv3OpYDZeHjwNrQIhi +rJg7i9TpMqwOeCvFWK+9UZ+P2n6h9g0/JO2+I82BFGUjVa5IvCTNOgv01GqxWY9ecdtaJjTc+dF2 +OAcRoKwvmtMJlxKEEgveui3BvPA4tuNdSrcoZBrQeo0ZHWVugXPvEZnwfZMcqwwPA+a/sUbZFg0P +Pr0AR0ZHpytnQE9OXE8wEUgT8H1yofQ+5QoZdgMpeAb8zGs+RuviLxcDkb9NtXUAiQ49ooWuFP3L +K9wMlaoWFTq7R+n5JVuSEYRCHC0l0bCV1/+awalT7XltXVCupI4lWzjYs52FZGGzuHG7S50Eufad +m4CQTPVgVaVn8WW2dmpMR8Gj8WbbZdyv21wMGOWjfgT0u3oiDnddGrFOoMNnZHch6rN3FRppoh7h +0U0fi8xxU1+EhUKq+fSIxZNr2iWN2if3Pipbxi9tyK9M41Y6aVF3HWjD58/OEql3aZjJZ1bqpXcE +qsPeFoXX78+7mTDvL75olMk2s/mg4mLqAAWQvTuoiOmj+SgMIFuTtFR+4r/TIFNdamz6AQ3RcmWG +ZcdRii+V27dtMA836vlAwxXRmJyE1LCL1kvUTq+J+AVsZi3xmBLFNlKPTlxswu7vSBrP1DlYOaBq +AgA0lKnkQdeXyDk/VdbTml7ywMW1g6HkFSqKGW/IIAObmBumBcIyHE6dWEHumRQomlJssIlEFSe+ +XEQ0rwedLetJXi5A0AXT1we1wvaKCEg0Pb0ZUxygwNPDrj6MmdodH7gDfyx0mW/7mEMCtIJb5MB+ +TRGPEa/vqdJb8uGtNXUy9UlwMhJ3tYoT7NXY4+IlNjbDH/yleMdwtWP2H2WH8oC+ysXPYXjlT8eU +poxRfJzPMVUn5SA3cvdGXDJWdX8U91j5sf9wuoYE5RBVrrJif3D3l0FpMrlWWoGw7wtZbMC2FaeT +QvdMS5c54IoXBtBTM+/AsTAw7WEE1QSmaQGHnh6xLL5Ns8olsWeKOMlVXdO9jSDbjOGBLr7mWukW +YzLXkH3TtJPQcbVN79af3YPhaHdMYITVKIwfg+vxZlLFHWLJQnkTl+9Qi7u2gKqkNeU7Zqs4E3CR +9K4dHrJMyAZLZ2HA1XQEj0/tMnbTpAzZhj02JRcFobLXK9SQfw7dzGZwMRky8cHcBHoK14P5RIEV +hr+38HSBM6wXtge5gL6DomAACvuORQO4X9x/CTjRt/J8uN3lKK5p+wi3ULeb319CEWiCiqmC1M+C +TADUhPUhUmTinSAVkTEn+BdbH/97dVaJnvd6HtLmdSlw4xqdWUfVL9Qd7+/5L6iwlOzGLKRv97c/ +gCRw+hzXyAom+5C18slSwanMuyPgIyrrFy/kp9Romk9SQr/c0CUF2am99t8G5qvVi/TiJGHyKEXD +aUYd4V7lqNlHMiiasvFHeq8blwmFr7rGEvbZzLNplc6sRUVlYhY2unRfyWsq9mqk3NDRW12Fa0J2 +YxQJlnXHQhNE8EyM/zsD9jCVNwsRZJ9/e5KS+ignmu6gKIR+ItDTwRfNI+NG/YmTgENUTyuO+vQC +CUKS3PCwpP+OEC966ARl7OCMdfn1hEyiAxsZnp1RmFngR6FM+mlGgfUoWNoHvnR1/YyQ4F4dadiA +QINwuSm5faw75F1EeL8Qi+LHKuqt05Pi/V9GJ6TzIkIsEbyyJ5sKHrp4QsU4C1p7ZhPjddz8De8k +6ZdwMIeXxi27WKtsFLcr8JKOBe0imIilKdMBOPS31pc1iJe4472WbWM0aBwdEYmnz9+xfOqnjHtO +0XTMjff7pzV6Y7t/u8J/zm3JS3ykote9HNRQvhZZNeVClVWd0fYFzat5ESnTojZTwHcc/BFTPnhz +VgLyw1KEIy2r3ZyGHu1b8GSYivzl33MOK/NVBQPZUIEfdcQ5vhkAvj+Yx340IYykRFEChwioprXD +LrIbTou7TNT5fTFA+beidHFsL+OE002/LMs6C3erSUW5C/LNjAQMS7cAV2yCyjX+/2GBmmDqnC4r +Ja2x5yik+fbOUPh3kk/md1YvrodlX/JkQeoWRrrVJsX2dr3BgivPJavaN0Jz1eHyxAYKNqlrfd1T +YWEDIisWerTxAVY/rEruZ6+OqLqOtZtn+4SOajOq8KFusglaMZqoYuM+LhPZck9PlZXwRqX08Vlv +8jX5V75BFWRhFd5/LYbnQHI6ZW80Wb2sBNngLL2QJT9yXGCDJb5qCdFwGd3i655pvRJXabeyCtDD +7I2PJcYRDd4stdq07BHyHJmye6vas8mG5QUygyWyUQv78za0m4gLMrRZBgoBDcVpWJUc+cPXzzfG +7PvLZu/Y0SaD5hqTp0LBB1PFxTpzdVeJ21gzVNQ6D4XGLTtdv4K4fOEYoeKEuzGoBaUDtIqz47gd +5rwfQ3ps2slkxfbtQcdKEACKvsCwzqHlgwsxD8QNOFzXYLiiiJBX22fIRoiJeSDMKSZyuFtpykCm +7bOpybPSHv3E7EIr8sIOr9MOe/R5HSthU2IgW1L5Ynr2t9HUnCA8CenkzIQjg0h5sruxcGWCYLx7 +q0f1AQs4Z7SebVbq1SCWVJNX/vc1bVjnjYfri7RX5WMmjJkuSnuIoP6a42cqJcAg7m0STB0elFAy +oO4vW9/JEmFUqLyQmWnoLJHX3IKtWa9CPvE= +=OA6b +-----END PGP MESSAGE----- + +--------------EOdOT2kJUL5hgCilmIhYyVZg-- diff --git a/test-data/message/verification-gossip-also-sent-to-from.eml b/test-data/message/verification-gossip-also-sent-to-from.eml index 27ee04a50c..f867fb7bf9 100644 --- a/test-data/message/verification-gossip-also-sent-to-from.eml +++ b/test-data/message/verification-gossip-also-sent-to-from.eml @@ -34,88 +34,112 @@ Content-Transfer-Encoding: 7bit -----BEGIN PGP MESSAGE----- -wU4D5tq63hTeebASAQdATHbs7R5uRADpjsyAvrozHqQ/9nSrspwbLN6XJKuR3xcg -eksHRdiKf6qnSIrSA5M5f8+jr1zmi6sUZQP/IziqRWnBwEwD49jcm8SO4yIBB/9K -EASmrVqvRHc8VZhDR3VUYM8VFtbi+gbcu+/av7fII43AgN3qoluv6Wqj6jrf3zF2 -psDjegkrDp3GNMYOGR/qDTsouoEM46tqLHhrYB870c/JbVfk/6HbSb4nmrjur3DT -63hWoqmh2SCdUAdGBuQMFE+3edrNX3AD3a8wsSVRuK8dpSacY8TgrAwmtaB+Epgv -DZQocmOZqJZ6TgOrEeZ2xpn17Yiu3w+WMerfbIFqyD22W8EnxFRp9AAca7pY4KIx -WVA1J311E3hmStN8kFKa5hM94Ihgo77YF/45KsLMjKblufvYbC05KExpyHFmrW09 -tn5KBedjkoUcD0eA8k6SwcBMA1Kpnh2oYq7LAQf/STVBew4ly3d9mFWw9JiBAMbb -hFi6NbnwIR/ZynrhA3pK8EW9vYd/xb85/5cBjc+gc/rtDIKaskI2THPaTnfrk1X+ -EVIYRgua6v9JvPq+599j0rL1neHNPCgVw/zVF5BhI+nx5FfiRBez3GRoJV7sOpjH -ftf+zwBT1cZ2z0WkUDHKXUATIqi9nH0ATCYAd7VzIPmlL4GLH5jh2OW9zWlE2sCn -RvfRjL/izhAUmFW1Ks+HMTG15Qcok4rpdYRwFCfX3S5EaxLEgnruTKeDjDMEew0t -Kzm8FsyW8nL62Jz/OGcCTprCtn8ex4AjgrWnru7PTcXb/4aKh39AGjmytAEYVtLL -7gGGoqxH4N3BZ4KeWLzd86gcXEEATg6Wrj211BlSkdFSvL2XHBpLNDjj/MfGKRfa -3PSRl+P3bNLF6by7HkdXPAIBqGn3qi1YQ3Bu7JQOSDJ/A0ypjS2TARcAL9c5Oz7/ -FgwN3b87tksX91w8T6mjhGcj+BNpWt1Xyc03uzt9ky8ZmrXuqF/f6RZ70JLWmKda -qmRod8dGxjALgh313tTV1UPtainUBTun5ISiB/nwdlkg6x3VdGHCaPvTcrWdzgBl -/bZQXAjyUiBPWwnsDsIS/fTOhoGj7CplJUnXrCLPcm3Dazh8XZcCxd3LgiYh7JX1 -X/54owVuwuqIh1yIcJGfIpsep7IgC5y27L6pMKaFk7o+HQZWE/yMQLGxWX201bi8 -t7nkTDUFy80BG/ex3mF1ynwC+Q+Wcrw9C0qNBFODAiiJyx+qHK5cw1OXOULXEnvu -jUY2CUvLMYDPTXBT16nkjHq9pjedbL+SAjdxWNPx5x+XlLM8fsv1UPw8m6LQGH8v -N0u/yCnyqj7xMFL2q/iIzKAVwkaMQx1kN87xqzm94Wv/TrbCMMiT05z36/uCJCVc -NTVyP8d2hwAr02ctlMw9TlOtUIhKKWAaD5Yh49O8WNq7bnH73Ifz+/pQaM2u5eT9 -di5tuPPM8+SoNIvvUJr0X8/JCphzGj6MDwl2AdG3Iwo87EtUs205CpV7xIkQsgIH -dKZNCmClNT3D8paRwHqDVwtMFSKfsqe5d/vt4Er4W2EXeF61fznunk+l2M4nxIRM -aBEjRWzYJW2V0NaAaYJQEoULmExW0BqK3dzjhVbrreSeo2/18vzQlGSJ7gkIyUq7 -+gwgsxvmHTFOBHtvInb9ZozgDhv2/39Ig+S5PLiXwPkte6PwnclVh87e+W0cbbPu -53me3jDJrBifsgLsJJP2rn+jL5+Jptb3ocZVnfcfZjHWA+i1YxRSWaalhKCpC4GF -+0toGelW8nBkCaetNuaBueFnOvb8XtzZqucLm3li5sRpsqEDiYeL8vz2l1z1Rm6U -+aqVP9j9n7RM00Cw2nEyUbJ4BMRqhQ/n5gXA3PG8rgPlcXKwdz0QmCURLU6YdYub -Hazkehk9nI9KsXtcVricMWsSY2AzGOHVYK181KUd9Jsx0lClC6lRG7Ykfth9VIWG -H+29yiiP0hkGfSY9hTubYqtzAkQ/vHWr7ZSlOM4ADa/6jhq5nLMzofY0+5h+DXkP -CPXrNwx9K4demw8EM1KeYergrtROz+k9gFUm4bQTjvjevOknr4vA8PXwKqMLSwUG -i+QfPbZNNSfqZK3QEE/9jbdOGPr1qPYvAeQnYkoI+ppCehSMK8sSrIPGCUVHvpj8 -uniPfK1nJL5c4klsBnWKLAFxhsq81/Eowfov0lsnhJNE58i2SEiSuf52+CJb/7ti -vBLFRGP+fx76p2HHxh1A8M/RlgSASjfKVPWCAdszNh7psLtLOiKm/6KS++TW+RYB -9sMWb+spy1xLfGz2LHw1n+VaQ9n6+BUj/QSGwMG+ypeOi+N2SmL1WEXx578mbO1A -hSPSH5q3RdjxasULdaOr/FMm5TTcEBbMPSTb2Dq/c2Zl6tcPTKGIjisdXpWoPbvp -er42Al5f1iu5II/RkkRYyR799ke0868suHUcCWr6qTH3tfPtk6+S+bS2jVXD9L4z -KzF06J/B4BQ9v/SyVyQ6E9nd0Cf1bFi7Vw/sA/YyTKOxoPrLd2tUq4oARZyDX3bm -liEhPYQEthRQ9e6WlFDbtawGPK9krlJOW+VFUZxyuzc/NJfbuf2aalbYulTicezY -ZiqWVg7I7l3oT0iwekle+xy917Xqk4rUPPtnce3DKJOs0AxgjAZmfFvyxYqqVqWS -do3f2agJZjfD5Zr3JHzSb/6ponAda9rGGVdkQyI4MBJBAAHmMneiq7OPM7dX0vXh -OmRrzoq16bi2nYfDqa+q/vIwOMepyO3Czlwpz8xjVD4ldGEjUhf6hVbdlp82Dg6F -yUcHgjnqtrggBksXFKk2+n+3r4X0vktzw9kxnbCmtp9QCBy955mdu5byAK/1V/5G -D1iwDALx/arBSXAhw8tb/ocE+VNSnTSXDKsZ5p3IXsTZmrTaMhkHBvU7456JKptE -hpguQbSAPCPfO2MA1XACVL2T2eiTkd2Rh5vKr5PhslOVI80tjK9YQF246VOqBur1 -zr5MqzN65tlXTOg8/SaEB1aNXw0Hr6vOz38f6rECNpcGp9Dzwh5VBq8FbYpgYBg3 -Dpq/0GYYZrwd4+pUuAjFCn2Ib5+Mr5oIdHVGOTnwICGHoAjjmNouVNPHgdlU/zsp -uENVX4Kqu4mz1GAvI2Iv4KXqPXCM5IJfInm+QoqfNCd565iSX0ZSFxO14XYHyqNE -CzuirR7hYzKjk7g3s9/zMkra7sZ3I+SgSjrVn5gDfYvfTMmi6JetnExrfiNsLes0 -bU4iZ06CBWS2RV2fHIBqeVBLONgETsmNO8fd3IKz3L3LBzwIBxdjPzkb7/UnbR0/ -2XBDquVabit+wrXd8wYBWmWtrE+wZFtXVaRvZESrFe8PxSua5ErCIxFecdb3DQdc -P53dKs+TmGw+/R+x08lZTAIZJFGjoMlaafY4xqonv7JEGqDLo8C0Awss1LayfS0+ -0pnHA+nkVjfx14xjsBnBGPsYmEPUjtI567gMRPppNga9NH9zw0CsSFpxBzfmFe8j -Nl/6YeWzZ28F2W+JK45Cj+9IKkciGRbc4dTeRz9p1dbCxwJyLtFOPYM8wBIgc2V5 -sDMebe74TMbBaBWsIAx9W9fEwj3OCdDTbvaFpbFJ24gsfmZOA6ZMCaWbk8Z8n5x0 -iClgfXyJZt9noK1SYPssHvNsxSsVpgSk1eKR036azz4syKsaLqxNNdLdfEPHYVo7 -nn4I9oM0ElvtQcvHg7lK7U7rgyI0RpVFyEjI8x2DHm1jRAFiWrmJIHHEUnVkNXsX -kjY2vas5l7lCX7/9JzfxP3vLhrZAuuXAJnUSXHQrLraXvMvgnRSe+zqx67fSfvEQ -iwkHeed01c7g7kDp8wI4gNXhsb+bb/hra2fhz/J4EvcVwsl4/u+7Dk0GUO4SpkLX -tEK2aCp0M6cL1viZ1IylnReNXhwa7E6mfKShe12+a7HzJO+ZbLzZ9DKTUH34EOeR -gU2dyA798azp0ZQu+UtHoYHxE8P+b6W3OKQZEWqULu9HLxQvuM1HSq4a6XJH5Tnz -r+H7IH6lSk2l8nFtjJBgI+DqHYqXkeKtlB1oz1bguSXqYOoviWd/GLsuZdne0d/x -BIDlYlp2h6rmvp+rVCaG6EJ6qEcMAkgSC2KgXP810/pwBlfFigQsB43WicGE5Znb -mhYOtQSCgYy5b49yFJU8n6KAVLHE0mrfmFZozPPWstnHXtriuFzri8E0LVymYnfy -ICOSta9MOQK++/CchT8hnjQfKaLx9BdwrUC/qXQjePcz2zTb64ex59pA4rGT0wal -gbGc9KkJEl6Dxe+CcAgL/4a5gXvXh1w8cPtimSSx6dpuS8cU7xb3XWiWpZKRsWOV -ul9OqXbw2gZ4lh4zfLv7WJN9dwMrKjG0LA0QqEZUA1/I9bpXIts80fz4vld4XPyq -wESjCWD6C+Vzvv5iEdKUYS9urL6K3WNQ21foiurcARTE9cUvf4ljc0vG6rpDp6QL -ak9EJxxDte75kI/MWup9CAWoZECFpTqX3ETbiMaymGk7We++sP0ULuwcghAM1/sA -v6teU5yQMCOjI8Gnhif43sdB9msuHzi+/v+7QFPTOn949o3au5rA+NE4N1Qfp5bi -3hYAHpz5q/BgL9IzHoqkGgoJBh3J8V+86GV28E8aiMFodenzvojowvISdAobvY1O -Y40VZYmPsN8dDzoD4LBFxKIryz5d6dT5j34vis7/i7UYWmvBzb6Nb/gf77CvjSwL -iYEMKLlgsLNBGq68PXCEIG9/sYpQzsFALB4Fx5Hc4GM4/Yo1oQDT10tHZNv3ehLo -aTsQQwj9mjObmWC2d4FpWWrnFMayCqY5ZrcPeyA7jrR9+hPGzUlCuha8dPQ3+JKi -lijeswzqV0/4Md+0Ghu/sxf2S0hUEQ20m1vXTXrHch3QTrQY7wijvVRJfpYSGdZW -hrSE9DrByWEL61imLaOxU+SEPQ8w6ia3m/tREeIo75ZrJ8lgasb5/CKU/gvXnVCY -3FtT/YinpYY/FBYhGK1QLX6NQuN3sMr7Jt80i7G9QI6O1g8CFBR5qqNZIRp160/1 -dE2YxsihlFM2jFWA2V5HF03WQiLakaYc0uxomGpps/BGnb0Gv5pqIpCo5Ii06RaT -xicreEfIE24TLmQaI3vrbMqBc6Yg6XTUsvEnwo3lGw== -=a+ak +wV4D5tq63hTeebASAQdAskXUGnR67hXwkJxNWpovE+gx+/9vY9qH9oTfggRP1Dww +jOCvLyKyDd6ezV7lW3hFQ/FxU+7Xjmq02otcwe3kHZrh6jj86vkD44AYFI/GfVO4 +wcBMA+PY3JvEjuMiAQf9HCtOIpTv12YXn4U1iRXIc21bPVXrM0/O4Yg4NNCOfIN8 +80VL604DOVn3ZTwL9vvM2OJgBL+jlCHPcbdDr0sUR/Zrw7btqSLbNaiAk63RGoWY +koJz+5f5j+1qTYuWJCyhcIFgG+dILfYx602FOnmhMzf0ULlDwW8znhM97fUiO7/W +0PvXCbIOi9VyrJa5sy6wJ0wr3hP4mvlQYHGCCAyObD6+sztCp67FrO8DjHAwvaU0 +qTryl18e53nCDcEoiTVuJ8SojvTLle8DvNWqAl2C6ZRoJYzFK4d9poUlE+F5fpwN +vQEPuTgNapP4GRJsa6av7jczWLRDntUewrHk57UFqcHATANSqZ4dqGKuywEH/2ur +FHadl4JTs/FHz+WqtMdlpMU34gBK6kQtj08oyQK1pCAEKoOKLQnz9+rXZqplZu+f +G+JO8nQB6PPfxtes0qiCUIM1JXQsXJOvf1J10ArbUUgNJ+vGIK4naJITa52wXoma +SMQyvgzfawh5UbGK6L9bpd9ZLeGp6PkABLhY6n40ZsLvvzW1NwDhte16a2YbrzyY +4tzZekpuQ1OlbiiXOj/Ialv3yAv/epzHR5D+hf0f0EqKp1nGA3uPsw2XFQPRL7G1 +ipQPgD561a5RVooVhJgYZ3kBNIgZ4yKVimpSvFf5Eoa1Dxwgyr3oREqXNIYx1Nc+ +fM6Y3rPiRsgLD9YlETvS0FABY4vOs7uUDPTA0fzdgbgRhwSC/uQP9W8ysVGj3los +mrm0ImKY9KW8auPZjZfc0dXx/YsIW3fT2qjM8Mg/zN4Co3NlQdwXmOJLNRhd5p0/ +wzyzTSZXmvBsRcC390ddiksdpCAHcmTZSUtoB84DvuZYqR0uEnXWW7LglB+wsEpR +38Ek9x5x9KdneILeWRlETm9FDx0RJ4RaYPRYfeWSOG7nt/gAuoWt5DV7d/FmNPGH +h2PmxvQYc1+eMhGUXry/6WSYgR2ykWCttwmQovsymVWmRZnb1CtRVtMwWVL4pjYG +YKYKfiLFA6jGyOZ7shz00AfzJXqZ9/8w5pZL1QTj1u68ZiIS+JleRaw2h1LOKtyh +TGVVyahDzw/wMNfMhL0VDi1m0x2CmlYfHT1+LoxxLGS1W/vsJivk5X/m5fQ44uXo +JgnMIQ3xtbAobxYkJ2joCbsmgn56ftzn8bV1dvFJw1krCu6FG0bxWOAnbcQQLAtH +XupTtvtJERbMngUxtAkc1XsZmwymBtlUD2XB1ZMI/NCNKpOQTcr6443iVe+irMdS +A2VhrO035q+Gl2G+X2vlJ7FzlMwUF5gTE8MHesq0/w4oNX4Q5cM29BHmn9BeTG2J +oYlyhxKcZrlJmgbnFe1EMZWA1IMmkrdy3thvN2QSqWnKwYrON6ubqiZe9gZDkNB8 +mP/oLJbb9iTsJn+U9uCGJsuc0L0OwrH2fDeYoL2Fp3JM0Hh2vvyGjBcWdgdK8Znm +J+B7Ja18+VXeBskOPfA+pDONsx7/1aGrP7ut950Q2c4KWuIZFIjFzJyaQrsnvxCG +lr+uTU2ZcFn1M9ftCX7psnPXMv/M2yEI+nbZcTMx2NDEqHllzBbdrnn4I1zsT08G +q1cjUQse183HNI7h+TBJyjgQpoeosff65mCefapXtRCNoi4xvbf+hZYqDaEXr4C+ +r/LkBfQcnophhiHkd0gWq2SQzCNgASk+rCsMwaEprTQwbr8eU9mKYLXSa6/ycWGo +Jfu38ixm475d5G7tzgPSMjbYfC7OLCdoJiwGdX8eyzk1UJm6jPgrfDNLh3/F61jM +Fl+BRodWn6A48+EOGxmPzH3vo15rzoZI6Ors/D6aiZq6Uu9P3Qlr1HnNqDZmRX2S +yE1rRqfrKdRemRydLk3HS8KVRKhRP3iwH5uWpVBGoWAVC73MXb1w0vNQjjaauOBq +ZkHHP2u5x7rcfgKAWOCNZnnQDnM+vjX1dj84QOVXNukYjaYdtAJNL7mc7fUWWmTI +MpeQGd+TWyFA8iWjUgjeJ/Gh6ILGfCAZWWrsWiLDnnlc/g7Wc9pAXVW5hbspI1Pg +yOi4OC2arv4U7ffJRYljBjOEQ8hOY3tP4z8LmtM/QtY+l3+IhmiEZw1xgUZNE6Gk +jSCCFE+p8kD0HYpaU4I8xrmcpIaZ3leWaQebE9wJAqWM3PuKMCLCk9nrZR0GFpy8 +uqRquEGwUZvYTqhQQqWvoNn7xq1nLzRgMgmj2uA9w1jdg+9OzvEOK01zEI2cX7EY +HIII+d60JWiYFY6ZMykVcuZOY5ZpDKwdfyqVbzEhnkpp5VP5FGeqoJDDp5gynv0A +Z45yrANigCTfiBmwVQWvkN/U3VgjDLPPDmOCV2MQoUsywCugcDRn5kAZCZEMNiuV +TcRrhtj5wKkzCFjGGJw9iycn6tT+x073fqFnUIKKpyTfL3Pt06KyIv/GCtDhPfRl +5moNZ3S9KBdqqlH3QYmzHwQJcUtBHmuTa9kbh0Q0k3jZBrxAxreA/bqP/szLi4KP +28rvIVikwsg+WPjQcxfEdkwXtcC8tBezZSfvKrSn0ogxv0gUBMaawoz6Apjz3sAY +LDWvRngj2uo+Gp210l2hb6QHWNOSaQeJJ4dXek6vZG3zPukHuvfmBVFLcR3OxKaG +Lg9nPcBz8q5CvRu9LzxWF7XQ/+IvOPoSeDm3k2UoEgz9evAIjGsR4PGOdTck0EFb +WOPqhEWtrxtXRqYIaayTWAwcLGMUqsluGlue7/Ca7WDLWtG7/PxqGa1SciQ7j9LN +jd5r2wOX6gqwv1Gfn+y+zrCZrj/OKrC9LHJMIeRQPBPb88VQmwrqadkCy1KyY09W +ge8nT4e7lYHLCoR0iu1aXb+xFz4E8mF8kVLdfdjNO9pTX6XrMUjfmjZjXGeb06A3 +wLLdBOBmskxl8H5XzlRuhhSJG9pqK4lIdkwgMQWoCK5ISjlFz/Yb9X1IQdJvVdHv +SRp96zBKKBBjsOshv5rBtKKwR35piXTroRFstt+gK1it9pO7b5nu2Ea/MXdbPhNL +4H4ViNVBBohEtYFOkMyLkPACeCdkXYnptJ9snCTB3YU0+6CZK9Goevgb5SqX/+dK +uSnY4ul8Pjvn+UBOJ0HVGbbnD7jI0ZpSSwMLrGLE3ON6ejM//NQw5cEO6UcnnKIL +z+SKJ/z0m3J4+qM5+Fcl5kxBanrWQYo90E2JhCnDgu7mTdx4spSdKNbrLVt9Slf0 +XFo1G/D1jhJ7QoDFyQ9jCd+WWPTqdF7xJ6BmlJCz5/m5Fs6quQA4tUQg3wGf419+ +O1uUT3Jb9/t1Em65m0R8F1fmPoxh0xbHaljG54EXjpwas50zzFb2zXAkysuSLgXS +A7N98VvYg/v9ZMEvK/qXKHrbYFOdpn9Yxdru3fxdEWU3o4pGPHkgS9EkSyZNwS08 +Xdr8eoWn9EAAwxMtugxKAGeIplbIoL9LWYwKK82e9SbtaHVw8/Nm30M+Go4NpeYD +YHYJTH5dDCIY/Gj1wFHVu/KBzgAlN1dcnL4b2zjNHaRXtB9BwsHJ5nyt3M4Q7+JZ +BbLubFONAwWFllbw7LWQbHg20/Kru4366493z6W3n0DDODN+UeR/2zKQp6Wo1CRq +GiSvJbKRoIhbx0XMqAUrzDWjDBr7bTfkK9WSqV7/Ue9AGkbKDKfxNysa6enNlnxs +tbDEEqqfpktv9FtSyxLSztTeGTBBltvSRaKhdeQgfeMw6tSMdosa65qDtQbNM1Ex +Y3x+z2vz9X8I/n5VOP+B+XdMEC3dwYc+l3eWJqsVKnEoRd01YMh3PMGVM9NQbcqf +BGP02C23XgAjee0Yz6YQkic3aDgRNV5nh0dMCUzVLHQngMT/+oX4z8j0vEV+MC+l +m3VreBcVXBPOqEJcm24KE8212TTBJCpvUoZ3bRVs4GfdV2mULej/Gc4HddehEwYQ +7Z+gbITkzoWLt7YoRsHDNOgeVIuGA4MQR4qZPabDwFWdTSbY/bm6wKq3hQVU+Xi4 +AgW7FBNfTS0Y8Whkm4FdwNa42Hq9iUYVqwCbP9G9duVLDrWdnjuKNcW/Gt6vb0/F +hH7jap28b+dG4Zu6fPD7pUC2LeCTmPrOx1lpDLgA0oA9InWi3pP9tmXtvgbK++L9 +BBSroevRpWWFjGDPpEqFYsyzo/JmadZ9wQIVWBANALi65/AmV2T4W3oIQ+MJw8qJ +EU2NRrBvKIhT1cZ/QTGQwsHXyiO+EzRKHUGhdsMufrPDUcQOefXPBD+NfriYEFnn +kpMAS5HeyEj9y/O+iFQ5eYcxLqkpDhKfzwBU7VaUJzeMXc39ek1TQKhzfD8sJw+f +NeprKbRQBj16yES1Ca+VONCcUUmaExqp+wo6MoKGaBd62mGPSh1TepjA2UmWPsHB +NM7tYIahd+eZV3ZjL/cVnL4FK2y2AW4UFQ/aC09oOS5e6K0hnO1kjyg4Z7MrysU2 +Lwa2u268Thirs0RGtDESlAXoqICRGteHZ5PP8eVIO9I4GKW26geSHf3esNMAgzGb +hkBpNtQTR8/kInVwy/jL3ITpu6LZOfwzrhoHtN1X1gKWf7a3OuLKnU1cBHH2Tdp5 +HDrHqEnztomHbnvlKQyoDji9BXZM9kVfgupwpQIaw9LPMavsZWCAPkAH+kajAaBw +xCd1ZfzD44mIThMAFl7xAWoVW+8tzdKTEIWYP0eNAQvjN4RrGtHUKPhUHP1Lyi8Q +XiziAj+YMLW8VwGUQi1/h/LbBg+bAYQ7P49ktYU0b/ZSL3hrdmUM44jt86TPehjq +IL1K2PUHAbbFrNLVg3qyMyFi7vpnYh88G1KRw1XaF+hSQYY/ykrA2XlgAdZoel/1 +2EGT/4VWh2X6tlxkheYCflQ6Wb2LrCgzzZDE7zei6Fm0b/TCHAsH/kYuAT1bIcVs +gAcGjKcxkB0P9HCHGAr7JCcuf+FF3UWxPbYSH5m6LOMBUlVLsI2md5Fo3EcN07at +yu2jyFYnQWW3aohKwerbXsJPy+acShMAnmuIevZ/V6C9HCMCLYyizcrR0VpXQ0aG +IdtCXr+524wVB79JUT4Z6RM6HIZD3Ce4Nmp2AE8R3X26HZvo2HiAHg2jCun1+E54 +pOmNWBQXy94R91O+edu0Rl+qqe/BxO0B7QuPlZ5otWvBHUej0CgJmgMls2uvvzmu +3sfpXq/PeiUOeE8EhOlQvtRBxhJxOTjEcO14j4SD+/D5BNlA6wDWUd2TkDDdjuX/ +PxlRUCW4sUFNAv1oDyIN8hTnAlR2ugroGHLoJ0DAsormZ6HUeWWtn4QLmPqf2aMv +UXpwo1tBE320fY+RbV1gaNISMGnCsTiXakdjIfPxJjXi7Q2lSrblqf96ZZg0N/Us +uTWSYcym4A6YJTpREf/zikwsodIP27OSnV7c3sQDiKWwTH+jN6z04sx2obOYg2MW +1+VnQh13C8uhgUqWL0yN23ENHdPq5kvqDiU3KM3jWJR6rG+yKUiHvROethNR0DMD +a7UfWDoj0eIkwY8+/JIgevv4aKdN6UM8MG1+w5HGeJDN8Fb/9szitW7K+STjDGUw +QZ9tDknNp3ZMy1x3WJ7r3TVvWejeM4fI5qOvziA3B2AQb4h0DPVEpRKalZ0GQ7c3 +yfuM6pzOyy6Wznp0COGVCb2aEIUrgCCYdPLc4Mtc/cgbwI89Q7gbgs31I0WAe9At +Dy9cmFkcjKn+rQPTlPF3WHyoq+3fwlBiy09ejBtNVdTKNzwsz/5kO3j1i3QvovLw ++uvatGwQfjYkbfstgZaXCFHsiojn8kZmzj0pWiifDTZQdHd8WGhn3oPRy1e6TcUK +3YszlkAvJC6aAi1MTRLXCfRjTgKhMMNKimKjtzgq0O+U8k8tp7hgh2/bj9Ro97y/ +0YRXyAl4zPQF0EQo98sE8TlzyO9fO2J8rRDFwji7tDw88m0qv/UD/tirFuMES4u+ +krqbvo6I9oJxqxGSVZZmxKdcvgrZMU0icT2VGsvNBiTYMRoX/fspAgDGzlOxUn8b +TZFhntnRXF+lGnH45HEn77rtvEc6PNm407gJthbPk8rYChJgABM3gNcA+7DuHFtU +2/HI2knreNAYDJ6XY0dexh2szL6gZOymt7H6twvAcHvHBS3mc0juQ+LxTYFvG46a +uYdTNAI005gFQWLy5hLAUbmCgTBds4ISOIo3Itz8bJcbBiInkwLfGbk6U6NJzNWO +wlfEsJjiDzJDgU5H6bJpH6AQ5JeO1CMWWGO1Yx++4eYL8cbBLYUhLvCFiyyNVQLH +BRCzM2zCASaJb3wPIIKLf+VxNVyv+n5F4KX1tfzjYIsCaNamucVZ38YiK59C5G1M +GfNHsBGTnM74Y5aNaZp7oK1YATVKQnORWZejInbAm6zezofOnn0bDe7a6JGr+rEk +snkgMBPdS6nNmscXx7QDAoQNf/Vjf2DPcPf7YblKtgvyzr9cpUrNf/rfJvzGSqpk +6FTia11h5rMs9bkPOqzhG2DQJX3fEmHHl1ZK0aGwPdXtcxVDuWsbYPEevCjTe24e +NKdUucW9QFBPIrn9ABTLAWDjSiR2FEFP/lq/dU/dZEJoAQzg98F5IdJzbmvjQSL8 +9itRo7h+e2Dv7NI9SbkWo+E= +=OL4B -----END PGP MESSAGE-----