Compare commits

..
Author SHA1 Message Date
iequidoo 974e32dd76 fix: Don't decrease member add/remove timestamps if they aren't far away in the future
We shouldn't decrease `add_timestamp` and `remove_timestamp` in the `chats_contacts` table normally,
even if remote changes arrive reordered. This particularly makes sense for ad-hoc groups (see
`chat::update_chat_contacts_table()` in `apply_group_changes()`) and in case if we join an encrypted
group which we were a member of before (see `chat::add_to_chat_contacts_table()` call).

Still, limit already stored timestamps in case local clock was in the future and is set back
now. But our clock may be slow, so limit stored timestamps with a remote timestamp if it's
bigger.

NB: `receive_imf::update_chats_contacts_timestamps()` already only increases timestamps, but it's
used only for handling of the "Chat-Group-Member-Timestamps" header, i.e. for encrypted groups.
2026-06-07 21:45:47 -03:00
278 changed files with 17161 additions and 19350 deletions
+1 -1
View File
@@ -15,6 +15,6 @@ updates:
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "monthly" interval: "weekly"
cooldown: cooldown:
default-days: 7 default-days: 7
+38 -25
View File
@@ -20,7 +20,7 @@ permissions: {}
env: env:
RUSTFLAGS: -Dwarnings RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.99.0 RUST_VERSION: 1.95.0
# Minimum Supported Rust Version # Minimum Supported Rust Version
MSRV: 1.89.0 MSRV: 1.89.0
@@ -30,11 +30,8 @@ jobs:
name: Lint Rust name: Lint Rust
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
env:
# Tests always unwind: match it so dependencies are only checked once.
CARGO_PROFILE_DEV_PANIC: unwind
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
@@ -43,7 +40,7 @@ jobs:
- run: rustup override set $RUST_VERSION - run: rustup override set $RUST_VERSION
shell: bash shell: bash
- name: Cache rust cargo artifacts - name: Cache rust cargo artifacts
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with: with:
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false cache-bin: false
@@ -51,6 +48,8 @@ jobs:
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
- name: Run clippy - name: Run clippy
run: scripts/clippy.sh run: scripts/clippy.sh
- name: Check with all features
run: cargo check --workspace --all-targets --all-features
- name: Check with only default features - name: Check with only default features
run: cargo check --all-targets run: cargo check --all-targets
@@ -59,16 +58,30 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 - uses: EmbarkStudios/cargo-deny-action@a531616d8ce3b9177443e48a1159bc945a099823
with: with:
arguments: --workspace --all-features --locked arguments: --workspace --all-features --locked
command: check command: check
command-arguments: "-Dwarnings" command-arguments: "-Dwarnings"
provider_database:
name: Check provider database
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
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
docs: docs:
name: Rust doc comments name: Rust doc comments
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -76,12 +89,12 @@ jobs:
env: env:
RUSTDOCFLAGS: -Dwarnings RUSTDOCFLAGS: -Dwarnings
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- name: Cache rust cargo artifacts - name: Cache rust cargo artifacts
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with: with:
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false cache-bin: false
@@ -115,7 +128,7 @@ jobs:
shell: bash shell: bash
if: matrix.rust == 'latest' if: matrix.rust == 'latest'
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
@@ -127,25 +140,25 @@ jobs:
shell: bash shell: bash
- name: Cache rust cargo artifacts - name: Cache rust cargo artifacts
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with: with:
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false cache-bin: false
- name: Install nextest - name: Install nextest
uses: taiki-e/install-action@9983c65e42da123ff25d1f78505eb6de315aa172 uses: taiki-e/install-action@60ae4ce63c7aeb6e96d7f572c1ec7fafbb17ca80
with: with:
tool: nextest tool: nextest
- name: Tests - name: Tests
env: env:
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
run: cargo nextest run --workspace --exclude deltachat-jsonrpc-bindings --locked run: cargo nextest run --workspace --locked
- name: Doc-Tests - name: Doc-Tests
env: env:
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
run: cargo test --workspace --exclude deltachat-jsonrpc-bindings --locked --doc run: cargo test --workspace --locked --doc
- name: Test cargo vendor - name: Test cargo vendor
run: cargo vendor run: cargo vendor
@@ -158,13 +171,13 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- name: Cache rust cargo artifacts - name: Cache rust cargo artifacts
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with: with:
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false cache-bin: false
@@ -187,13 +200,13 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- name: Cache rust cargo artifacts - name: Cache rust cargo artifacts
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with: with:
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false cache-bin: false
@@ -213,7 +226,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
@@ -239,7 +252,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
@@ -289,7 +302,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
@@ -301,7 +314,7 @@ jobs:
path: target/debug path: target/debug
- name: Install python - name: Install python
uses: actions/setup-python@v7.0.0 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python }} python-version: ${{ matrix.python }}
@@ -343,13 +356,13 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- name: Install python - name: Install python
uses: actions/setup-python@v7.0.0 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python }} python-version: ${{ matrix.python }}
+25 -30
View File
@@ -30,11 +30,11 @@ jobs:
arch: [aarch64, armv7l, armv6l, i686, x86_64] arch: [aarch64, armv7l, armv6l, i686, x86_64]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- name: Build deltachat-rpc-server binaries - name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
@@ -54,11 +54,11 @@ jobs:
arch: [aarch64, armv7l, armv6l, i686, x86_64] arch: [aarch64, armv7l, armv6l, i686, x86_64]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- name: Build deltachat-rpc-server wheels - name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
@@ -78,11 +78,11 @@ jobs:
arch: [win32, win64] arch: [win32, win64]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- name: Build deltachat-rpc-server binaries - name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }} run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
@@ -102,11 +102,11 @@ jobs:
arch: [win32, win64] arch: [win32, win64]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- name: Build deltachat-rpc-server wheels - name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel
@@ -127,7 +127,7 @@ jobs:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
@@ -153,11 +153,11 @@ jobs:
arch: [arm64-v8a, armeabi-v7a] arch: [arm64-v8a, armeabi-v7a]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- name: Build deltachat-rpc-server binaries - name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
@@ -177,11 +177,11 @@ jobs:
arch: [arm64-v8a, armeabi-v7a] arch: [arm64-v8a, armeabi-v7a]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- name: Build deltachat-rpc-server wheels - name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel
@@ -204,11 +204,11 @@ jobs:
contents: write contents: write
runs-on: "ubuntu-latest" runs-on: "ubuntu-latest"
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- name: Download Linux aarch64 binary - name: Download Linux aarch64 binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v7
@@ -370,14 +370,6 @@ jobs:
- name: List artifacts - name: List artifacts
run: ls -l dist/ run: ls -l dist/
- name: Check that the wheel metadata reads back
run: |
echo 'You can check a local `nix build` on non-Mac machines against the following checksums.'
sha256sum dist/*.whl
mkdir tagcheck
cp dist/*.whl tagcheck/
nix run --inputs-from . nixpkgs#python3Packages.wheel -- tags tagcheck/*.whl
- name: Upload binaries to the GitHub release - name: Upload binaries to the GitHub release
if: github.event_name == 'release' if: github.event_name == 'release'
env: env:
@@ -390,7 +382,7 @@ jobs:
- name: Publish deltachat-rpc-server to PyPI - name: Publish deltachat-rpc-server to PyPI
if: github.event_name == 'release' if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
publish_npm_package: publish_npm_package:
name: Build & Publish npm prebuilds and deltachat-rpc-server name: Build & Publish npm prebuilds and deltachat-rpc-server
@@ -405,11 +397,11 @@ jobs:
# Needed to publish the binaries to the release. # Needed to publish the binaries to the release.
contents: write contents: write
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- uses: actions/setup-python@v7.0.0 - uses: actions/setup-python@v6
with: with:
python-version: "3.11" python-version: "3.11"
@@ -521,12 +513,15 @@ jobs:
deltachat-rpc-server/npm-package/*.tgz deltachat-rpc-server/npm-package/*.tgz
# Configure Node.js for publishing. # Configure Node.js for publishing.
# Check <https://docs.npmjs.com/trusted-publishers> for the version requirements. - uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 20
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
package-manager-cache: false # never use caching in release builds
# Ensure npm 11.5.1 or later is installed.
# It is needed for <https://docs.npmjs.com/trusted-publishers>
- name: Update npm
run: npm install -g npm@latest
- name: Publish npm packets for prebuilds and `@deltachat/stdio-rpc-server` - name: Publish npm packets for prebuilds and `@deltachat/stdio-rpc-server`
if: github.event_name == 'release' if: github.event_name == 'release'
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
name: Check that current version ends with -dev name: Check that current version ends with -dev
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
@@ -17,29 +17,31 @@ jobs:
id-token: write id-token: write
contents: read contents: read
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
# Configure Node.js for publishing. - uses: actions/setup-node@v6
# Check <https://docs.npmjs.com/trusted-publishers> for the version requirements.
- uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 20
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
package-manager-cache: false # never use caching in release builds
# Ensure npm 11.5.1 or later is installed.
# It is needed for <https://docs.npmjs.com/trusted-publishers>
- name: Update npm
run: npm install -g npm@latest
- name: Install dependencies without running scripts - name: Install dependencies without running scripts
working-directory: deltachat-jsonrpc-bindings/typescript working-directory: deltachat-jsonrpc/typescript
run: npm install --ignore-scripts run: npm install --ignore-scripts
- name: Package - name: Package
working-directory: deltachat-jsonrpc-bindings/typescript working-directory: deltachat-jsonrpc/typescript
run: | run: |
npm run build npm run build
npm pack . npm pack .
- name: Publish - name: Publish
working-directory: deltachat-jsonrpc-bindings/typescript working-directory: deltachat-jsonrpc/typescript
run: npm publish --provenance deltachat-jsonrpc-client-* --access public run: npm publish --provenance deltachat-jsonrpc-client-* --access public
+9 -9
View File
@@ -16,30 +16,30 @@ jobs:
build_and_test: build_and_test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- name: Use Node.js 24 - name: Use Node.js 18.x
uses: actions/setup-node@v7 uses: actions/setup-node@v6
with: with:
node-version: 24 node-version: 18.x
- name: Add Rust cache - name: Add Rust cache
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
with: with:
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false cache-bin: false
- name: npm install - name: npm install
working-directory: deltachat-jsonrpc-bindings/typescript working-directory: deltachat-jsonrpc/typescript
run: npm install run: npm install
- name: Build TypeScript, run Rust tests, generate bindings - name: Build TypeScript, run Rust tests, generate bindings
working-directory: deltachat-jsonrpc-bindings/typescript working-directory: deltachat-jsonrpc/typescript
run: npm run build run: npm run build
- name: Run integration tests - name: Run integration tests
working-directory: deltachat-jsonrpc-bindings/typescript working-directory: deltachat-jsonrpc/typescript
run: npm run test run: npm run test
env: env:
CHATMAIL_DOMAIN: ${{ vars.CHATMAIL_DOMAIN }} CHATMAIL_DOMAIN: ${{ vars.CHATMAIL_DOMAIN }}
- name: Run linter - name: Run linter
working-directory: deltachat-jsonrpc-bindings/typescript working-directory: deltachat-jsonrpc/typescript
run: npm run prettier:check run: npm run prettier:check
+7 -9
View File
@@ -5,13 +5,11 @@ on:
paths: paths:
- flake.nix - flake.nix
- flake.lock - flake.lock
- nix/**
- .github/workflows/nix.yml - .github/workflows/nix.yml
push: push:
paths: paths:
- flake.nix - flake.nix
- flake.lock - flake.lock
- nix/**
- .github/workflows/nix.yml - .github/workflows/nix.yml
branches: branches:
- main - main
@@ -23,12 +21,12 @@ jobs:
name: check flake formatting name: check flake formatting
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- run: nix fmt flake.nix nix/ -- --check - run: nix fmt flake.nix -- --check
build: build:
name: nix build name: nix build
@@ -81,11 +79,11 @@ jobs:
#- deltachat-rpc-server-x86_64-android #- deltachat-rpc-server-x86_64-android
#- deltachat-rpc-server-x86-android #- deltachat-rpc-server-x86-android
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- run: nix build .#${{ matrix.installable }} - run: nix build .#${{ matrix.installable }}
build-macos: build-macos:
@@ -102,9 +100,9 @@ jobs:
# because of <https://github.com/NixOS/nixpkgs/issues/413910>. # because of <https://github.com/NixOS/nixpkgs/issues/413910>.
# - deltachat-rpc-server-aarch64-darwin # - deltachat-rpc-server-aarch64-darwin
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- run: nix build .#${{ matrix.installable }} - run: nix build .#${{ matrix.installable }}
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
@@ -47,4 +47,4 @@ jobs:
name: python-package-distributions name: python-package-distributions
path: dist/ path: dist/
- name: Publish deltachat-rpc-client to PyPI - name: Publish deltachat-rpc-client to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
+2 -2
View File
@@ -14,11 +14,11 @@ jobs:
name: Build REPL example name: Build REPL example
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- name: Build - name: Build
run: nix build .#deltachat-repl-win64 run: nix build .#deltachat-repl-win64
- name: Upload binary - name: Upload binary
+15 -15
View File
@@ -15,7 +15,7 @@ jobs:
url: https://rs.delta.chat/ url: https://rs.delta.chat/
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
@@ -27,7 +27,7 @@ jobs:
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.RS_DOCS_SSH_KEY }}" > "$HOME/.ssh/key" echo "${{ secrets.RS_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/target/doc/ "${{ secrets.RS_DOCS_SSH_USER }}@rs.delta.chat:" rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/target/doc "${{ secrets.RS_DOCS_SSH_USER }}@rs.delta.chat:/var/www/html/rs.delta.chat/"
build-python: build-python:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -36,12 +36,12 @@ jobs:
url: https://py.delta.chat/ url: https://py.delta.chat/
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number. fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- name: Build Python documentation - name: Build Python documentation
run: nix build .#python-docs run: nix build .#python-docs
- name: Upload to py.delta.chat - name: Upload to py.delta.chat
@@ -49,7 +49,7 @@ jobs:
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.PY_DOCS_SSH_KEY }}" > "$HOME/.ssh/key" echo "${{ secrets.PY_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/result/html/ "${{ secrets.PY_DOCS_SSH_USER }}@py.delta.chat:" rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/result/html/ "${{ secrets.PY_DOCS_SSH_USER }}@py.delta.chat:/var/www/html/py.delta.chat"
build-c: build-c:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -58,12 +58,12 @@ jobs:
url: https://c.delta.chat/ url: https://c.delta.chat/
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number. fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
- name: Build C documentation - name: Build C documentation
run: nix build .#docs run: nix build .#docs
- name: Upload to c.delta.chat - name: Upload to c.delta.chat
@@ -71,7 +71,7 @@ jobs:
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.C_DOCS_SSH_KEY }}" > "$HOME/.ssh/key" echo "${{ secrets.C_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/result/html/ "${{ secrets.C_DOCS_SSH_USER }}@c.delta.chat:" rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/result/html/ "${{ secrets.C_DOCS_SSH_USER }}@c.delta.chat:/var/www/html/c.delta.chat"
build-ts: build-ts:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -81,18 +81,18 @@ jobs:
defaults: defaults:
run: run:
working-directory: ./deltachat-jsonrpc-bindings/typescript working-directory: ./deltachat-jsonrpc/typescript
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number. fetch-depth: 0 # Fetch history to calculate VCS version number.
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v7 uses: actions/setup-node@v6
with: with:
node-version: 24 node-version: '18'
- name: npm install - name: npm install
run: npm install run: npm install
- name: npm run build - name: npm run build
@@ -104,7 +104,7 @@ jobs:
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.JS_JSONRPC_DOCS_SSH_KEY }}" > "$HOME/.ssh/key" echo "${{ secrets.JS_JSONRPC_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/deltachat-jsonrpc-bindings/typescript/docs/ "${{ secrets.JS_JSONRPC_DOCS_SSH_USER }}@js.jsonrpc.delta.chat:" rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/deltachat-jsonrpc/typescript/docs/ "${{ secrets.JS_JSONRPC_DOCS_SSH_USER }}@js.jsonrpc.delta.chat:/var/www/html/js.jsonrpc.delta.chat/"
build-cffi: build-cffi:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -113,7 +113,7 @@ jobs:
url: https://cffi.delta.chat/ url: https://cffi.delta.chat/
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false persist-credentials: false
@@ -125,4 +125,4 @@ jobs:
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.CFFI_DOCS_SSH_KEY }}" > "$HOME/.ssh/key" echo "${{ secrets.CFFI_DOCS_SSH_KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/target/doc/ "${{ secrets.CFFI_DOCS_SSH_USER }}@delta.chat:" rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/target/doc/ "${{ secrets.CFFI_DOCS_SSH_USER }}@delta.chat:/var/www/html/cffi.delta.chat/"
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
actions: read actions: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v6
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
-686
View File
@@ -1,679 +1,5 @@
# Changelog # Changelog
## [2.63.0] - 2026-10-09
### API-Changes
- add `PinnedMessagesChanged` event.
- [**breaking**]: remove unused `dc_chatlist_get_summary2()` ([#8812](https://github.com/chatmail/core/pull/8812))
- new `dc_msg_get_summary_text()` ([#8813](https://github.com/chatmail/core/pull/8813)).
### Features / Changes
- connect to the most recently successfully used SMTP transport first.
- re-add "second device added" message if deleted soon after transfer ([#8758](https://github.com/chatmail/core/pull/8758)).
- do not request MDNs for unencrypted messages.
- do not send unencrypted MDNs.
- add context to HTTP(S) proxy connection errors.
- deltachat-repl: remove "reset" command.
### Fixes
- Correctly percent-encode addresses in securejoin invite codes ([#8747](https://github.com/chatmail/core/pull/8747)).
- regenerate deltachat.pc if env changed ([#8752](https://github.com/chatmail/core/pull/8752)).
- delete handled securejoin messages on all relays.
- add headers to unencrypted MDNs.
- In SMTP loop, reset `transport_id` and `from` when disconnecting ([#8797](https://github.com/chatmail/core/pull/8797)).
- deltachat-rpc-client: shutdown all threads after RPC server crash.
- (linux-only) make sure large attachments return memory to kernel.
- do not reencode animated WebPs into JPEG.
### CI
- update Rust to 1.99.0.
- speedup lint job and the Rust test builds
### Refactor
- [**breaking**] stop saving legacy config values when configuring a transport.
- core does not set deprecated config values `addr`, `mail_pw` etc. Use `list_transports()` API instead.
- get rid of dead sticker-related code in check_or_recode_to_size().
- factor add_headers_to_part() out of mimefactory::add_headers_to_protected_part().
- replace MimeFactory::from_mdn() with a standalone mimefactory::mdn().
- take securejoin addresses from transport list.
- deltachat-repl: move all commands to cmdline.rs.
- do not guess image format from file extension on I/O error.
- reduce macro-generated lines by >70%, and drop tracing.
- stop excluding `COPYUID` responses from the logs.
### Tests
- move pinned messages tests into pinned_messages_tests module.
- Add basic tests for rendering unencrypted messages ([#8736](https://github.com/chatmail/core/pull/8736)).
- do not ignore errors in add_pseudo_transport().
- test sending and receiving long filename with emojis.
### Miscellaneous Tasks
- update rPGP from 0.20.0 to 0.21.0.
- reduce noise created by key-contact migration.
- enable `clippy::unnecessary_wraps`, fix `clippy::redundant_clone` and `clippy::string_lit_as_bytes` suggestions.
- add chatmail.au.
- add chatmail.cc ([#8756](https://github.com/chatmail/core/pull/8756)).
- remove some `unwrap()` calls.
- remove unused "sdp" dependency.
- cargo: bump smallvec from 1.15.2 to 1.16.1.
- cargo: bump syn from 3.0.4 to 3.0.6.
- deps: bump zizmorcore/zizmor-action from 0.6.2 to 0.6.4.
- cargo: bump mailparse from 0.16.1 to 0.17.0.
- cargo: bump uuid from 1.25.0 to 1.26.1.
- deps: bump cachix/install-nix-action from 31.11.0 to 31.11.1.
- cargo: bump hyper from 1.10.1 to 1.11.1.
- cargo: bump tokio-rustls from 0.26.4 to 0.26.5.
- cargo: bump thiserror from 2.0.20 to 2.0.21.
- deps: bump taiki-e/install-action from 2.86.7 to 2.87.20.
- cargo: bump hyper-util from 0.1.20 to 0.1.21.
- cargo: bump dirs from 6.0.0 to 7.0.0.
- move deltachat-time to dev dependencies.
- update astral-tokio-tar from 0.6.4 to 0.7.0.
- update async-imap to 0.12.0.
- update mail-builder to 1.0.
## [2.62.0] - 2026-09-22
### API-Changes
- Remove unused ImapMessageMoved event.
- [**breaking**] get rid of `Qr::FprWithoutAddr` and `Qr::FprMismatch` variants.
- removed `DC_QR_FPR_WITHOUT_ADDR` and `DC_QR_FPR_MISMATCH` constants from CFFI
- removed FprWithoutAddr and FprMismatch variants from JSON-RPC QrObject returned by `check_qr`
### Features / Changes
- Don't show "member added" messages in channels ([#8730](https://github.com/chatmail/core/pull/8730)).
### Fixes
- Don't notify about a reaction sent by a blocked contact ([#8729](https://github.com/chatmail/core/pull/8729)).
- keep a message read status if a failure arrives later.
- prefer login errors over connection errors in the log message ([#8727](https://github.com/chatmail/core/pull/8727)).
- blocked or email contacts contacts are never recently seen or old ([#8733](https://github.com/chatmail/core/pull/8733)).
### Refactor
- Remove code that moves messages between IMAP folders.
## [2.61.0] - 2026-09-21
### API-Changes
- add `init_transports()` for multi-relay onboarding.
- add JSON-RPC API `is_sending_finished()`.
- [**breaking**] remove verification methods from the FFI and JSON-RPC APIs.
- `dc_contact_is_verified()` and `dc_contact_get_verifier_id()` are removed.
- the JSON-RPC Contact object loses the `isVerified` and `verifierId` fields. A bot reading `snapshot.is_verified` now gets an `AttributeError` at runtime.
- the Python bindings lose `Contact.is_verified()` and `Contact.get_verifier()`.
- `DC_STR_CONTACT_VERIFIED` (35) is removed, so UIs should stop registering a translation for it. A stock id core does not know is logged and otherwise ignored, so an un-updated client keeps working.
- [**breaking**] remove default value for "addr" config.
- [**breaking**] remove `addr` field from Account objects in JSON-RPC APIs.
- `list_transports()` should be used instead.
- [**breaking**] remove `is_chatmail` and the XCHATMAIL capability.
- `is_chatmail` is no longer a known config key.
- [**breaking**] remove `Contact.get_name_n_addr()` and related APIs.
- `dc_contact_get_name_n_addr()` CFFI is removed
- JSON-RPC contact objects don't have nameAndAddr field anymore
- [**breaking**] replace `was_seen_recently` by `freshness` in contact object.
- use contact's `freshness` instead of `seen_recently`
### Fixes
- always emit `AccountsBackgroundFetchDone`.
- make `background_fetch` not wait on or trigger SMTP connections.
- do not send a sync message when changing `configured_addr`.
- emit `SmtpMessageSent` event after deleting the message from SMTP queue.
- don't use extra STUN nine server for fallback.
- use `max_smtp_rcpt_to` chunking for the actual transport we are sending from.
- use correct `From` address when sending MDNs.
- use correct address for Bcc-self in unencrypted mails.
- don't emit configure progress events during background relay additions.
### Features / Changes
- better quality of image recoding ([#8682](https://github.com/chatmail/core/pull/8682)).
- perform background fetch from all transports.
- queue messages for SMTP before encryption.
- [**breaking**] stop tracking contact verification.
- the statistics JSON sent to the self-reporting-bot on Android changes: Contacts have `encrypted` instead of `verified` and lose `transitive_chain` properties and message stats have `encrypted` instead of `verified` and `unverified_encrypted`, and securejoin invites lose `already_verified`. The collecting bot stores incoming reports verbatim but analysis will have to make sense of older and newer reports.
- remove last usage of XDELTAPUSH capability.
- base server-side message deletion on `force_encryption`.
- do not restart I/O when setting `configured_addr`.
- do not use ConfiguredAddr when connecting to SMTP.
- mark autorelays for relay operators ([#8701](https://github.com/chatmail/core/pull/8701)).
- try fasted relays to attempt first configure on.
- do not mark message as failed for which we got a read receipt before.
- a single NDN does not mark a group message as failed.
### Documentation
- update `sys.msgsize_max_recommended` documentation.
- add hint about how to reset an invitation ([#8160](https://github.com/chatmail/core/pull/8160)).
- mention `get_app_version()` API in the changelog for 2.59.0.
- remove `protect_autocrypt` setting.
### Miscellaneous Tasks
- cleanup "primary" wording in comments.
- update rustls to 0.23.45.
- fix nightly "cargo" warnings.
- fix some types in `deltachat_rpc_client`.
### Refactor
- add `Encryption.is_encrypted()`.
- separate QueuedEncryption.
- sql: disable double-quoted string literals.
- add smtp::queue module.
- substitute configure progress macro with simple function call.
### Tests
- cleanup `get_smtp_rows_for_msg()`.
- cross-core securejoin invites for every chat type.
- explicitly empty url in appversion updates ([#8702](https://github.com/chatmail/core/pull/8702)).
- do not talk about "inconsistent key state" in `test_securejoin_after_contact_resetup`.
- rename `test_aeap_transition_{0,1}` to `test_aeap_transition_{single,group}`.
- do not fetch all messages when `direct_imap` is created.
- `direct_imap`: always pass `mark_seen=False` to fetch().
- add pseudo transport explicitly rather than by setting ConfiguredAddr.
## [2.60.0] - 2026-09-11
### API-Changes
- [**breaking**] remove a relay immediately instead of unpublishing it.
- `set_transport_unpublished()` is removed: UIs call `delete_transport()` when the user removes a relay.
- `list_transports_ex()` and the `TransportListEntry` type are removed: use `list_transports()`.
- `delete_transport()` no longer refuses to remove the primary transport: it refuses only to remove the last one and re-elects the sending transport as needed.
- `TransportsModified` is now also emitted on the device modifying the transports, not only on devices applying the synced change.
- [**breaking**] do not load webxdc icon if it has too large dimensions.
- `get_webxdc_blob()` may fail to load `icon.png` or `icon.jpg` if image dimensions are too large.
Fixing the issue discovered by https://github.com/Sergei768
- Generate JSON-RPC headers at build time ([#8350](https://github.com/chatmail/core/pull/8350)).
- Generate Qt JSON-RPC bindings ([#8330](https://github.com/chatmail/core/pull/8330)).
### Features / Changes
- Introduce keyupdate messages informing contacts about relay changes.
- Remove `Final-Recipient` from MDNs (and keyupdates).
- Carry all published relay addresses in securejoin links ([#8591](https://github.com/chatmail/core/pull/8591)).
- Use display name for contacts in encryption info ([#8609](https://github.com/chatmail/core/pull/8609)).
- Do not create device messages for IMAP authentication errors.
- Delete avatars referred to by parameters of special contacts.
- Import `Autocrypt-Gossip` keys without checking the addresses.
- Ignore `Chat-Disposition-Notification-To` value.
- Increase `sys.msgsize_max_recommended` to match chatmail relay message size limit.
### Fixes
- Do not try to load profile image from param for self.
- Send legacy securejoin key requests as `multipart/mixed` so they are not rejected by chatmail relays.
- rpc: avoid hang when requests race a dying rpc-server.
- Take `timestamp_rcvd` into account in `estimate_deletion_cnt`.
- Reliably complete configuration with progress=1000 or progress=0.
- Make `create_send_msg_jobs` actually return row IDs.
- Don't notify of missed call from blocked user.
- Trash MDNs that reference no message early.
- Return no relay address for key-contacts without an address.
- Do not emit events in `set_profile_image()` if contact avatar is unchanged.
- Remove `Original-Recipient` field from MDNs.
- ffi: support custom allocators in event string getters.
- Start checking column documentation in CI and add comment for `transports.add_timestamp`.
- Sanitize `version_string` we got from the wire ([#8582](https://github.com/chatmail/core/pull/8582))
- RUSTSEC-2026-0258 ([#8603](https://github.com/chatmail/core/pull/8603)).
### Build system
- Use `--locked` in `scripts/clippy.sh`.
- Produce correct wheel metadata.
### Documentation
- Always suggest using `--locked` with "cargo install".
- JSON-RPC: clarify when `reactions` is `None`.
- Fix async-imap and async-smtp URLs in README.md ([#8637](https://github.com/chatmail/core/pull/8637)).
- Update the timeout value in `DC_EVENT_CALL_ENDED` description.
### Refactor
- Don't store email address in location KML. ([#8615](https://github.com/chatmail/core/pull/8615)).
- Turn `DC_CHAT_ID_*` into `ChatId::*` associated constants.
- Turn `DC_MSG_ID_*` into `MsgId::*` associated constants.
- Don't include email addresses in export filenames ([#8626](https://github.com/chatmail/core/pull/8626)).
- Make `create_send_msg_jobs()` private.
- Rename `automatic_relay_management` to autorelay.
- Extract shared pieces for non-chat messages.
- Remove unused functions from the tools module.
- Remove the code to set own avatar in `set_profile_image()`.
- Move pgp tests to submodule.
- Split `flake.nix` into multiple files.
- Use `&[..]` instead of `&Vec<..>`.
### Tests
- [**breaking**] rename rpc fixtures to disambiguate from ffi fixtures.
- Test `dc_send_msg_sync()`.
- Print which error/warning was expected if it does not arrive.
### CI
- Update Rust to 1.98.1.
### Miscellaneous Tasks
- Add script to show the sizes of futures (async Rust) ([#8536](https://github.com/chatmail/core/pull/8536)).
- deps: bump zizmorcore/zizmor-action from 0.6.1 to 0.6.2.
- deps: bump swatinem/rust-cache from 2.9.1 to 2.9.2.
- deps: bump pypa/gh-action-pypi-publish from 1.14.1 to 1.14.2.
- deps: bump taiki-e/install-action from 2.85.1 to 2.86.7.
- cargo: bump futures from 0.3.33 to 0.3.34.
- cargo: bump thiserror from 2.0.19 to 2.0.20.
- cargo: bump syn from 3.0.3 to 3.0.4.
- cargo: bump log from 0.4.33 to 0.4.34.
- cargo: bump mail-builder from 0.4.4 to 0.5.0.
- cargo: bump blake3 from 1.8.5 to 1.8.7.
- cargo: bump http-body-util from 0.1.3 to 0.1.5.
- cargo: bump uuid from 1.20.0 to 1.25.0.
- cargo: bump data-encoding from 2.11.0 to 2.11.1.
- bump chacha20 0.10.1 to 0.10.2.
## [2.59.0] - 2026-08-14
### API-Changes
- [**breaking**] Remove deprecated `dc_chat_is_protected()`.
- Deprecate `dc_chat_get_info_json()` ([#8580](https://github.com/chatmail/core/pull/8580))
- New `get_app_version()` JSON-RPC API to get information about available updates.
### Features / Changes
- Add stock strings for being added/removed from group ([#8562](https://github.com/chatmail/core/pull/8562)).
- Client version information ([#8557](https://github.com/chatmail/core/pull/8557)).
- Remove hidden headers.
- Stop creating info messages for old broadcast lists.
### Fixes
- Filtered reactions are info, not error in device chat.
- Send MDNs to self even if MDNs are disabled.
- Send HTTP requests in origin not absolute form.
### Documentation
- json-rpc: improve `reactions_by_contact` doc.
- Do not refer to `is_chat_protected()`.
- Do not talk about verified chats in securejoin QR-scanning functions.
- Add SQL schema documentation.
### Miscellaneous Tasks
- Fix nightly clippy warnings.
- cargo: bump astral-tokio-tar from 0.6.3 to 0.6.4.
- cargo: bump bytes from 1.12.0 to 1.12.1.
- FFI: don't swallow but log errors in three places.
### Refactor
- Remove `MessengerMessage`.
- Stop setting chats.protected column explicitly.
- Merge `msg_group_left_local` into `msg_del_member_local` ([#8575](https://github.com/chatmail/core/pull/8575)).
- Rename `_ex()` -> `_ext()`.
- mimefactory: add Encryption enum.
### Tests
- Fix flakyness of iroh tests by sending "forever" so that late swarm-joins still make the test work.
- Move iroh tests into separate module.
- Provide complete test isolation by not re-using account addresses.
- Avoid another source of random failures with `direct_imap` failing to connect on first try.
- Remove all cache-related logic in the FFI pytest plugin.
- Add a CI-failing check that documented sql schema matches real one.
- Abort early if DNS to chatmail domain does not work and nicer pytest startup header.
- Load test data through the `data` fixture.
- Allow to run the test suite against underscore-domain relays.
## [2.58.0] - 2026-08-10
### API-Changes
- [**breaking**] remove getPushState() and core's internal tracking of it
- [**breaking**] remove `dc_chatlist_get_context()`, because it was easy to misuse and likely led to crashes ([#8503](https://github.com/chatmail/core/pull/8503))
- instead, store reference-counted Context in `dc_msg_t`, `dc_contact_t` and `dc_chatlist_t`
- add "pinned messages" API.
### Build system
- update all crates to Rust 2024 edition.
### CI
- update github actions monthly instead of weekly.
### Documentation
- clarify `ChatId::do_set_draft()` docs.
- add missing slash to ConnectionSecurity::Starttls doc comment.
### Features / Changes
- send Autocrypt pgp key in MDNs occassionally and when relaylist changes.
- reduce unncessary gossipping of keys in group chats.
- stop requiring XDELTAPUSH capability for push notifications.
- prepare basic multi-relay onboarding ([#8444](https://github.com/chatmail/core/pull/8444))
- collect ICE servers from all relays.
- send messages to 5 relays instead of the newest 3 ones.
- allow to send reactions in broadcast channels ([#8450](https://github.com/chatmail/core/pull/8450)).
- allow only default reactions in channels broadcast ([#8545](https://github.com/chatmail/core/pull/8545)).
- resend pinned state in broadcast channels ([#8549](https://github.com/chatmail/core/pull/8549)).
### Fixes
- **The primary transport is not synchronized between devices anymore.**
- Don't warn about correct EXIF orientation values. ([#8483](https://github.com/chatmail/core/pull/8483)).
- deltachat-rpc-client: don't depend on execnet for importing pytest plugin, remove deprecated "py" usage.
- send MDNs to all authentic relays of a contact, not just whatever `get_addr()` returns..
- mark `as_path()` function unsafe.
- python: create event emitter when EventThread is initialized.
- Don't download pre-message again if it is known already ([#8488](https://github.com/chatmail/core/pull/8488)).
- recognize self addresses in various places (instead of just the "primary").
- fix multi relay connectivity view ([#8550](https://github.com/chatmail/core/pull/8550)).
- ensure same-second primary transport change propagates correctly.
- invalidate `configured_addr` cache before sending transport sync message.
- prevent transport de-synchronization because of early fetch cancellation.
- improve connectivity HTML if quota info has an error.
### Miscellaneous Tasks
- bump version to 2.58.0-dev.
- deps: bump actions/setup-python from 6 to 6.3.0.
- deps: bump zizmorcore/zizmor-action from 0.5.7 to 0.6.0.
- cargo: bump futures from 0.3.32 to 0.3.33.
- cargo: bump tokio from 1.52.3 to 1.53.0.
- cargo: bump regex from 1.12.4 to 1.13.1.
- disable "large futures" lint again.
- cargo: bump tokio-util from 0.7.18 to 0.7.19.
- deps: bump zizmorcore/zizmor-action from 0.6.0 to 0.6.1.
- deps: bump taiki-e/install-action from 2.83.4 to 2.85.1.
- cargo: bump `serde_json` from 1.0.150 to 1.0.151.
- deps: bump pypa/gh-action-pypi-publish from 1.14.0 to 1.14.1.
- deps: bump actions/setup-python from 6.3.0 to 7.0.0.
- cargo: introduce syn 3 dependency.
- cargo: bump anyhow from 1.0.103 to 1.0.104.
- cargo: bump serde from 1.0.228 to 1.0.229.
- cargo: bump thiserror from 2.0.18 to 2.0.19.
- cargo: bump libc from 0.2.186 to 0.2.189.
### Performance
- Box::pin iroh::endpoint::Builder::bind in order to reduce memory usage.
### Refactor
- use the new regex! macro.
- Remove FolderMeaning and `target_folder` ([#8456](https://github.com/chatmail/core/pull/8456)).
- Unify naming of direct/single/1:1/normal chats ([#8442](https://github.com/chatmail/core/pull/8442)).
- un-nest `prepare_msg_blob`.
- do not clean `imap_send` table on transport change.
- mark enabled ephemeral timer duration as NonZero.
- reduce the scope of unsafe in `dc_context_unref()`.
- mimefactory: separate rendering of message payload and sendable message.
### Tests
- fix flaky `test_markseen_message_and_mdn` test.
- fix flaky `test_no_markseen_in_team_profile` ([#8500](https://github.com/chatmail/core/pull/8500)).
- Add `test_bcc_self`.
- Add test for unencrypted headers ([#8538](https://github.com/chatmail/core/pull/8538)).
- Assert log warnings and errors ([#8457](https://github.com/chatmail/core/pull/8457)).
## [2.57.0] - 2026-07-25
### API-Changes
- [**breaking**] remove heartbeat push notifications.
- [**breaking**] remove provider-db handling and provider lookup APIs.
- provider lookup APIs were removed from CFFI and JSON-RPC.
also removes offline provider database code and generated provider data,
provider-specific fields in configure/transport paths, and REPL providerinfo.
### Documentation
- remove oauth2 from standards.
### Features / Changes
- accept messages from key contacts with forged From address.
- enable TLS certificate compression.
- read SMTP recipient limit from relay IMAP metadata.
### Fixes
- fixup CI failures.
- never merge outer To headers if standard header protection is used.
- Re-add oauth2 to serialized structs ([#8464](https://github.com/chatmail/core/pull/8464)).
- migrate transports configured on 2.56 to also have a oauth:false flag.
### Miscellaneous Tasks
- bump version to 2.57.0-dev.
- deps: bump actions/setup-node from 6 to 7.
- deps: bump cachix/install-nix-action from 31.10.6 to 31.11.0.
- deps: bump EmbarkStudios/cargo-deny-action from 2.0.20 to 2.1.1.
- deps: bump taiki-e/install-action from 2.82.10 to 2.83.4.
- cargo: bump quinn-proto from 0.11.14 to 0.11.16.
## [2.56.0] - 2026-07-21
### API-Changes
- [**breaking**] remove all oauth support and drop DC_LP_AUTH flags.
- removed oauth2 module, dc_get_oauth2_url FFI function, DC_LP_AUTH flags and configured/serverflags, and the oauth2 parameter/field from SMTP/IMAP clients, JSON-RPC interfaces, and CLI tools.
also contains regenerated provider data after dropping oauth in the update script.
### Features / Changes
- do not set backup_time in exported databases.
### Fixes
- revert 207c2e6e4c1bec43204c3b8a46fcbbff67d54b3f because some users reported problems with it.
### Miscellaneous Tasks
- bump version to 2.56.0-dev.
## [2.55.0] - 2026-07-20
Minor release to fix CI because releasing 2.54.0 failed.
### CI
- Update Node version to 24.
## [2.54.0] - 2026-07-20
### API-Changes
- [**breaking**] Deprecate `is_chatmail`.
- UIs should not behave differently for chatmail relays than for classical email servers; most usages of `is_chatmail` can be replaced by `force_encryption`.
- [**breaking**] `delete_transport()` must not be used by UIs anymore. Instead, `set_transport_unpublished()` must be called when a user clicks on "Remove".
- [**breaking**] `list_transports()` doesn't return unpublished relays anymore.
- UIs should use `list_transports()` rather than `list_transports_ex()`, because unpublished transports count as removed from the user point of view, and should not be shown in the relay list anymore.
- deltachat-rpc-client: add `Account.set_transport_unpublished()`.
- Add `MsgReadCountChanged` event.
### Features / Changes
- Implement support for populating and maintaining a list of default relays ([#8341](https://github.com/chatmail/core/pull/8341)).
- Remove hidden relays automatically ([#8402](https://github.com/chatmail/core/pull/8402)).
- Automatically remove oldest unpublished relay in order to make space when the user wants to add more; don't allow more than 5 relays overall ([#8428](https://github.com/chatmail/core/pull/8428)).
- Add silent group changes messages as InNoticed, not InSeen.
- Remove `?emailaddress` argument from autoconfig URL that is not using a dedicated domain.
- Remove `imap::Session::sync_seen_flags()` ([#7742](https://github.com/chatmail/core/pull/7742)).
- Use CAPABILITY response code if IMAP LOGIN command returns it.
- Increase max idle timeout for iroh backup receiver to 60 seconds.
### Fixes
- Request MDNs for resent channel messages.
- Make pre-messages w/o text want MDNs ([#8004](https://github.com/chatmail/core/pull/8004)).
- Make truncated edited messages have HTML for receivers ([#8249](https://github.com/chatmail/core/pull/8249)).
- Un-escape message footer marks in full messages (`get_html`) ([#8427](https://github.com/chatmail/core/pull/8427)).
- Hide synced chat if we only know its visibility ([#8343](https://github.com/chatmail/core/pull/8343)).
- Tombstone MDN before sending it ([#8252](https://github.com/chatmail/core/pull/8252)).
- Recreate `imap_markseen` with `PRIMARY KEY` constraint.
- Rerun the full securejoin protocol if the address was outdated ([#8358](https://github.com/chatmail/core/pull/8358)).
- Return early from `receive_imf` to not tombstone Iroh-Node-Addr message if webxdc instance isn't found ([#8372](https://github.com/chatmail/core/pull/8372)).
- Replace `last_added_location_id` with `last_added_location_timestamp`.
- Do not put locations into pre-messages.
- RUSTSEC-2026-0204 ([#8403](https://github.com/chatmail/core/pull/8403)).
- Ensure public key signatures are not in the past compared to the public key.
- Do not bubble up errors in IMAP candidate loop.
- Do not log errors if full message is not available on any transport.
- Apply reactions that arrived before the message at later time ([#8415](https://github.com/chatmail/core/pull/8415)).
### Performance
- Add timestamp to `msgs_index7` and speed up `Chatlist::try_load()` ([#7848](https://github.com/chatmail/core/pull/7848)).
### CI
- Update Rust to 1.97.1.
- rrsync prepends the restricted upload path, we need to leave it out ([#8405](https://github.com/chatmail/core/pull/8405)).
### Documentation
- Update STYLE.md: macros should be used only when necessary ([#8410](https://github.com/chatmail/core/pull/8410)).
- `create_group_chat_unencrypted()` may lead to chat split on the first device.
### Refactor
- Deprecate unused `SkipAutocrypt` param.
- Remove commented out `RenderedEmail.envelope`.
- Remove the ability to send messages with non-standard header protection.
- Make `crate::pgp::symm_encrypt_message` non-async.
- Move `ensure_secret_key_exists` into key.rs.
- Improve comment ([#8366](https://github.com/chatmail/core/pull/8366)).
- Remove `set_modseq()` function.
- Remove unnecessary reference in format string.
- Label the loop iterating over the candidates.
- Remove `GROUP BY c.id` from chatlist queries.
### Tests
- securejoin: Check that "vc-{,request-}pubkey" messages don't contain displayname.
### Miscellaneous Tasks
- bump version to 2.54.0-dev.
- deps: bump taiki-e/install-action from 2.81.1 to 2.81.8.
- deps: bump taiki-e/install-action from 2.81.8 to 2.81.11.
- update rPGP from 0.19.0 to 0.20.0.
- update astral-tokio-tar from 0.6.2 to 0.6.3.
- deps: bump anyhow to 1.0.103.
- deps: bump actions/checkout from 6 to 7.
- cargo: bump syn from 2.0.117 to 2.0.118.
- cargo: bump quote from 1.0.45 to 1.0.46.
- cargo: bump bytes from 1.11.1 to 1.12.0.
- cargo: bump regex from 1.12.3 to 1.12.4.
- cargo: bump log from 0.4.31 to 0.4.33.
- cargo: bump hyper from 1.9.0 to 1.10.1.
- deps: bump zizmorcore/zizmor-action from 0.5.6 to 0.5.7.
- cargo: bump chrono from 0.4.44 to 0.4.45.
- update quick-xml to 0.41.0.
- cargo: bump brotli from 8.0.2 to 8.0.4.
- cargo: bump smallvec from 1.15.1 to 1.15.2.
- deps: bump taiki-e/install-action from 2.81.11 to 2.82.6.
- update yanked spin@0.9.8 and spin@0.10.0.
- deps: bump taiki-e/install-action from 2.82.6 to 2.82.10.
- update async-imap to 0.11.3.
## [2.53.0] - 2026-06-15
### Features / Changes
- Make quality of images sent in chats more consistent between images with different aspect ratio.
- `MsgId::get_html`: Make only one db query.
- Do not log the recipient list for sent messages.
### Fixes
- Do not trash pre-messages without text but with a webxdc update.
- Don't send or process webxdc status updates in pre-messages.
- Ignore SecureJoin messages from blocked contacts ([#8295](https://github.com/chatmail/core/pull/8295)).
- Do not abort IMAP connection if setting the push token fails.
### Documentation
- STYLE.md: Require to list columns explicitly in `INSERT` statements.
### Build system
- nix: switch to the "master" branch for naersk.
- flake.nix: Use hostPlatform.rust.rustcTarget instead of hardcoding it.
### Miscellaneous Tasks
- Bump version to 2.52.0-dev.
- deps: bump taiki-e/install-action from 2.79.10 to 2.81.1.
- deps: bump EmbarkStudios/cargo-deny-action from 2.0.19 to 2.0.20.
- Bump version to 2.53.0-dev.
### Refactor
- Move the definition of the `target_wh`-variable.
- Remove timesmearing.
### Tests
- Print multiline chat descriptions with debug formatter.
- `exec_securejoin_qr_multi_device()`: Make inviter devices receive each other messages.
- Fixup the tests after removing timesmearing.
- Remove timeout from `pop_sent_msg_ex()`.
## [2.52.0] - 2026-06-09
### Fixes
- Update the channel title after joining if the QR code included a wrong title ([#8260](https://github.com/chatmail/core/pull/8260)).
- Don't send removal message to contact that hasn't been a chat member ([#8298](https://github.com/chatmail/core/pull/8298)).
### Features / Changes
- Add cryptography-related statistics (`number_of_transports`, `key_version`, `key_algorithm`, `pubkey_size`, `number_of_keys`) ([#8293](https://github.com/chatmail/core/pull/8293), [#8297](https://github.com/chatmail/core/pull/8297)).
- Add IMAP folder to `Context::get_info()` ([#8285](https://github.com/chatmail/core/pull/8285)).
### Miscellaneous Tasks
- Update preloaded DNS cache.
- Use default aws-lc-rs cryptography provider for rustls.
- Add exception for unmaintained proc-macro-error2 to deny.toml.
- cargo: bump `pin-project` from 1.1.11 to 1.1.13.
- cargo: bump `tokio` from 1.52.1 to 1.52.3.
- cargo: bump `log` from 0.4.29 to 0.4.30.
- cargo: bump `serde_json` from 1.0.149 to 1.0.150.
- deps: bump EmbarkStudios/cargo-deny-action from 2.0.18 to 2.0.19.
- deps: bump taiki-e/install-action from 2.79.2 to 2.79.10.
### Build system
- nix: fix windows cross-compilation by adding pthreads includes.
### Refactor
- Remove support for building "source" packages for deltachat-rpc-server.
## [2.51.0] - 2026-05-29 ## [2.51.0] - 2026-05-29
### Features / Changes ### Features / Changes
@@ -8971,15 +8297,3 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
[2.49.0]: https://github.com/chatmail/core/compare/v2.48.0..v2.49.0 [2.49.0]: https://github.com/chatmail/core/compare/v2.48.0..v2.49.0
[2.50.0]: https://github.com/chatmail/core/compare/v2.49.0..v2.50.0 [2.50.0]: https://github.com/chatmail/core/compare/v2.49.0..v2.50.0
[2.51.0]: https://github.com/chatmail/core/compare/v2.50.0..v2.51.0 [2.51.0]: https://github.com/chatmail/core/compare/v2.50.0..v2.51.0
[2.52.0]: https://github.com/chatmail/core/compare/v2.51.0..v2.52.0
[2.53.0]: https://github.com/chatmail/core/compare/v2.52.0..v2.53.0
[2.54.0]: https://github.com/chatmail/core/compare/v2.53.0..v2.54.0
[2.55.0]: https://github.com/chatmail/core/compare/v2.54.0..v2.55.0
[2.56.0]: https://github.com/chatmail/core/compare/v2.55.0..v2.56.0
[2.57.0]: https://github.com/chatmail/core/compare/v2.56.0..v2.57.0
[2.58.0]: https://github.com/chatmail/core/compare/v2.57.0..v2.58.0
[2.59.0]: https://github.com/chatmail/core/compare/v2.58.0..v2.59.0
[2.60.0]: https://github.com/chatmail/core/compare/v2.59.0..v2.60.0
[2.61.0]: https://github.com/chatmail/core/compare/v2.60.0..v2.61.0
[2.62.0]: https://github.com/chatmail/core/compare/v2.61.0..v2.62.0
[2.63.0]: https://github.com/chatmail/core/compare/v2.62.0..v2.63.0
+28 -42
View File
@@ -2,59 +2,45 @@ cmake_minimum_required(VERSION 3.16)
project(deltachat LANGUAGES C) project(deltachat LANGUAGES C)
include(GNUInstallDirs) include(GNUInstallDirs)
option(WITH_JSONRPC_BINDINGS "Generate jsonrpc bindings" OFF)
find_program(CARGO cargo) find_program(CARGO cargo)
if(APPLE) if(APPLE)
set(DYNAMIC_EXT "dylib") set(DYNAMIC_EXT "dylib")
elseif(UNIX) elseif(UNIX)
set(DYNAMIC_EXT "so") set(DYNAMIC_EXT "so")
else() else()
set(DYNAMIC_EXT "dll") set(DYNAMIC_EXT "dll")
endif() endif()
if(DEFINED ENV{CARGO_BUILD_TARGET}) if(DEFINED ENV{CARGO_BUILD_TARGET})
set(CARGO_OUT_DIR "${CMAKE_BINARY_DIR}/target/$ENV{CARGO_BUILD_TARGET}/release") set(ARCH_DIR "$ENV{CARGO_BUILD_TARGET}")
else() else()
set(CARGO_OUT_DIR "${CMAKE_BINARY_DIR}/target/release") set(ARCH_DIR "./")
endif() endif()
if(WITH_JSONRPC_BINDINGS) add_custom_command(
set(JSONRPC_ARGS --package deltachat-jsonrpc-bindings) OUTPUT
endif() "${CMAKE_BINARY_DIR}/target/release/libdeltachat.a"
"${CMAKE_BINARY_DIR}/target/release/libdeltachat.${DYNAMIC_EXT}"
add_custom_target( "${CMAKE_BINARY_DIR}/target/release/pkgconfig/deltachat.pc"
lib_deltachat COMMAND
ALL PREFIX=${CMAKE_INSTALL_PREFIX}
COMMAND LIBDIR=${CMAKE_INSTALL_FULL_LIBDIR}
${CMAKE_COMMAND} -E env INCLUDEDIR=${CMAKE_INSTALL_FULL_INCLUDEDIR}
CARGO_TARGET_DIR="${CMAKE_BINARY_DIR}/target" ${CARGO} build --target-dir=${CMAKE_BINARY_DIR}/target --release
PREFIX="${CMAKE_INSTALL_PREFIX}" WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/deltachat-ffi
LIBDIR="${CMAKE_INSTALL_FULL_LIBDIR}"
INCLUDEDIR="${CMAKE_INSTALL_FULL_INCLUDEDIR}"
${CARGO} build --release --package deltachat_ffi ${JSONRPC_ARGS}
WORKING_DIRECTORY
"${CMAKE_CURRENT_SOURCE_DIR}"
) )
install(FILES "deltachat-ffi/deltachat.h" DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") add_custom_target(
install(FILES "${CARGO_OUT_DIR}/libdeltachat.a" DESTINATION "${CMAKE_INSTALL_LIBDIR}") lib_deltachat
install(FILES "${CARGO_OUT_DIR}/libdeltachat.${DYNAMIC_EXT}" DESTINATION "${CMAKE_INSTALL_LIBDIR}") ALL
install(FILES "${CARGO_OUT_DIR}/pkgconfig/deltachat.pc" DESTINATION "${CMAKE_INSTALL_LIBDIR}/pkgconfig") DEPENDS
"${CMAKE_BINARY_DIR}/target/release/libdeltachat.a"
"${CMAKE_BINARY_DIR}/target/release/libdeltachat.${DYNAMIC_EXT}"
"${CMAKE_BINARY_DIR}/target/release/pkgconfig/deltachat.pc"
)
if(WITH_JSONRPC_BINDINGS) install(FILES "deltachat-ffi/deltachat.h" DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install( install(FILES "${CMAKE_BINARY_DIR}/target/${ARCH_DIR}/release/libdeltachat.a" DESTINATION ${CMAKE_INSTALL_LIBDIR})
FILES install(FILES "${CMAKE_BINARY_DIR}/target/${ARCH_DIR}/release/libdeltachat.${DYNAMIC_EXT}" DESTINATION ${CMAKE_INSTALL_LIBDIR})
"${CMAKE_CURRENT_SOURCE_DIR}/deltachat-jsonrpc-bindings/qt/generated/types.hpp" install(FILES "${CMAKE_BINARY_DIR}/target/${ARCH_DIR}/release/pkgconfig/deltachat.pc" DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig)
"${CMAKE_CURRENT_SOURCE_DIR}/deltachat-jsonrpc-bindings/qt/generated/client.hpp"
DESTINATION
"${CMAKE_INSTALL_INCLUDEDIR}/deltachat-jsonrpc/generated"
)
install(
FILES
"${CMAKE_CURRENT_SOURCE_DIR}/deltachat-jsonrpc-bindings/qt/deltachat-jsonrpc/cffi_client.hpp"
DESTINATION
"${CMAKE_INSTALL_INCLUDEDIR}/deltachat-jsonrpc"
)
endif()
Generated
+326 -361
View File
File diff suppressed because it is too large Load Diff
+14 -12
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "deltachat" name = "deltachat"
version = "2.64.0-dev" version = "2.51.0-dev"
edition = "2024" edition = "2024"
license = "MPL-2.0" license = "MPL-2.0"
rust-version = "1.89" rust-version = "1.89"
@@ -36,6 +36,7 @@ strip = true
[dependencies] [dependencies]
deltachat_derive = { path = "./deltachat_derive" } deltachat_derive = { path = "./deltachat_derive" }
deltachat-time = { path = "./deltachat-time" }
deltachat-contact-tools = { workspace = true } deltachat-contact-tools = { workspace = true }
format-flowed = { path = "./format-flowed" } format-flowed = { path = "./format-flowed" }
ratelimit = { path = "./deltachat-ratelimit" } ratelimit = { path = "./deltachat-ratelimit" }
@@ -43,7 +44,7 @@ ratelimit = { path = "./deltachat-ratelimit" }
anyhow = { workspace = true } anyhow = { workspace = true }
async-broadcast = "0.7.2" async-broadcast = "0.7.2"
async-channel = { workspace = true } async-channel = { workspace = true }
async-imap = { version = "0.12.0", default-features = false, features = ["runtime-tokio", "compress"] } async-imap = { version = "0.11.1", default-features = false, features = ["runtime-tokio", "compress"] }
async-native-tls = { version = "0.6", default-features = false, features = ["runtime-tokio"] } async-native-tls = { version = "0.6", default-features = false, features = ["runtime-tokio"] }
async-smtp = { version = "0.10.2", default-features = false, features = ["runtime-tokio"] } async-smtp = { version = "0.10.2", default-features = false, features = ["runtime-tokio"] }
async_zip = { version = "0.0.18", default-features = false, features = ["deflate", "tokio-fs"] } async_zip = { version = "0.0.18", default-features = false, features = ["deflate", "tokio-fs"] }
@@ -69,7 +70,7 @@ iroh-gossip = { version = "0.35", default-features = false, features = ["net"] }
iroh = { version = "0.35", default-features = false } iroh = { version = "0.35", default-features = false }
kamadak-exif = "0.6.1" kamadak-exif = "0.6.1"
libc = { workspace = true } libc = { workspace = true }
mail-builder = { version = "1.0.0", default-features = false } mail-builder = { version = "0.4.4", default-features = false }
mailparse = { workspace = true } mailparse = { workspace = true }
mime = "0.3.17" mime = "0.3.17"
num_cpus = "1.17" num_cpus = "1.17"
@@ -77,16 +78,18 @@ num-derive = "0.4"
num-traits = { workspace = true } num-traits = { workspace = true }
parking_lot = "0.12.4" parking_lot = "0.12.4"
percent-encoding = "2.3" percent-encoding = "2.3"
pgp = { version = "0.21.0", features = ["pqc"], default-features = false } pgp = { version = "0.19.0", features = ["draft-pqc"], default-features = false }
pin-project = "1" pin-project = "1"
qrcodegen = "1.7.0" qrcodegen = "1.7.0"
quick-xml = { version = "0.41", features = ["escape-html"] } quick-xml = { version = "0.39", features = ["escape-html"] }
rand-old = { package = "rand", version = "0.8" } rand-old = { package = "rand", version = "0.8" }
rand = { workspace = true } rand = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] } rusqlite = { workspace = true, features = ["sqlcipher"] }
sanitize-filename = { workspace = true } sanitize-filename = { workspace = true }
sdp = "0.17.1"
serde_json = { workspace = true } serde_json = { workspace = true }
serde_urlencoded = "0.7.1"
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
sha-1 = "0.10" sha-1 = "0.10"
sha2 = "0.10" sha2 = "0.10"
@@ -98,12 +101,13 @@ tagger = "4.3.4"
textwrap = "0.16.2" textwrap = "0.16.2"
thiserror = { workspace = true } thiserror = { workspace = true }
tokio-io-timeout = "1.2.1" tokio-io-timeout = "1.2.1"
tokio-rustls = { version = "0.26.2", default-features = false, features = ["tls12", "brotli"] } tokio-rustls = { version = "0.26.2", default-features = false, features = ["aws-lc-rs", "tls12"] }
tokio-stream = { version = "0.1.17", features = ["fs"] } tokio-stream = { version = "0.1.17", features = ["fs"] }
astral-tokio-tar = { version = "0.7.0", default-features = false } astral-tokio-tar = { version = "0.6.2", default-features = false }
tokio-util = { workspace = true } tokio-util = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] } tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.9" toml = "0.9"
tracing = "0.1.41"
url = "2" url = "2"
uuid = { version = "1", features = ["serde", "v4"] } uuid = { version = "1", features = ["serde", "v4"] }
walkdir = "2.5.0" walkdir = "2.5.0"
@@ -112,7 +116,6 @@ webpki-roots = "0.26.8"
[dev-dependencies] [dev-dependencies]
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests. anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.
criterion = { version = "0.8.1", features = ["async_tokio"] } criterion = { version = "0.8.1", features = ["async_tokio"] }
deltachat-time = { path = "./deltachat-time" }
futures-lite = { workspace = true } futures-lite = { workspace = true }
log = { workspace = true } log = { workspace = true }
nu-ansi-term = { workspace = true } nu-ansi-term = { workspace = true }
@@ -127,7 +130,6 @@ members = [
"deltachat-ffi", "deltachat-ffi",
"deltachat_derive", "deltachat_derive",
"deltachat-jsonrpc", "deltachat-jsonrpc",
"deltachat-jsonrpc-bindings",
"deltachat-rpc-server", "deltachat-rpc-server",
"deltachat-ratelimit", "deltachat-ratelimit",
"deltachat-repl", "deltachat-repl",
@@ -178,7 +180,7 @@ harness = false
[workspace.dependencies] [workspace.dependencies]
anyhow = "1" anyhow = "1"
async-channel = "2.5.0" async-channel = "2.5.0"
base64 = { version = "0.23", default-features = false, features = ["std"] } base64 = "0.22"
chrono = { version = "0.4.44", default-features = false } chrono = { version = "0.4.44", default-features = false }
deltachat-contact-tools = { path = "deltachat-contact-tools" } deltachat-contact-tools = { path = "deltachat-contact-tools" }
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false } deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
@@ -187,7 +189,7 @@ futures = "0.3.32"
futures-lite = "2.6.1" futures-lite = "2.6.1"
libc = "0.2" libc = "0.2"
log = "0.4" log = "0.4"
mailparse = "0.17.0" mailparse = "0.16.1"
nu-ansi-term = "0.50" nu-ansi-term = "0.50"
num-traits = "0.2" num-traits = "0.2"
rand = "0.9" rand = "0.9"
@@ -201,7 +203,7 @@ thiserror = "2"
tokio = "1" tokio = "1"
tokio-util = "0.7.18" tokio-util = "0.7.18"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
yerpc = "0.7" yerpc = "0.6.4"
[features] [features]
default = ["vendored"] default = ["vendored"]
+3 -3
View File
@@ -21,8 +21,8 @@ The following protocols are handled without requiring API users to know much abo
- secure TLS setup with DNS caching and shadowsocks/proxy support - secure TLS setup with DNS caching and shadowsocks/proxy support
- robust [SMTP](https://github.com/chatmail/async-smtp) - robust [SMTP](https://github.com/chatmail/async-imap)
and [IMAP](https://github.com/chatmail/async-imap) handling and [IMAP](https://github.com/chatmail/async-smtp) handling
- safe and interoperable [MIME parsing](https://github.com/staktrace/mailparse) - safe and interoperable [MIME parsing](https://github.com/staktrace/mailparse)
and [MIME building](https://github.com/stalwartlabs/mail-builder). and [MIME building](https://github.com/stalwartlabs/mail-builder).
@@ -167,7 +167,7 @@ $ cargo test -- --ignored
Install [`cargo-bolero`](https://github.com/camshaft/bolero) with Install [`cargo-bolero`](https://github.com/camshaft/bolero) with
```sh ```sh
$ cargo install --locked cargo-bolero $ cargo install cargo-bolero
``` ```
Run fuzzing tests with Run fuzzing tests with
-14
View File
@@ -59,13 +59,6 @@ If column is already declared without `NOT NULL`, use `IFNULL` function to provi
Use `HAVING COUNT(*) > 0` clause Use `HAVING COUNT(*) > 0` clause
to [prevent aggregate functions such as `MIN` and `MAX` from returning `NULL`](https://stackoverflow.com/questions/66527856/aggregate-functions-max-etc-return-null-instead-of-no-rows). to [prevent aggregate functions such as `MIN` and `MAX` from returning `NULL`](https://stackoverflow.com/questions/66527856/aggregate-functions-max-etc-return-null-instead-of-no-rows).
List columns explicitly in `INSERT` statements:
```
INSERT OR IGNORE INTO download (rfc724_mid, msg_id) VALUES (?,0);
```
Otherwise if a new column with default value is added in a future DB version, an upgraded DB can't
be used with the old code, e.g. after transferring a DB from a device running a newer version.
Don't delete unused columns too early, but maybe after several months/releases, unused columns are Don't delete unused columns too early, but maybe after several months/releases, unused columns are
still used by older versions, so deleting them breaks downgrading the core or importing a backup in still used by older versions, so deleting them breaks downgrading the core or importing a backup in
an older version. Also don't change the column type, consider adding a new column with another name an older version. Also don't change the column type, consider adding a new column with another name
@@ -181,10 +174,3 @@ Calling `into()`, `try_into()` or `parse()`
creates an indirection, creates an indirection,
which is hard to follow for people who are not familiar with Rust, which is hard to follow for people who are not familiar with Rust,
or who are not using rust-analyzer. or who are not using rust-analyzer.
## Use macros only when really needed
Macros can be hard to read for people unfamiliar with Rust,
and can have surprising effects like evaluating arguments multiple times.
Therefore, macros should only be used when really needed;
using functions is usually better.
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "deltachat-contact-tools" name = "deltachat-contact-tools"
version = "0.0.0" # No semver-stable versioning version = "0.0.0" # No semver-stable versioning
edition = "2024" edition = "2021"
description = "Contact-related tools, like parsing vcards and sanitizing name and address. Meant for internal use in the deltachat crate." description = "Contact-related tools, like parsing vcards and sanitizing name and address. Meant for internal use in the deltachat crate."
license = "MPL-2.0" license = "MPL-2.0"
+9 -6
View File
@@ -29,16 +29,17 @@
use std::fmt; use std::fmt;
use std::ops::Deref; use std::ops::Deref;
use std::sync::LazyLock;
use anyhow::Result;
use anyhow::bail; use anyhow::bail;
use regex::regex; use anyhow::Result;
use regex::Regex;
mod vcard; mod vcard;
pub use vcard::{VcardContact, make_vcard, parse_vcard}; pub use vcard::{make_vcard, parse_vcard, VcardContact};
/// Valid contact address. /// Valid contact address.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone)]
pub struct ContactAddress(String); pub struct ContactAddress(String);
impl Deref for ContactAddress { impl Deref for ContactAddress {
@@ -87,7 +88,9 @@ impl rusqlite::types::ToSql for ContactAddress {
/// - Removes special characters from the name, see [`sanitize_name()`] /// - Removes special characters from the name, see [`sanitize_name()`]
/// - Removes the name if it is equal to the address by setting it to "" /// - Removes the name if it is equal to the address by setting it to ""
pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) { pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) {
let (name, addr) = if let Some(captures) = regex!("(.*)<(.*)>").captures(addr.as_ref()) { static ADDR_WITH_NAME_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new("(.*)<(.*)>").unwrap());
let (name, addr) = if let Some(captures) = ADDR_WITH_NAME_REGEX.captures(addr.as_ref()) {
( (
if name.is_empty() { if name.is_empty() {
captures.get(1).map_or("", |m| m.as_str()) captures.get(1).map_or("", |m| m.as_str())
@@ -104,7 +107,7 @@ pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) {
let mut name = sanitize_name(name); let mut name = sanitize_name(name);
// If the 'display name' is just the address, remove it: // If the 'display name' is just the address, remove it:
// Otherwise, the contact would sometimes be shown as "alice@example.com (alice@example.com)". // Otherwise, the contact would sometimes be shown as "alice@example.com (alice@example.com)" (see `get_name_n_addr()`).
// If the display name is empty, DC will just show the address when it needs a display name. // If the display name is empty, DC will just show the address when it needs a display name.
if name == addr { if name == addr {
name = "".to_string(); name = "".to_string();
+6 -2
View File
@@ -1,8 +1,10 @@
use std::sync::LazyLock;
use anyhow::Context as _; use anyhow::Context as _;
use anyhow::Result; use anyhow::Result;
use chrono::DateTime; use chrono::DateTime;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use regex::regex; use regex::Regex;
use crate::sanitize_name_and_addr; use crate::sanitize_name_and_addr;
@@ -208,7 +210,9 @@ pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
} }
// Remove line folding, see https://datatracker.ietf.org/doc/html/rfc6350#section-3.2 // Remove line folding, see https://datatracker.ietf.org/doc/html/rfc6350#section-3.2
let unfolded_lines = regex!("\r?\n[\t ]").replace_all(vcard, ""); static NEWLINE_AND_SPACE_OR_TAB: LazyLock<Regex> =
LazyLock::new(|| Regex::new("\r?\n[\t ]").unwrap());
let unfolded_lines = NEWLINE_AND_SPACE_OR_TAB.replace_all(vcard, "");
let mut lines = unfolded_lines.lines().peekable(); let mut lines = unfolded_lines.lines().peekable();
let mut contacts = Vec::new(); let mut contacts = Vec::new();
@@ -220,10 +220,7 @@ END:VCARD
assert_eq!(contacts[0].addr, "bob@example.org".to_string()); assert_eq!(contacts[0].addr, "bob@example.org".to_string());
assert_eq!(contacts[0].authname, "Bob".to_string()); assert_eq!(contacts[0].authname, "Bob".to_string());
assert_eq!(contacts[0].key, None); assert_eq!(contacts[0].key, None);
assert_eq!( assert_eq!(contacts[0].profile_image.as_deref().unwrap(), "/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEUAAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAAL8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q==");
contacts[0].profile_image.as_deref().unwrap(),
"/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEUAAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAAL8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q=="
);
} }
} }
@@ -247,10 +244,7 @@ END:VCARD",
assert_eq!(contacts.len(), 1); assert_eq!(contacts.len(), 1);
assert_eq!(&contacts[0].addr, "alice@example.org"); assert_eq!(&contacts[0].addr, "alice@example.org");
assert_eq!(&contacts[0].authname, "Alice Wonderland"); assert_eq!(&contacts[0].authname, "Alice Wonderland");
assert_eq!( assert_eq!(contacts[0].key.as_ref().unwrap(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
contacts[0].key.as_ref().unwrap(),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
assert!(contacts[0].timestamp.is_err()); assert!(contacts[0].timestamp.is_err());
assert_eq!(contacts[0].profile_image, None); assert_eq!(contacts[0].profile_image, None);
} }
@@ -278,15 +272,9 @@ END:VCARD",
assert_eq!(contacts.len(), 1); assert_eq!(contacts.len(), 1);
assert_eq!(&contacts[0].addr, "alice@example.org"); assert_eq!(&contacts[0].addr, "alice@example.org");
assert_eq!(&contacts[0].authname, "Alice"); assert_eq!(&contacts[0].authname, "Alice");
assert_eq!( assert_eq!(contacts[0].key.as_ref().unwrap(), "xsaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa==");
contacts[0].key.as_ref().unwrap(),
"xsaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=="
);
assert!(contacts[0].timestamp.is_err()); assert!(contacts[0].timestamp.is_err());
assert_eq!( assert_eq!(contacts[0].profile_image.as_ref().unwrap(), "/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z");
contacts[0].profile_image.as_ref().unwrap(),
"/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z"
);
} }
#[test] #[test]
+3 -2
View File
@@ -1,8 +1,9 @@
[package] [package]
name = "deltachat_ffi" name = "deltachat_ffi"
version = "2.64.0-dev" version = "2.51.0-dev"
description = "Deltachat FFI" description = "Deltachat FFI"
edition = "2024" edition = "2018"
readme = "README.md"
license = "MPL-2.0" license = "MPL-2.0"
keywords = ["deltachat", "chat", "openpgp", "email", "encryption"] keywords = ["deltachat", "chat", "openpgp", "email", "encryption"]
+3 -3
View File
@@ -21,9 +21,9 @@ fn main() {
url = env::var("CARGO_PKG_HOMEPAGE").unwrap_or_else(|_| "".to_string()), url = env::var("CARGO_PKG_HOMEPAGE").unwrap_or_else(|_| "".to_string()),
version = env::var("CARGO_PKG_VERSION").unwrap(), version = env::var("CARGO_PKG_VERSION").unwrap(),
libs_priv = libs_priv, libs_priv = libs_priv,
prefix = option_env!("PREFIX").unwrap_or_else(|| "/usr/local"), prefix = env::var("PREFIX").unwrap_or_else(|_| "/usr/local".to_string()),
libdir = option_env!("LIBDIR").unwrap_or_else(|| "/usr/local/lib"), libdir = env::var("LIBDIR").unwrap_or_else(|_| "/usr/local/lib".to_string()),
includedir = option_env!("INCLUDEDIR").unwrap_or_else(|| "/usr/local/include"), includedir = env::var("INCLUDEDIR").unwrap_or_else(|_| "/usr/local/include".to_string()),
); );
fs::create_dir_all(target_path.join("pkgconfig")).unwrap(); fs::create_dir_all(target_path.join("pkgconfig")).unwrap();
+441 -238
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,4 +1,5 @@
use crate::chat::ChatItem; use crate::chat::ChatItem;
use crate::constants::DC_MSG_ID_DAYMARKER;
use crate::contact::ContactId; use crate::contact::ContactId;
use crate::location::Location; use crate::location::Location;
use crate::message::MsgId; use crate::message::MsgId;
@@ -20,7 +21,7 @@ impl dc_array_t {
Self::ContactIds(array) => array[index].to_u32(), Self::ContactIds(array) => array[index].to_u32(),
Self::Chat(array) => match array[index] { Self::Chat(array) => match array[index] {
ChatItem::Message { msg_id } => msg_id.to_u32(), ChatItem::Message { msg_id } => msg_id.to_u32(),
ChatItem::DayMarker { .. } => MsgId::DAYMARKER.to_u32(), ChatItem::DayMarker { .. } => DC_MSG_ID_DAYMARKER,
}, },
Self::Locations(array) => array[index].location_id, Self::Locations(array) => array[index].location_id,
Self::Uint(array) => array[index], Self::Uint(array) => array[index],
+1275 -1000
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -47,6 +47,8 @@ impl Lot {
Qr::AskVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)), Qr::AskVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::AskJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)), Qr::AskJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::FprOk { .. } => None, Qr::FprOk { .. } => None,
Qr::FprMismatch { .. } => None,
Qr::FprWithoutAddr { fingerprint, .. } => Some(Cow::Borrowed(fingerprint)),
Qr::Account { domain } => Some(Cow::Borrowed(domain)), Qr::Account { domain } => Some(Cow::Borrowed(domain)),
Qr::Backup2 { .. } => None, Qr::Backup2 { .. } => None,
Qr::BackupTooNew { .. } => None, Qr::BackupTooNew { .. } => None,
@@ -101,6 +103,8 @@ impl Lot {
Qr::AskVerifyGroup { .. } => LotState::QrAskVerifyGroup, Qr::AskVerifyGroup { .. } => LotState::QrAskVerifyGroup,
Qr::AskJoinBroadcast { .. } => LotState::QrAskJoinBroadcast, Qr::AskJoinBroadcast { .. } => LotState::QrAskJoinBroadcast,
Qr::FprOk { .. } => LotState::QrFprOk, Qr::FprOk { .. } => LotState::QrFprOk,
Qr::FprMismatch { .. } => LotState::QrFprMismatch,
Qr::FprWithoutAddr { .. } => LotState::QrFprWithoutAddr,
Qr::Account { .. } => LotState::QrAccount, Qr::Account { .. } => LotState::QrAccount,
Qr::Backup2 { .. } => LotState::QrBackup2, Qr::Backup2 { .. } => LotState::QrBackup2,
Qr::BackupTooNew { .. } => LotState::QrBackupTooNew, Qr::BackupTooNew { .. } => LotState::QrBackupTooNew,
@@ -128,6 +132,8 @@ impl Lot {
Qr::AskVerifyGroup { .. } => Default::default(), Qr::AskVerifyGroup { .. } => Default::default(),
Qr::AskJoinBroadcast { .. } => Default::default(), Qr::AskJoinBroadcast { .. } => Default::default(),
Qr::FprOk { contact_id } => contact_id.to_u32(), Qr::FprOk { contact_id } => contact_id.to_u32(),
Qr::FprMismatch { contact_id } => contact_id.unwrap_or_default().to_u32(),
Qr::FprWithoutAddr { .. } => Default::default(),
Qr::Account { .. } => Default::default(), Qr::Account { .. } => Default::default(),
Qr::Backup2 { .. } => Default::default(), Qr::Backup2 { .. } => Default::default(),
Qr::BackupTooNew { .. } => Default::default(), Qr::BackupTooNew { .. } => Default::default(),
@@ -175,6 +181,12 @@ pub enum LotState {
/// id=contact /// id=contact
QrFprOk = 210, QrFprOk = 210,
/// id=contact
QrFprMismatch = 220,
/// text1=formatted fingerprint
QrFprWithoutAddr = 230,
/// text1=domain /// text1=domain
QrAccount = 250, QrAccount = 250,
+32 -44
View File
@@ -17,15 +17,13 @@ use std::ptr;
/// } /// }
/// ``` /// ```
unsafe fn dc_strdup(s: *const libc::c_char) -> *mut libc::c_char { unsafe fn dc_strdup(s: *const libc::c_char) -> *mut libc::c_char {
unsafe { let ret: *mut libc::c_char = if !s.is_null() {
let ret: *mut libc::c_char = if !s.is_null() { libc::strdup(s)
libc::strdup(s) } else {
} else { libc::calloc(1, 1) as *mut libc::c_char
libc::calloc(1, 1) as *mut libc::c_char };
}; assert!(!ret.is_null());
assert!(!ret.is_null()); ret
ret
}
} }
/// Error type for the [OsStrExt] trait /// Error type for the [OsStrExt] trait
@@ -166,40 +164,34 @@ pub(crate) trait Strdup {
/// This function will panic when the original string contains an /// This function will panic when the original string contains an
/// interior null byte as this can not be represented in raw C /// interior null byte as this can not be represented in raw C
/// strings. /// strings.
fn strdup(&self) -> *mut libc::c_char; unsafe fn strdup(&self) -> *mut libc::c_char;
} }
impl Strdup for str { impl Strdup for str {
fn strdup(&self) -> *mut libc::c_char { unsafe fn strdup(&self) -> *mut libc::c_char {
unsafe { let tmp = CString::new_lossy(self);
let tmp = CString::new_lossy(self); dc_strdup(tmp.as_ptr())
dc_strdup(tmp.as_ptr())
}
} }
} }
impl Strdup for String { impl Strdup for String {
fn strdup(&self) -> *mut libc::c_char { unsafe fn strdup(&self) -> *mut libc::c_char {
let s: &str = self; let s: &str = self;
s.strdup() s.strdup()
} }
} }
impl Strdup for std::path::Path { impl Strdup for std::path::Path {
fn strdup(&self) -> *mut libc::c_char { unsafe fn strdup(&self) -> *mut libc::c_char {
unsafe { let tmp = self.to_c_string().unwrap_or_else(|_| CString::default());
let tmp = self.to_c_string().unwrap_or_else(|_| CString::default()); dc_strdup(tmp.as_ptr())
dc_strdup(tmp.as_ptr())
}
} }
} }
impl Strdup for [u8] { impl Strdup for [u8] {
fn strdup(&self) -> *mut libc::c_char { unsafe fn strdup(&self) -> *mut libc::c_char {
unsafe { let tmp = CString::new_lossy(self);
let tmp = CString::new_lossy(self); dc_strdup(tmp.as_ptr())
dc_strdup(tmp.as_ptr())
}
} }
} }
@@ -217,15 +209,15 @@ pub(crate) trait OptStrdup {
/// Allocate a new raw C `*char` version of this string, or NULL. /// Allocate a new raw C `*char` version of this string, or NULL.
/// ///
/// See [Strdup::strdup] for details. /// See [Strdup::strdup] for details.
fn strdup(&self) -> *mut libc::c_char; unsafe fn strdup(&self) -> *mut libc::c_char;
} }
impl<T: AsRef<str>> OptStrdup for Option<T> { impl<T: AsRef<str>> OptStrdup for Option<T> {
fn strdup(&self) -> *mut libc::c_char { unsafe fn strdup(&self) -> *mut libc::c_char {
match self { match self {
Some(s) => { Some(s) => {
let tmp = CString::new_lossy(s.as_ref()); let tmp = CString::new_lossy(s.as_ref());
unsafe { dc_strdup(tmp.as_ptr()) } dc_strdup(tmp.as_ptr())
} }
None => ptr::null_mut(), None => ptr::null_mut(),
} }
@@ -263,18 +255,20 @@ pub(crate) fn to_opt_string_lossy(s: *const libc::c_char) -> Option<String> {
/// ///
/// [Path]: std::path::Path /// [Path]: std::path::Path
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
pub(crate) unsafe fn as_path<'a>(s: *const libc::c_char) -> &'a std::path::Path { pub(crate) fn as_path<'a>(s: *const libc::c_char) -> &'a std::path::Path {
assert!(!s.is_null(), "cannot be used on null pointers"); assert!(!s.is_null(), "cannot be used on null pointers");
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
let c_str = unsafe { std::ffi::CStr::from_ptr(s) }.to_bytes(); unsafe {
let os_str = std::ffi::OsStr::from_bytes(c_str); let c_str = std::ffi::CStr::from_ptr(s).to_bytes();
std::path::Path::new(os_str) let os_str = std::ffi::OsStr::from_bytes(c_str);
std::path::Path::new(os_str)
}
} }
// as_path() implementation for windows, documented above. // as_path() implementation for windows, documented above.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub(crate) unsafe fn as_path<'a>(s: *const libc::c_char) -> &'a std::path::Path { pub(crate) fn as_path<'a>(s: *const libc::c_char) -> &'a std::path::Path {
unsafe { as_path_unicode(s) } as_path_unicode(s)
} }
// Implementation for as_path() on Windows. // Implementation for as_path() on Windows.
@@ -282,7 +276,7 @@ pub(crate) unsafe fn as_path<'a>(s: *const libc::c_char) -> &'a std::path::Path
// Having this as a separate function means it can be tested on unix // Having this as a separate function means it can be tested on unix
// too. // too.
#[allow(dead_code)] #[allow(dead_code)]
unsafe fn as_path_unicode<'a>(s: *const libc::c_char) -> &'a std::path::Path { fn as_path_unicode<'a>(s: *const libc::c_char) -> &'a std::path::Path {
assert!(!s.is_null(), "cannot be used on null pointers"); assert!(!s.is_null(), "cannot be used on null pointers");
let cstr = unsafe { CStr::from_ptr(s) }; let cstr = unsafe { CStr::from_ptr(s) };
@@ -370,20 +364,14 @@ mod tests {
fn test_as_path() { fn test_as_path() {
let some_path = CString::new("/some/path").unwrap(); let some_path = CString::new("/some/path").unwrap();
let ptr = some_path.as_ptr(); let ptr = some_path.as_ptr();
assert_eq!( assert_eq!(as_path(ptr), std::ffi::OsString::from("/some/path"))
unsafe { as_path(ptr) },
std::ffi::OsString::from("/some/path")
)
} }
#[test] #[test]
fn test_as_path_unicode_fn() { fn test_as_path_unicode_fn() {
let some_path = CString::new("/some/path").unwrap(); let some_path = CString::new("/some/path").unwrap();
let ptr = some_path.as_ptr(); let ptr = some_path.as_ptr();
assert_eq!( assert_eq!(as_path_unicode(ptr), std::ffi::OsString::from("/some/path"));
unsafe { as_path_unicode(ptr) },
std::ffi::OsString::from("/some/path")
);
} }
#[test] #[test]
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "deltachat-jsonrpc-bindings"
version = "2.64.0-dev"
description = "Autogenerate DeltaChat JSON-RPC API bindings at build time"
edition = "2024"
license = "MPL-2.0"
repository = "https://github.com/chatmail/core"
[build-dependencies]
deltachat-jsonrpc = { workspace = true }
[dependencies]
[features]
default = ["vendored"]
vendored = ["deltachat-jsonrpc/vendored"]
-7
View File
@@ -1,7 +0,0 @@
use deltachat_jsonrpc::api::{write_qt_bindings, write_ts_bindings};
use std::path::Path;
fn main() {
write_ts_bindings(Path::new("typescript/generated"));
write_qt_bindings(Path::new("qt/generated"), "deltachat");
}
-1
View File
@@ -1 +0,0 @@
generated
@@ -1,111 +0,0 @@
#pragma once
#include "deltachat.h"
#include "generated/client.hpp"
#include "generated/types.hpp"
#include <cstdint>
#include <mutex>
#include <thread>
namespace deltachat {
class CffiTransport : public Transport {
using CompletionHandler = Transport::CompletionHandler;
public:
explicit CffiTransport(dc_accounts_t *accounts)
: jsonrpc_(dc_jsonrpc_init(accounts)) {
if (!jsonrpc_)
std::abort();
thread_ = std::thread([this] { run(); });
}
~CffiTransport() override {
done_ = true;
// Unblock dc_jsonrpc_next_response by sending a dummy request
if (jsonrpc_)
dc_jsonrpc_request(
jsonrpc_,
"{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"get_system_info\"}");
if (thread_.joinable())
thread_.join();
std::lock_guard lk(mu_);
for (auto &[id, cb] : pending_) {
cb(Result<QJsonValue>::error(-32060, "Transport destructed"));
}
pending_.clear();
if (jsonrpc_)
dc_jsonrpc_unref(jsonrpc_);
}
virtual void send(const QString method, const QJsonValue params,
CompletionHandler onCompleted) override {
uint32_t id = next_id_++;
QJsonObject envelope{
{"jsonrpc", "2.0"},
{"id", static_cast<qint64>(id)},
{"method", method},
{"params", params},
};
{
std::lock_guard lk(mu_);
pending_[id] = std::move(onCompleted);
}
QByteArray json = QJsonDocument(envelope).toJson(QJsonDocument::Compact);
dc_jsonrpc_request(jsonrpc_, json.constData());
}
private:
void run() {
while (!done_) {
char *raw_json = dc_jsonrpc_next_response(jsonrpc_);
if (!raw_json) {
break;
}
QByteArray json{raw_json};
dc_str_unref(raw_json);
if (done_)
break;
QJsonObject obj = QJsonDocument::fromJson(json).object();
if (!obj["id"].isDouble()) {
qCritical() << "No valid rpc id in" << QString{json};
continue;
}
uint32_t id = static_cast<uint32_t>(obj["id"].toInt());
CompletionHandler cb;
{
std::lock_guard<std::mutex> lk(mu_);
if (auto nh = pending_.extract(id)) {
cb = std::move(nh.mapped());
} else {
qCritical() << "Could not map response" << QString{json};
continue;
}
}
cb(parseResult(obj));
}
}
private:
dc_jsonrpc_instance_t *jsonrpc_;
std::thread thread_;
std::mutex mu_;
std::atomic<uint32_t> next_id_{1};
std::atomic<bool> done_{false};
std::unordered_map<uint32_t, CompletionHandler> pending_;
};
class CffiDeltaChat : public RawClient {
public:
explicit CffiDeltaChat(dc_accounts_t *accounts)
: RawClient(std::make_unique<CffiTransport>(accounts)) {}
};
} // namespace deltachat
Q_DECLARE_METATYPE(deltachat::CffiDeltaChat *)
-1
View File
@@ -1 +0,0 @@
+2 -2
View File
@@ -1,8 +1,8 @@
[package] [package]
name = "deltachat-jsonrpc" name = "deltachat-jsonrpc"
version = "2.64.0-dev" version = "2.51.0-dev"
description = "DeltaChat JSON-RPC API" description = "DeltaChat JSON-RPC API"
edition = "2024" edition = "2021"
license = "MPL-2.0" license = "MPL-2.0"
repository = "https://github.com/chatmail/core" repository = "https://github.com/chatmail/core"
+107 -154
View File
@@ -5,30 +5,31 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use std::{collections::HashMap, str::FromStr}; use std::{collections::HashMap, str::FromStr};
use anyhow::{Context, Result, anyhow, bail, ensure}; use anyhow::{anyhow, bail, ensure, Context, Result};
use deltachat::EventEmitter;
pub use deltachat::accounts::Accounts; pub use deltachat::accounts::Accounts;
use deltachat::blob::BlobObject; use deltachat::blob::BlobObject;
use deltachat::calls::ice_servers; use deltachat::calls::ice_servers;
use deltachat::chat::{ use deltachat::chat::{
self, Chat, ChatId, ChatItem, MessageListOptions, add_contact_to_chat, forward_msgs, self, add_contact_to_chat, forward_msgs, forward_msgs_2ctx, get_chat_media, get_chat_msgs,
forward_msgs_2ctx, get_chat_media, get_chat_msgs, get_chat_msgs_ext, markfresh_chat, get_chat_msgs_ex, markfresh_chat, marknoticed_all_chats, marknoticed_chat,
marknoticed_all_chats, marknoticed_chat, remove_contact_from_chat, remove_contact_from_chat, Chat, ChatId, ChatItem, MessageListOptions,
}; };
use deltachat::chatlist::Chatlist; use deltachat::chatlist::Chatlist;
use deltachat::config::{Config, get_all_ui_config_keys}; use deltachat::config::{get_all_ui_config_keys, Config};
use deltachat::contact::{Contact, ContactId, Origin, may_be_valid_addr}; use deltachat::constants::DC_MSG_ID_DAYMARKER;
use deltachat::contact::{may_be_valid_addr, Contact, ContactId, Origin};
use deltachat::context::get_info; use deltachat::context::get_info;
use deltachat::ephemeral::Timer; use deltachat::ephemeral::Timer;
use deltachat::imex; use deltachat::imex;
use deltachat::location; use deltachat::location;
use deltachat::message::{ use deltachat::message::{
self, Message, MessageState, MsgId, Viewtype, delete_msgs_ext, get_existing_msg_ids, self, delete_msgs_ex, get_existing_msg_ids, get_msg_read_receipt_count, get_msg_read_receipts,
get_msg_read_receipt_count, get_msg_read_receipts, markseen_msgs, markseen_msgs, Message, MessageState, MsgId, Viewtype,
}; };
use deltachat::peer_channels::{ use deltachat::peer_channels::{
leave_webxdc_realtime, send_webxdc_realtime_advertisement, send_webxdc_realtime_data, leave_webxdc_realtime, send_webxdc_realtime_advertisement, send_webxdc_realtime_data,
}; };
use deltachat::provider::get_provider_info;
use deltachat::qr::{self, Qr}; use deltachat::qr::{self, Qr};
use deltachat::qr_code_generator::{create_qr_svg, generate_backup_qr, get_securejoin_qr_svg}; use deltachat::qr_code_generator::{create_qr_svg, generate_backup_qr, get_securejoin_qr_svg};
use deltachat::reaction::{get_msg_reactions, send_reaction}; use deltachat::reaction::{get_msg_reactions, send_reaction};
@@ -36,9 +37,10 @@ use deltachat::securejoin;
use deltachat::stock_str::StockMessage; use deltachat::stock_str::StockMessage;
use deltachat::storage_usage::{get_blobdir_storage_usage, get_storage_usage}; use deltachat::storage_usage::{get_blobdir_storage_usage, get_storage_usage};
use deltachat::webxdc::StatusUpdateSerial; use deltachat::webxdc::StatusUpdateSerial;
use deltachat::EventEmitter;
use sanitize_filename::is_sanitized; use sanitize_filename::is_sanitized;
use tokio::fs; use tokio::fs;
use tokio::sync::{Mutex, RwLock, watch}; use tokio::sync::{watch, Mutex, RwLock};
use types::login_param::EnteredLoginParam; use types::login_param::EnteredLoginParam;
use yerpc::rpc; use yerpc::rpc;
@@ -52,6 +54,8 @@ use types::contact::{ContactObject, VcardContact};
use types::events::Event; use types::events::Event;
use types::http::HttpResponse; use types::http::HttpResponse;
use types::message::{MessageData, MessageObject, MessageReadReceipt}; use types::message::{MessageData, MessageObject, MessageReadReceipt};
use types::notify_state::JsonrpcNotifyState;
use types::provider_info::ProviderInfo;
use types::reactions::JsonrpcReactions; use types::reactions::JsonrpcReactions;
use types::webxdc::WebxdcMessageInfo; use types::webxdc::WebxdcMessageInfo;
@@ -63,8 +67,8 @@ use self::types::{
JsonrpcMessageListItem, MessageNotificationInfo, MessageSearchResult, MessageViewtype, JsonrpcMessageListItem, MessageNotificationInfo, MessageSearchResult, MessageViewtype,
}, },
}; };
use crate::api::types::appversions::JsonrpcAppSource; use crate::api::types::chat_list::{get_chat_list_item_by_id, ChatListItemFetchResult};
use crate::api::types::chat_list::{ChatListItemFetchResult, get_chat_list_item_by_id}; use crate::api::types::login_param::TransportListEntry;
use crate::api::types::qr::{QrObject, SecurejoinSource, SecurejoinUiPath}; use crate::api::types::qr::{QrObject, SecurejoinSource, SecurejoinUiPath};
#[derive(Debug)] #[derive(Debug)]
@@ -153,7 +157,7 @@ impl CommandApi {
} }
} }
#[rpc(all_positional)] #[rpc(all_positional, ts_outdir = "typescript/generated")]
impl CommandApi { impl CommandApi {
/// Test function. /// Test function.
async fn sleep(&self, delay: f64) { async fn sleep(&self, delay: f64) {
@@ -278,26 +282,9 @@ impl CommandApi {
/// Performs a background fetch for all accounts in parallel with a timeout. /// Performs a background fetch for all accounts in parallel with a timeout.
/// ///
/// For an account with IO stopped, the scheduler is paused /// The `AccountsBackgroundFetchDone` event is emitted at the end even in case of timeout.
/// and every transport is fetched concurrently on a dedicated connection.
/// The account is done as soon as one transport received messages, the others stop.
/// Only one batch of messages is fetched per transport this way,
/// so a larger backlog is left to the next call or to started IO.
///
/// For an account with IO running, IMAP IDLE is interrupted on every transport
/// and the account is done once every transport is.
///
/// The call never waits for outgoing messages and never triggers sending them itself.
/// Received messages may still queue replies, securejoin handshakes for example,
/// which go out only while IO is running.
/// Use `is_sending_finished()` to tell whether the outgoing queue is empty.
///
/// The `AccountsBackgroundFetchDone` event is emitted at the end even in case of timeout,
/// and immediately if another background fetch is already running.
/// Process all events until you get this one and you can safely return to the background /// Process all events until you get this one and you can safely return to the background
/// without forgetting to create a generic notification if no message was fetched. /// without forgetting to create notifications caused by timing race conditions.
/// The event carries no data identifying the call it belongs to,
/// so it marks your own call only if no concurrent background fetch is happening.
async fn background_fetch(&self, timeout_in_seconds: f64) -> Result<()> { async fn background_fetch(&self, timeout_in_seconds: f64) -> Result<()> {
let future = { let future = {
let lock = self.accounts.read().await; let lock = self.accounts.read().await;
@@ -308,11 +295,6 @@ impl CommandApi {
Ok(()) Ok(())
} }
/// Stops an ongoing `background_fetch()` call, making it return early
/// without waiting for the remaining transports or for the timeout.
///
/// The `AccountsBackgroundFetchDone` event is emitted as usual.
/// Does nothing if no background fetch is running.
async fn stop_background_fetch(&self) -> Result<()> { async fn stop_background_fetch(&self) -> Result<()> {
self.accounts.read().await.stop_background_fetch(); self.accounts.read().await.stop_background_fetch();
Ok(()) Ok(())
@@ -348,6 +330,12 @@ impl CommandApi {
} }
} }
/// Get the current push notification state.
async fn get_push_state(&self, account_id: u32) -> Result<JsonrpcNotifyState> {
let ctx = self.get_context(account_id).await?;
Ok(ctx.push_state().await.into())
}
/// Get the combined filesize of an account in bytes /// Get the combined filesize of an account in bytes
async fn get_account_file_size(&self, account_id: u32) -> Result<u64> { async fn get_account_file_size(&self, account_id: u32) -> Result<u64> {
let ctx = self.get_context(account_id).await?; let ctx = self.get_context(account_id).await?;
@@ -357,6 +345,21 @@ impl CommandApi {
Ok(dbfile + total_size) Ok(dbfile + total_size)
} }
/// Returns provider for the given domain.
///
/// This function looks up domain in offline database.
///
/// For compatibility, email address can be passed to this function
/// instead of the domain.
async fn get_provider_info(
&self,
_account_id: u32,
email: String,
) -> Result<Option<ProviderInfo>> {
let provider_info = get_provider_info(email.split('@').next_back().unwrap_or(""));
Ok(ProviderInfo::from_dc_type(provider_info))
}
/// Checks if the context is already configured. /// Checks if the context is already configured.
async fn is_configured(&self, account_id: u32) -> Result<bool> { async fn is_configured(&self, account_id: u32) -> Result<bool> {
let ctx = self.get_context(account_id).await?; let ctx = self.get_context(account_id).await?;
@@ -524,6 +527,7 @@ impl CommandApi {
/// from a server encoded in a QR code. /// from a server encoded in a QR code.
/// - [Self::list_transports()] to get a list of all configured transports. /// - [Self::list_transports()] to get a list of all configured transports.
/// - [Self::delete_transport()] to remove a transport. /// - [Self::delete_transport()] to remove a transport.
/// - [Self::set_transport_unpublished()] to set whether contacts see this transport.
async fn add_or_update_transport( async fn add_or_update_transport(
&self, &self,
account_id: u32, account_id: u32,
@@ -546,22 +550,26 @@ impl CommandApi {
ctx.add_transport_from_qr(&qr).await ctx.add_transport_from_qr(&qr).await
} }
/// Adds an initial transport on the chatmail relay that answers fastest /// Returns the list of all email accounts that are used as a transport in the current profile.
/// and lets the profile add further ones in the background. /// Use [Self::add_or_update_transport()] to add or change a transport
/// /// and [Self::delete_transport()] to delete a transport.
/// A `DCACCOUNT:` or `DCLOGIN:` `qr` code adds a single transport /// Use [Self::list_transports_ex()] to additionally query
/// while securejoin codes add the inviter's relays to the candidates. /// whether the transports are marked as 'unpublished'.
/// async fn list_transports(&self, account_id: u32) -> Result<Vec<EnteredLoginParam>> {
/// Does nothing if the profile already has a transport.
async fn init_transports(&self, account_id: u32, qr: Option<String>) -> Result<()> {
let ctx = self.get_context(account_id).await?; let ctx = self.get_context(account_id).await?;
ctx.init_transports(qr.as_deref()).await let res = ctx
.list_transports()
.await?
.into_iter()
.map(|t| t.param.into())
.collect();
Ok(res)
} }
/// Returns the list of all email accounts that are used as a transport in the current profile. /// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport /// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to remove a transport. /// and [Self::delete_transport()] to delete a transport.
async fn list_transports(&self, account_id: u32) -> Result<Vec<EnteredLoginParam>> { async fn list_transports_ex(&self, account_id: u32) -> Result<Vec<TransportListEntry>> {
let ctx = self.get_context(account_id).await?; let ctx = self.get_context(account_id).await?;
let res = ctx let res = ctx
.list_transports() .list_transports()
@@ -572,17 +580,33 @@ impl CommandApi {
Ok(res) Ok(res)
} }
/// Removes a transport. /// Removes the transport with the specified email address
/// UIs should call this function when the user removes a relay. /// (i.e. [EnteredLoginParam::addr]).
///
/// The last transport cannot be removed.
/// If the removed transport was the one used for sending,
/// another one is chosen automatically.
async fn delete_transport(&self, account_id: u32, addr: String) -> Result<()> { async fn delete_transport(&self, account_id: u32, addr: String) -> Result<()> {
let ctx = self.get_context(account_id).await?; let ctx = self.get_context(account_id).await?;
ctx.delete_transport(&addr).await ctx.delete_transport(&addr).await
} }
/// Change whether the transport is unpublished.
///
/// Unpublished transports are not advertised to contacts,
/// and self-sent messages are not sent there,
/// so that we don't cause extra messages to the corresponding inbox,
/// but can still receive messages from contacts who don't know our new transport addresses yet.
///
/// The default is false, but when the user updates from a version that didn't have this flag,
/// existing secondary transports are set to unpublished,
/// so that an existing transport address doesn't suddenly get spammed with a lot of messages.
async fn set_transport_unpublished(
&self,
account_id: u32,
addr: String,
unpublished: bool,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.set_transport_unpublished(&addr, unpublished).await
}
/// Signal an ongoing process to stop. /// Signal an ongoing process to stop.
async fn stop_ongoing_process(&self, account_id: u32) -> Result<()> { async fn stop_ongoing_process(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?; let ctx = self.get_context(account_id).await?;
@@ -835,7 +859,7 @@ impl CommandApi {
/// - The chat or the contact is **not blocked**, so new messages from the user/the group may appear as a contact request /// - The chat or the contact is **not blocked**, so new messages from the user/the group may appear as a contact request
/// and the user may create the chat again. /// and the user may create the chat again.
/// - **Groups are not left** - this would /// - **Groups are not left** - this would
/// be unexpected as (1) deleting a single chat also does not prevent new mails /// be unexpected as (1) deleting a normal chat also does not prevent new mails
/// from arriving, (2) leaving a group requires sending a message to /// from arriving, (2) leaving a group requires sending a message to
/// all group members - especially for groups not used for a longer time, this is /// all group members - especially for groups not used for a longer time, this is
/// really unexpected when deletion results in contacting all members again, /// really unexpected when deletion results in contacting all members again,
@@ -859,8 +883,6 @@ impl CommandApi {
/// Get QR code text that will offer a [SecureJoin](https://securejoin.delta.chat/) invitation. /// Get QR code text that will offer a [SecureJoin](https://securejoin.delta.chat/) invitation.
/// ///
/// To reset invitations, pass the link to `set_config_from_qr()`.
///
/// If `chat_id` is a group chat ID, SecureJoin QR code for the group is returned. /// If `chat_id` is a group chat ID, SecureJoin QR code for the group is returned.
/// If `chat_id` is unset, setup contact QR code is returned. /// If `chat_id` is unset, setup contact QR code is returned.
async fn get_chat_securejoin_qr_code( async fn get_chat_securejoin_qr_code(
@@ -874,19 +896,20 @@ impl CommandApi {
Ok(qr) Ok(qr)
} }
/// Get QR code (text and SVG) that will offer a SecureJoin invitation. /// Get QR code (text and SVG) that will offer a Setup-Contact or Verified-Group invitation.
/// The QR code is compatible to the OPENPGP4FPR format /// The QR code is compatible to the OPENPGP4FPR format
/// so that a basic fingerprint comparison also works e.g. with OpenKeychain. /// so that a basic fingerprint comparison also works e.g. with OpenKeychain.
/// ///
/// The scanning device will pass the scanned content to `checkQr()` then; /// The scanning device will pass the scanned content to `checkQr()` then;
/// if `checkQr()` returns `askVerifyContact` or `askVerifyGroup` /// if `checkQr()` returns `askVerifyContact` or `askVerifyGroup`
/// the securejoin protocol can be started using `secure_join()` /// an out-of-band-verification can be joined using `secure_join()`
/// ///
/// @deprecated as of 2026-03; use create_qr_svg(get_chat_securejoin_qr_code()) instead. /// @deprecated as of 2026-03; use create_qr_svg(get_chat_securejoin_qr_code()) instead.
/// ///
/// chat_id: If set to a group-chat-id, /// chat_id: If set to a group-chat-id,
/// the SecureJoin QR code for the group is returned. /// the Verified-Group-Invite protocol is offered in the QR code;
/// If not set, the setup contact QR code is returned. /// works for protected groups as well as for normal groups.
/// If not set, the Setup-Contact protocol is offered in the QR code.
/// See https://securejoin.delta.chat/ for details about both protocols. /// See https://securejoin.delta.chat/ for details about both protocols.
/// ///
/// return format: `[code, svg]` /// return format: `[code, svg]`
@@ -902,7 +925,7 @@ impl CommandApi {
Ok((qr, svg)) Ok((qr, svg))
} }
/// Continue the SecureJoin protocol /// Continue a Setup-Contact or Verified-Group-Invite protocol
/// started on another device with `get_chat_securejoin_qr_code_svg()`. /// started on another device with `get_chat_securejoin_qr_code_svg()`.
/// This function is typically called when `check_qr()` returns /// This function is typically called when `check_qr()` returns
/// type=AskVerifyContact or type=AskVerifyGroup. /// type=AskVerifyContact or type=AskVerifyGroup.
@@ -920,6 +943,7 @@ impl CommandApi {
/// to `check_qr()`. /// to `check_qr()`.
/// ///
/// **returns**: The chat ID of the joined chat, the UI may redirect to the this chat. /// **returns**: The chat ID of the joined chat, the UI may redirect to the this chat.
/// A returned chat ID does not guarantee that the chat is protected or the belonging contact is verified.
/// ///
async fn secure_join(&self, account_id: u32, qr: String) -> Result<u32> { async fn secure_join(&self, account_id: u32, qr: String) -> Result<u32> {
let ctx = self.get_context(account_id).await?; let ctx = self.get_context(account_id).await?;
@@ -985,6 +1009,8 @@ impl CommandApi {
/// If the group is already _promoted_ (any message was sent to the group), /// If the group is already _promoted_ (any message was sent to the group),
/// all group members are informed by a special status message that is sent automatically by this function. /// all group members are informed by a special status message that is sent automatically by this function.
/// ///
/// If the group has group protection enabled, only verified contacts can be added to the group.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent. /// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
async fn add_contact_to_chat( async fn add_contact_to_chat(
&self, &self,
@@ -998,7 +1024,7 @@ impl CommandApi {
/// Get the contact IDs belonging to a chat. /// Get the contact IDs belonging to a chat.
/// ///
/// - for single chats, the function always returns exactly one contact, /// - for normal chats, the function always returns exactly one contact,
/// DC_CONTACT_ID_SELF is returned only for SELF-chats. /// DC_CONTACT_ID_SELF is returned only for SELF-chats.
/// ///
/// - for group chats all members are returned, DC_CONTACT_ID_SELF is returned /// - for group chats all members are returned, DC_CONTACT_ID_SELF is returned
@@ -1055,9 +1081,7 @@ impl CommandApi {
/// Create a new unencrypted group chat. /// Create a new unencrypted group chat.
/// ///
/// Same as [`Self::create_group_chat`], but the chat is unencrypted and can only have /// Same as [`Self::create_group_chat`], but the chat is unencrypted and can only have
/// address-contacts. NB: Chats with similar names and the same members are merged on other /// address-contacts.
/// devices, but usually users don't create such chats and look up the existing one instead, so
/// chat split on the first device is acceptable.
async fn create_group_chat_unencrypted(&self, account_id: u32, name: String) -> Result<u32> { async fn create_group_chat_unencrypted(&self, account_id: u32, name: String) -> Result<u32> {
let ctx = self.get_context(account_id).await?; let ctx = self.get_context(account_id).await?;
chat::create_group_unencrypted(&ctx, &name) chat::create_group_unencrypted(&ctx, &name)
@@ -1326,7 +1350,7 @@ impl CommandApi {
/// The concrete action depends on the type of the chat and on the users settings /// The concrete action depends on the type of the chat and on the users settings
/// (dc_msgs_presented() may be a better name therefore, but well. :) /// (dc_msgs_presented() may be a better name therefore, but well. :)
/// ///
/// - For single chats, the IMAP state is updated, MDN is sent /// - For normal chats, the IMAP state is updated, MDN is sent
/// (if set_config()-options `mdns_enabled` is set) /// (if set_config()-options `mdns_enabled` is set)
/// and the internal state is changed to @ref DC_STATE_IN_SEEN to reflect these actions. /// and the internal state is changed to @ref DC_STATE_IN_SEEN to reflect these actions.
/// ///
@@ -1364,7 +1388,7 @@ impl CommandApi {
/// ///
/// * chat_id The chat ID of which the messages IDs should be queried. /// * chat_id The chat ID of which the messages IDs should be queried.
/// * _info_only: Deprecated, pass `false` here. /// * _info_only: Deprecated, pass `false` here.
/// * `add_daymarker` - If `true`, add day markers as `MsgId::DAYMARKER` to the result, /// * `add_daymarker` - If `true`, add day markers as `DC_MSG_ID_DAYMARKER` to the result,
/// e.g. [1234, 1237, 9, 1239]. The day marker timestamp is the midnight one for the /// e.g. [1234, 1237, 9, 1239]. The day marker timestamp is the midnight one for the
/// corresponding (following) day in the local timezone. /// corresponding (following) day in the local timezone.
async fn get_message_ids( async fn get_message_ids(
@@ -1375,7 +1399,7 @@ impl CommandApi {
add_daymarker: bool, add_daymarker: bool,
) -> Result<Vec<u32>> { ) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?; let ctx = self.get_context(account_id).await?;
let msg = get_chat_msgs_ext( let msg = get_chat_msgs_ex(
&ctx, &ctx,
ChatId::new(chat_id), ChatId::new(chat_id),
MessageListOptions { add_daymarker }, MessageListOptions { add_daymarker },
@@ -1386,7 +1410,7 @@ impl CommandApi {
.map(|chat_item| -> u32 { .map(|chat_item| -> u32 {
match chat_item { match chat_item {
deltachat::chat::ChatItem::Message { msg_id } => msg_id.to_u32(), deltachat::chat::ChatItem::Message { msg_id } => msg_id.to_u32(),
deltachat::chat::ChatItem::DayMarker { .. } => MsgId::DAYMARKER.to_u32(), deltachat::chat::ChatItem::DayMarker { .. } => DC_MSG_ID_DAYMARKER,
} }
}) })
.collect()) .collect())
@@ -1424,7 +1448,7 @@ impl CommandApi {
add_daymarker: bool, add_daymarker: bool,
) -> Result<Vec<JsonrpcMessageListItem>> { ) -> Result<Vec<JsonrpcMessageListItem>> {
let ctx = self.get_context(account_id).await?; let ctx = self.get_context(account_id).await?;
let msg = get_chat_msgs_ext( let msg = get_chat_msgs_ex(
&ctx, &ctx,
ChatId::new(chat_id), ChatId::new(chat_id),
MessageListOptions { add_daymarker }, MessageListOptions { add_daymarker },
@@ -1490,32 +1514,12 @@ impl CommandApi {
MessageNotificationInfo::from_msg_id(&ctx, MsgId::new(message_id)).await MessageNotificationInfo::from_msg_id(&ctx, MsgId::new(message_id)).await
} }
/// Sets the "pinned" state for a message.
async fn set_pinned_message_state(
&self,
account_id: u32,
message_id: u32,
pinned_state: bool,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
deltachat::pinned_messages::set_pinned_state(&ctx, MsgId::new(message_id), pinned_state)
.await
}
/// Returns all pinned messages of a chat.
async fn get_pinned_messages(&self, account_id: u32, chat_id: u32) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?;
let msg_ids =
deltachat::pinned_messages::get_pinned_messages(&ctx, ChatId::new(chat_id)).await?;
Ok(msg_ids.into_iter().map(|id| id.to_u32()).collect())
}
/// Delete messages. The messages are deleted on the current device and /// Delete messages. The messages are deleted on the current device and
/// on the IMAP server. /// on the IMAP server.
async fn delete_messages(&self, account_id: u32, message_ids: Vec<u32>) -> Result<()> { async fn delete_messages(&self, account_id: u32, message_ids: Vec<u32>) -> Result<()> {
let ctx = self.get_context(account_id).await?; let ctx = self.get_context(account_id).await?;
let msgs: Vec<MsgId> = message_ids.into_iter().map(MsgId::new).collect(); let msgs: Vec<MsgId> = message_ids.into_iter().map(MsgId::new).collect();
delete_msgs_ext(&ctx, &msgs, false).await delete_msgs_ex(&ctx, &msgs, false).await
} }
/// Delete messages. The messages are deleted on the current device, /// Delete messages. The messages are deleted on the current device,
@@ -1523,7 +1527,7 @@ impl CommandApi {
async fn delete_messages_for_all(&self, account_id: u32, message_ids: Vec<u32>) -> Result<()> { async fn delete_messages_for_all(&self, account_id: u32, message_ids: Vec<u32>) -> Result<()> {
let ctx = self.get_context(account_id).await?; let ctx = self.get_context(account_id).await?;
let msgs: Vec<MsgId> = message_ids.into_iter().map(MsgId::new).collect(); let msgs: Vec<MsgId> = message_ids.into_iter().map(MsgId::new).collect();
delete_msgs_ext(&ctx, &msgs, true).await delete_msgs_ex(&ctx, &msgs, true).await
} }
/// Get an informational text for a single message. The text is multiline and may /// Get an informational text for a single message. The text is multiline and may
@@ -1806,7 +1810,7 @@ impl CommandApi {
/// Get encryption info for a contact. /// Get encryption info for a contact.
/// Get a multi-line encryption info, containing your fingerprint and the /// Get a multi-line encryption info, containing your fingerprint and the
/// fingerprint of the contact, used e.g. to compare the fingerprints out-of-band. /// fingerprint of the contact, used e.g. to compare the fingerprints for a simple out-of-band verification.
async fn get_contact_encryption_info( async fn get_contact_encryption_info(
&self, &self,
account_id: u32, account_id: u32,
@@ -1888,7 +1892,7 @@ impl CommandApi {
// chat // chat
// --------------------------------------------- // ---------------------------------------------
/// Returns the [`ChatId`] for the single chat with `contact_id` if it exists. /// Returns the [`ChatId`] for the 1:1 chat with `contact_id` if it exists.
/// ///
/// If it does not exist, `None` is returned. /// If it does not exist, `None` is returned.
async fn get_chat_id_by_contact_id( async fn get_chat_id_by_contact_id(
@@ -2071,20 +2075,15 @@ impl CommandApi {
Ok(()) Ok(())
} }
/// Waits until all transports are idle or failed and no background work is left.
/// Never returns unless I/O is started. Must ONLY be used by tests.
async fn wait_for_all_work_done(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.wait_for_all_work_done().await;
Ok(())
}
/// Get the current connectivity, i.e. whether the device is connected to the IMAP server. /// Get the current connectivity, i.e. whether the device is connected to the IMAP server.
/// One of: /// One of:
/// - DC_CONNECTIVITY_NOT_CONNECTED (1000): Show e.g. the string "Not connected" or a red dot /// - DC_CONNECTIVITY_NOT_CONNECTED (1000-1999): Show e.g. the string "Not connected" or a red dot
/// - DC_CONNECTIVITY_CONNECTING (2000): Show e.g. the string "Connecting…" or a yellow dot /// - DC_CONNECTIVITY_CONNECTING (2000-2999): Show e.g. the string "Connecting…" or a yellow dot
/// - DC_CONNECTIVITY_WORKING (3000): Show e.g. the string "Getting new messages" or a spinning wheel /// - DC_CONNECTIVITY_WORKING (3000-3999): Show e.g. the string "Getting new messages" or a spinning wheel
/// - DC_CONNECTIVITY_CONNECTED (4000): Show e.g. the string "Connected" or a green dot /// - DC_CONNECTIVITY_CONNECTED (>=4000): Show e.g. the string "Connected" or a green dot
///
/// We don't use exact values but ranges here so that we can split up
/// states into multiple states in the future.
/// ///
/// Meant as a rough overview that can be shown /// Meant as a rough overview that can be shown
/// e.g. in the title of the main screen. /// e.g. in the title of the main screen.
@@ -2268,9 +2267,6 @@ impl CommandApi {
/// Get blob encoded as base64 from a webxdc message /// Get blob encoded as base64 from a webxdc message
/// ///
/// path is the path of the file within webxdc archive /// path is the path of the file within webxdc archive
///
/// If the file is `icon.png` or `icon.jpg`,
/// loading it may fail if dimensions are unexpectedly large.
async fn get_webxdc_blob( async fn get_webxdc_blob(
&self, &self,
account_id: u32, account_id: u32,
@@ -2281,7 +2277,7 @@ impl CommandApi {
let message = Message::load_from_db(&ctx, MsgId::new(instance_msg_id)).await?; let message = Message::load_from_db(&ctx, MsgId::new(instance_msg_id)).await?;
let blob = message.get_webxdc_blob(&ctx, &path).await?; let blob = message.get_webxdc_blob(&ctx, &path).await?;
use base64::{Engine as _, engine::general_purpose}; use base64::{engine::general_purpose, Engine as _};
Ok(general_purpose::STANDARD_NO_PAD.encode(blob)) Ok(general_purpose::STANDARD_NO_PAD.encode(blob))
} }
@@ -2451,7 +2447,6 @@ impl CommandApi {
} }
/// Returns reactions to the message. /// Returns reactions to the message.
/// `None` when there are no reactions.
async fn get_message_reactions( async fn get_message_reactions(
&self, &self,
account_id: u32, account_id: u32,
@@ -2783,48 +2778,6 @@ impl CommandApi {
Err(anyhow!("chat with id {chat_id} doesn't have draft message")) Err(anyhow!("chat with id {chat_id} doesn't have draft message"))
} }
} }
/// Get version information of a specific client and source
/// across all configured accounts and transports.
///
/// Returns the source with the highest `version_integer`.
/// If no matching version information is available at all, `None` is returned.
///
/// UIs shall call the function after a reasonable time after app start,
/// when most relays have reported the information they have, say 30 seconds.
/// After that, once a day.
/// (it is accepted if by the simple approach an update message is delayed.
/// an event was considered, but that seemed more complex for few benefit:
/// as we do not know if "late" relays will report "better" versions,
/// also there we would work with timeouts etc.)
///
/// If the reported `version_integer` is larger than the running app version,
/// the UI shall report to the user, that an update is available,
/// and, if possible, offer a direct update by the given URL.
///
/// Security note: consumers need to verify themselves
/// that downloaded app files are valid before installing them.
async fn get_app_version(
&self,
client_id: String,
source_id: String,
) -> Result<Option<JsonrpcAppSource>> {
let accounts = self.accounts.read().await;
Ok(
deltachat::appversions::get_app_version(&accounts, &client_id, &source_id)
.await?
.map(JsonrpcAppSource::from_core_type),
)
}
/// Returns true if all accounts have empty outgoing message queue.
///
/// This API is intended to be used by UIs
/// to request that operating system does not put the application in background
/// while there are still outgoing messages that are not sent out.
async fn is_sending_finished(&self) -> Result<bool> {
self.accounts.read().await.is_sending_finished().await
}
} }
// Helper functions (to prevent code duplication) // Helper functions (to prevent code duplication)
@@ -13,6 +13,7 @@ pub enum Account {
Configured { Configured {
id: u32, id: u32,
display_name: Option<String>, display_name: Option<String>,
addr: Option<String>,
// size: u32, // size: u32,
profile_image: Option<String>, profile_image: Option<String>,
color: String, color: String,
@@ -28,6 +29,7 @@ impl Account {
pub async fn from_context(ctx: &deltachat::context::Context, id: u32) -> Result<Self> { pub async fn from_context(ctx: &deltachat::context::Context, id: u32) -> Result<Self> {
if ctx.is_configured().await? { if ctx.is_configured().await? {
let display_name = ctx.get_config(Config::Displayname).await?; let display_name = ctx.get_config(Config::Displayname).await?;
let addr = ctx.get_config(Config::Addr).await?;
let profile_image = ctx.get_config(Config::Selfavatar).await?; let profile_image = ctx.get_config(Config::Selfavatar).await?;
let color = color_int_to_hex_string( let color = color_int_to_hex_string(
Contact::get_by_id(ctx, ContactId::SELF) Contact::get_by_id(ctx, ContactId::SELF)
@@ -39,6 +41,7 @@ impl Account {
Ok(Account::Configured { Ok(Account::Configured {
id, id,
display_name, display_name,
addr,
profile_image, profile_image,
color, color,
private_tag, private_tag,
@@ -1,31 +0,0 @@
use deltachat::appversions::AppSource;
use serde::{Deserialize, Serialize};
use typescript_type_def::TypeDef;
/// Version information of a single source of a client, eg. "gplay" or "fdroid".
#[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "AppSource", rename_all = "camelCase")]
pub struct JsonrpcAppSource {
/// Always increasing version number.
pub version_integer: u32,
/// Version string that should be shown to the user.
/// UI must not linkify the string
/// as it may be interpreted like a phone number or an IP address.
pub version_string: String,
/// Where to download that version.
/// Security note: consumers need to verify themselves
/// that downloaded app files are valid before installing them.
pub download_url: String,
}
impl JsonrpcAppSource {
pub fn from_core_type(source: AppSource) -> Self {
JsonrpcAppSource {
version_integer: source.version_integer,
version_string: source.version_string,
download_url: source.download_url,
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use deltachat::calls::{CallState, call_state}; use deltachat::calls::{call_state, CallState};
use deltachat::context::Context; use deltachat::context::Context;
use deltachat::message::MsgId; use deltachat::message::MsgId;
use serde::Serialize; use serde::Serialize;
+10 -13
View File
@@ -1,7 +1,7 @@
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use anyhow::{Context as _, Result, bail}; use anyhow::{bail, Context as _, Result};
use deltachat::chat::{self, ChatVisibility, get_chat_contacts, get_past_chat_contacts}; use deltachat::chat::{self, get_chat_contacts, get_past_chat_contacts, ChatVisibility};
use deltachat::chat::{Chat, ChatId}; use deltachat::chat::{Chat, ChatId};
use deltachat::constants::Chattype; use deltachat::constants::Chattype;
use deltachat::contact::{Contact, ContactId}; use deltachat::contact::{Contact, ContactId};
@@ -9,8 +9,6 @@ use deltachat::context::Context;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use typescript_type_def::TypeDef; use typescript_type_def::TypeDef;
use crate::api::types::contact::ContactFreshness;
use super::color_int_to_hex_string; use super::color_int_to_hex_string;
#[derive(Serialize, TypeDef, schemars::JsonSchema)] #[derive(Serialize, TypeDef, schemars::JsonSchema)]
@@ -71,7 +69,7 @@ pub struct FullChat {
is_muted: bool, is_muted: bool,
ephemeral_timer: u32, ephemeral_timer: u32,
can_send: bool, can_send: bool,
freshness: ContactFreshness, was_seen_recently: bool,
mailing_list_address: Option<String>, mailing_list_address: Option<String>,
} }
@@ -94,17 +92,16 @@ impl FullChat {
let can_send = chat.can_send(context).await?; let can_send = chat.can_send(context).await?;
let freshness = if chat.get_type() == Chattype::Single { let was_seen_recently = if chat.get_type() == Chattype::Single {
match contact_ids.first() { match contact_ids.first() {
Some(contact) => Contact::get_by_id(context, *contact) Some(contact) => Contact::get_by_id(context, *contact)
.await .await
.context("failed to load contact for get_freshness")? .context("failed to load contact for was_seen_recently")?
.get_freshness() .was_seen_recently(),
.into(), None => false,
None => ContactFreshness::Normal,
} }
} else { } else {
ContactFreshness::Normal false
}; };
let mailing_list_address = chat.get_mailinglist_addr().map(|s| s.to_string()); let mailing_list_address = chat.get_mailinglist_addr().map(|s| s.to_string());
@@ -129,7 +126,7 @@ impl FullChat {
is_muted: chat.is_muted(), is_muted: chat.is_muted(),
ephemeral_timer, ephemeral_timer,
can_send, can_send,
freshness, was_seen_recently,
mailing_list_address, mailing_list_address,
}) })
} }
@@ -140,7 +137,7 @@ impl FullChat {
/// - fresh_message_counter /// - fresh_message_counter
/// - ephemeral_timer /// - ephemeral_timer
/// - self_in_group /// - self_in_group
/// - freshness /// - was_seen_recently
/// - can_send /// - can_send
/// ///
/// used when you only need the basic metadata of a chat like type, name, profile picture /// used when you only need the basic metadata of a chat like type, name, profile picture
+17 -12
View File
@@ -4,15 +4,13 @@ use deltachat::chatlist::get_last_message_for_chat;
use deltachat::constants::*; use deltachat::constants::*;
use deltachat::contact::Contact; use deltachat::contact::Contact;
use deltachat::{ use deltachat::{
chat::{ChatVisibility, get_chat_contacts}, chat::{get_chat_contacts, ChatVisibility},
chatlist::Chatlist, chatlist::Chatlist,
}; };
use num_traits::cast::ToPrimitive; use num_traits::cast::ToPrimitive;
use serde::Serialize; use serde::Serialize;
use typescript_type_def::TypeDef; use typescript_type_def::TypeDef;
use crate::api::types::contact::ContactFreshness;
use super::chat::JsonrpcChatType; use super::chat::JsonrpcChatType;
use super::color_int_to_hex_string; use super::color_int_to_hex_string;
use super::message::MessageViewtype; use super::message::MessageViewtype;
@@ -31,6 +29,8 @@ pub enum ChatListItemFetchResult {
summary_text1: String, summary_text1: String,
summary_text2: String, summary_text2: String,
summary_status: u32, summary_status: u32,
/// showing preview if last chat message is image
summary_preview_image: Option<String>,
/// True if the chat is encrypted. /// True if the chat is encrypted.
/// This means that all messages in the chat are encrypted, /// This means that all messages in the chat are encrypted,
@@ -68,7 +68,7 @@ pub enum ChatListItemFetchResult {
is_contact_request: bool, is_contact_request: bool,
/// contact id if this is a dm chat (for view profile entry in context menu) /// contact id if this is a dm chat (for view profile entry in context menu)
dm_chat_contact: Option<u32>, dm_chat_contact: Option<u32>,
freshness: ContactFreshness, was_seen_recently: bool,
last_message_type: Option<MessageViewtype>, last_message_type: Option<MessageViewtype>,
last_message_id: Option<u32>, last_message_id: Option<u32>,
}, },
@@ -101,6 +101,8 @@ pub(crate) async fn get_chat_list_item_by_id(
let summary_text1 = summary.prefix.map_or_else(String::new, |s| s.to_string()); let summary_text1 = summary.prefix.map_or_else(String::new, |s| s.to_string());
let summary_text2 = summary.text.to_owned(); let summary_text2 = summary.text.to_owned();
let summary_preview_image = summary.thumbnail_path;
let visibility = chat.get_visibility(); let visibility = chat.get_visibility();
let avatar_path = chat let avatar_path = chat
@@ -125,20 +127,22 @@ pub(crate) async fn get_chat_list_item_by_id(
None => (None, None), None => (None, None),
}; };
let (dm_chat_contact, freshness) = if chat.get_type() == Chattype::Single { let (dm_chat_contact, was_seen_recently) = if chat.get_type() == Chattype::Single {
let chat_contacts = get_chat_contacts(ctx, chat_id).await?; let chat_contacts = get_chat_contacts(ctx, chat_id).await?;
let contact = chat_contacts.first(); let contact = chat_contacts.first();
let freshness = match contact { let was_seen_recently = match contact {
Some(contact) => Contact::get_by_id(ctx, *contact) Some(contact) => Contact::get_by_id(ctx, *contact)
.await .await
.context("contact")? .context("contact")?
.get_freshness() .was_seen_recently(),
.into(), None => false,
None => ContactFreshness::Normal,
}; };
(contact.map(|contact_id| contact_id.to_u32()), freshness) (
contact.map(|contact_id| contact_id.to_u32()),
was_seen_recently,
)
} else { } else {
(None, ContactFreshness::Normal) (None, false)
}; };
let color = color_int_to_hex_string(chat.get_color(ctx).await?); let color = color_int_to_hex_string(chat.get_color(ctx).await?);
@@ -153,6 +157,7 @@ pub(crate) async fn get_chat_list_item_by_id(
summary_text1, summary_text1,
summary_text2, 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_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_encrypted: chat.is_encrypted(ctx).await?, is_encrypted: chat.is_encrypted(ctx).await?,
is_group: chat.get_type() == Chattype::Group, is_group: chat.get_type() == Chattype::Group,
fresh_message_counter, fresh_message_counter,
@@ -165,7 +170,7 @@ pub(crate) async fn get_chat_list_item_by_id(
is_muted: chat.is_muted(), is_muted: chat.is_muted(),
is_contact_request: chat.is_contact_request(), is_contact_request: chat.is_contact_request(),
dm_chat_contact, dm_chat_contact,
freshness, was_seen_recently,
last_message_type: message_type, last_message_type: message_type,
last_message_id: last_msgid.map(|id| id.to_u32()), last_message_id: last_msgid.map(|id| id.to_u32()),
}) })
+45 -24
View File
@@ -1,5 +1,4 @@
use anyhow::Result; use anyhow::Result;
use deltachat::contact;
use deltachat::context::Context; use deltachat::context::Context;
use deltachat::key::{DcKey, SignedPublicKey}; use deltachat::key::{DcKey, SignedPublicKey};
use serde::Serialize; use serde::Serialize;
@@ -7,27 +6,6 @@ use typescript_type_def::TypeDef;
use super::color_int_to_hex_string; use super::color_int_to_hex_string;
/// Freshness of a contact, based on when it was last seen.
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
pub enum ContactFreshness {
/// Contact shall not be highlighted.
Normal,
/// Contact was seen recently.
RecentlySeen,
/// Contact was not seen for a long time.
Old,
}
impl From<contact::Freshness> for ContactFreshness {
fn from(freshness: contact::Freshness) -> Self {
match freshness {
contact::Freshness::Normal => ContactFreshness::Normal,
contact::Freshness::RecentlySeen => ContactFreshness::RecentlySeen,
contact::Freshness::Old => ContactFreshness::Old,
}
}
}
#[derive(Serialize, TypeDef, schemars::JsonSchema)] #[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "Contact", rename_all = "camelCase")] #[serde(rename = "Contact", rename_all = "camelCase")]
pub struct ContactObject { pub struct ContactObject {
@@ -39,6 +17,7 @@ pub struct ContactObject {
id: u32, id: u32,
name: String, name: String,
profile_image: Option<String>, // BLOBS profile_image: Option<String>, // BLOBS
name_and_addr: String,
is_blocked: bool, is_blocked: bool,
/// Is the contact a key contact. /// Is the contact a key contact.
@@ -52,9 +31,40 @@ pub struct ContactObject {
/// e.g. if we just scanned the fingerprint from a QR code. /// e.g. if we just scanned the fingerprint from a QR code.
e2ee_avail: bool, e2ee_avail: bool,
/// True if the contact
/// can be added to protected chats
/// because SELF and contact have verified their fingerprints in both directions.
///
/// See [`Self::verifier_id`]/`Contact.verifierId` for a guidance how to display these information.
is_verified: bool,
/// The contact ID that verified a contact.
///
/// As verifier may be unknown,
/// use [`Self::is_verified`]/`Contact.isVerified` to check if a contact can be added to a protected chat.
///
/// UI should display the information in the contact's profile as follows:
///
/// - If `verifierId` != 0,
/// display text "Introduced by ..."
/// with the name of the contact.
/// Prefix the text by a green checkmark.
///
/// - If `verifierId` == 0 and `isVerified` != 0,
/// display "Introduced" prefixed by a green checkmark.
///
/// - if `verifierId` == 0 and `isVerified` == 0,
/// display nothing
///
/// This contains the contact ID of the verifier.
/// If it is `DC_CONTACT_ID_SELF`, we verified the contact ourself.
/// If it is None/Null, we don't have verifier information or
/// the contact is not verified.
verifier_id: Option<u32>,
/// the contact's last seen timestamp /// the contact's last seen timestamp
last_seen: i64, last_seen: i64,
freshness: ContactFreshness, was_seen_recently: bool,
/// If the contact is a bot. /// If the contact is a bot.
is_bot: bool, is_bot: bool,
@@ -69,6 +79,14 @@ impl ContactObject {
Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()), Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()),
None => None, None => None,
}; };
let is_verified = contact.is_verified(context).await?;
let verifier_id = contact
.get_verifier_id(context)
.await?
.flatten()
.map(|contact_id| contact_id.to_u32());
Ok(ContactObject { Ok(ContactObject {
address: contact.get_addr().to_owned(), address: contact.get_addr().to_owned(),
color: color_int_to_hex_string(contact.get_color()), color: color_int_to_hex_string(contact.get_color()),
@@ -78,11 +96,14 @@ impl ContactObject {
id: contact.id.to_u32(), id: contact.id.to_u32(),
name: contact.get_name().to_owned(), name: contact.get_name().to_owned(),
profile_image, //BLOBS profile_image, //BLOBS
name_and_addr: contact.get_name_n_addr(),
is_blocked: contact.is_blocked(), is_blocked: contact.is_blocked(),
is_key_contact: contact.is_key_contact(), is_key_contact: contact.is_key_contact(),
e2ee_avail: contact.e2ee_avail(context).await?, e2ee_avail: contact.e2ee_avail(context).await?,
is_verified,
verifier_id,
last_seen: contact.last_seen(), last_seen: contact.last_seen(),
freshness: contact.get_freshness().into(), was_seen_recently: contact.was_seen_recently(),
is_bot: contact.is_bot(), is_bot: contact.is_bot(),
}) })
} }
+13 -39
View File
@@ -44,6 +44,9 @@ pub enum EventType {
/// Emitted when an IMAP message has been marked as deleted /// Emitted when an IMAP message has been marked as deleted
ImapMessageDeleted { msg: String }, ImapMessageDeleted { msg: String },
/// Emitted when an IMAP message has been moved
ImapMessageMoved { msg: String },
/// Emitted before going into IDLE on the Inbox folder. /// Emitted before going into IDLE on the Inbox folder.
ImapInboxIdle, ImapInboxIdle,
@@ -200,17 +203,6 @@ pub enum EventType {
msg_id: u32, msg_id: u32,
}, },
/// Like [`EventType::MsgRead`], but also fires on subsequent MDNs,
/// if there are multiple receivers, i.e. in groups and channels.
#[serde(rename_all = "camelCase")]
MsgReadCountChanged {
/// ID of the chat which the message belongs to.
chat_id: u32,
/// ID of the message that was read.
msg_id: u32,
},
/// A single message was deleted. /// A single message was deleted.
/// ///
/// This event means that the message will no longer appear in the messagelist. /// This event means that the message will no longer appear in the messagelist.
@@ -258,15 +250,6 @@ pub enum EventType {
chat_id: u32, chat_id: u32,
}, },
/// The list of pinned messages for the chat has changed.
///
/// Some message got pinned, or pinned message is unpinned or deleted.
#[serde(rename_all = "camelCase")]
PinnedMessagesChanged {
/// ID of the chat where the list of pinned messages changed.
chat_id: u32,
},
/// Contact(s) created, renamed, blocked or deleted. /// Contact(s) created, renamed, blocked or deleted.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
ContactsChanged { ContactsChanged {
@@ -343,7 +326,8 @@ pub enum EventType {
contact_id: u32, contact_id: u32,
/// Progress as: /// Progress as:
/// 400=vg-/vc-request-with-auth sent, typically shown as "introducing myself." /// 400=vg-/vc-request-with-auth sent, typically shown as "alice@addr verified, introducing myself."
/// (Bob has verified alice and waits until Alice does the same for him)
/// 1000=vg-member-added/vc-contact-confirm received /// 1000=vg-member-added/vc-contact-confirm received
progress: u16, progress: u16,
}, },
@@ -399,15 +383,11 @@ pub enum EventType {
msg_id: u32, msg_id: u32,
}, },
/// Tells that a background fetch call is done: /// Tells that the Background fetch was completed (or timed out).
/// the fetch completed, timed out, was stopped or was not started. /// This event acts as a marker, when you reach this event you can be sure
/// that all events emitted during the background fetch were processed.
/// ///
/// For the call that started the fetch, this event acts as a marker: /// This event is only emitted by the account manager
/// all events emitted during the fetch were processed once it is reached.
/// A call made while another background fetch is running gets the event immediately,
/// and the running fetch keeps emitting events until its own marker.
///
/// This event is only emitted by the account manager.
AccountsBackgroundFetchDone, AccountsBackgroundFetchDone,
/// Inform that set of chats or the order of the chats in the chatlist has changed. /// Inform that set of chats or the order of the chats in the chatlist has changed.
/// ///
@@ -487,9 +467,9 @@ pub enum EventType {
/// ///
/// UI should update the list. /// UI should update the list.
/// ///
/// The event is emitted on the device modifying /// This event is emitted when transport
/// the transports as well as on other devices /// synchronization messages arrives,
/// applying the synced change. /// but not when the UI modifies the transport list by itself.
TransportsModified, TransportsModified,
} }
@@ -502,6 +482,7 @@ impl From<CoreEventType> for EventType {
CoreEventType::ImapConnected(msg) => ImapConnected { msg }, CoreEventType::ImapConnected(msg) => ImapConnected { msg },
CoreEventType::SmtpMessageSent(msg) => SmtpMessageSent { msg }, CoreEventType::SmtpMessageSent(msg) => SmtpMessageSent { msg },
CoreEventType::ImapMessageDeleted(msg) => ImapMessageDeleted { msg }, CoreEventType::ImapMessageDeleted(msg) => ImapMessageDeleted { msg },
CoreEventType::ImapMessageMoved(msg) => ImapMessageMoved { msg },
CoreEventType::ImapInboxIdle => ImapInboxIdle, CoreEventType::ImapInboxIdle => ImapInboxIdle,
CoreEventType::NewBlobFile(file) => NewBlobFile { file }, CoreEventType::NewBlobFile(file) => NewBlobFile { file },
CoreEventType::DeletedBlobFile(file) => DeletedBlobFile { file }, CoreEventType::DeletedBlobFile(file) => DeletedBlobFile { file },
@@ -521,9 +502,6 @@ impl From<CoreEventType> for EventType {
msg_id: msg_id.to_u32(), msg_id: msg_id.to_u32(),
contact_id: contact_id.to_u32(), contact_id: contact_id.to_u32(),
}, },
CoreEventType::PinnedMessagesChanged { chat_id } => PinnedMessagesChanged {
chat_id: chat_id.to_u32(),
},
CoreEventType::IncomingReaction { CoreEventType::IncomingReaction {
chat_id, chat_id,
contact_id, contact_id,
@@ -568,10 +546,6 @@ impl From<CoreEventType> for EventType {
chat_id: chat_id.to_u32(), chat_id: chat_id.to_u32(),
msg_id: msg_id.to_u32(), msg_id: msg_id.to_u32(),
}, },
CoreEventType::MsgReadCountChanged { chat_id, msg_id } => MsgReadCountChanged {
chat_id: chat_id.to_u32(),
msg_id: msg_id.to_u32(),
},
CoreEventType::MsgDeleted { chat_id, msg_id } => MsgDeleted { CoreEventType::MsgDeleted { chat_id, msg_id } => MsgDeleted {
chat_id: chat_id.to_u32(), chat_id: chat_id.to_u32(),
msg_id: msg_id.to_u32(), msg_id: msg_id.to_u32(),
+1 -1
View File
@@ -16,7 +16,7 @@ pub struct HttpResponse {
impl From<CoreHttpResponse> for HttpResponse { impl From<CoreHttpResponse> for HttpResponse {
fn from(response: CoreHttpResponse) -> Self { fn from(response: CoreHttpResponse) -> Self {
use base64::{Engine as _, engine::general_purpose}; use base64::{engine::general_purpose, Engine as _};
let blob = general_purpose::STANDARD_NO_PAD.encode(response.blob); let blob = general_purpose::STANDARD_NO_PAD.encode(response.blob);
let mimetype = response.mimetype; let mimetype = response.mimetype;
let encoding = response.encoding; let encoding = response.encoding;
+28 -3
View File
@@ -4,6 +4,16 @@ use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use yerpc::TypeDef; use yerpc::TypeDef;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct TransportListEntry {
/// The login data entered by the user.
pub param: EnteredLoginParam,
/// Whether this transport is set to 'unpublished'.
/// See `set_transport_unpublished` / `setTransportUnpublished` for details.
pub is_unpublished: bool,
}
/// Login parameters entered by the user. /// Login parameters entered by the user.
/// ///
/// Usually it will be enough to only set `addr` and `password`, /// Usually it will be enough to only set `addr` and `password`,
@@ -56,6 +66,19 @@ pub struct EnteredLoginParam {
/// invalid hostnames. /// invalid hostnames.
/// Default: Automatic /// Default: Automatic
pub certificate_checks: Option<EnteredCertificateChecks>, pub certificate_checks: Option<EnteredCertificateChecks>,
/// If true, login via OAUTH2 (not recommended anymore).
/// Default: false
pub oauth2: Option<bool>,
}
impl From<dc::TransportListEntry> for TransportListEntry {
fn from(transport: dc::TransportListEntry) -> Self {
TransportListEntry {
param: transport.param.into(),
is_unpublished: transport.is_unpublished,
}
}
} }
impl From<dc::EnteredLoginParam> for EnteredLoginParam { impl From<dc::EnteredLoginParam> for EnteredLoginParam {
@@ -77,6 +100,7 @@ impl From<dc::EnteredLoginParam> for EnteredLoginParam {
smtp_user: param.smtp.user.into_option(), smtp_user: param.smtp.user.into_option(),
smtp_password: param.smtp.password.into_option(), smtp_password: param.smtp.password.into_option(),
certificate_checks: certificate_checks.into_option(), certificate_checks: certificate_checks.into_option(),
oauth2: param.oauth2.into_option(),
} }
} }
} }
@@ -103,7 +127,7 @@ impl TryFrom<EnteredLoginParam> for dc::EnteredLoginParam {
password: param.smtp_password.unwrap_or_default(), password: param.smtp_password.unwrap_or_default(),
}, },
certificate_checks: param.certificate_checks.unwrap_or_default().into(), certificate_checks: param.certificate_checks.unwrap_or_default().into(),
oauth2: false, oauth2: param.oauth2.unwrap_or_default(),
}) })
} }
} }
@@ -150,8 +174,9 @@ impl From<Socket> for dc::Socket {
#[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema, Default, PartialEq)] #[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema, Default, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum EnteredCertificateChecks { pub enum EnteredCertificateChecks {
/// `Automatic` means strict certificate checks, /// `Automatic` means that provider database setting should be taken.
/// unless a legacy-domain override disables them. /// If there is no provider database setting for certificate checks,
/// check certificates strictly.
#[default] #[default]
Automatic, Automatic,
+5 -13
View File
@@ -103,9 +103,6 @@ pub struct MessageObject {
saved_message_id: Option<u32>, saved_message_id: Option<u32>,
is_pinned: bool,
/// `None` when there are no reactions.
reactions: Option<JsonrpcReactions>, reactions: Option<JsonrpcReactions>,
vcard_contact: Option<VcardContact>, vcard_contact: Option<VcardContact>,
@@ -266,7 +263,6 @@ impl MessageObject {
.await? .await?
.map(|id| id.to_u32()), .map(|id| id.to_u32()),
is_pinned: message.is_pinned(),
reactions, reactions,
vcard_contact: vcard_contacts.first().cloned(), vcard_contact: vcard_contacts.first().cloned(),
@@ -394,11 +390,11 @@ pub enum SystemMessageType {
LocationOnly, LocationOnly,
InvalidUnencryptedMail, InvalidUnencryptedMail,
/// Single chats info message telling that SecureJoin has started and the user should wait for it /// 1:1 chats info message telling that SecureJoin has started and the user should wait for it
/// to complete. /// to complete.
SecurejoinWait, SecurejoinWait,
/// Single chats info message telling that SecureJoin is still running, but the user may already /// 1:1 chats info message telling that SecureJoin is still running, but the user may already
/// send messages. /// send messages.
SecurejoinWaitTimeout, SecurejoinWaitTimeout,
@@ -429,8 +425,6 @@ pub enum SystemMessageType {
CallAccepted, CallAccepted,
CallEnded, CallEnded,
MessagePinned,
MessageUnpinned,
} }
impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType { impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
@@ -460,8 +454,6 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout, SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout,
SystemMessage::CallAccepted => SystemMessageType::CallAccepted, SystemMessage::CallAccepted => SystemMessageType::CallAccepted,
SystemMessage::CallEnded => SystemMessageType::CallEnded, SystemMessage::CallEnded => SystemMessageType::CallEnded,
SystemMessage::MessagePinned => SystemMessageType::MessagePinned,
SystemMessage::MessageUnpinned => SystemMessageType::MessageUnpinned,
} }
} }
} }
@@ -734,9 +726,9 @@ impl From<deltachat::ephemeral::Timer> for EphemeralTimer {
fn from(value: deltachat::ephemeral::Timer) -> Self { fn from(value: deltachat::ephemeral::Timer) -> Self {
match value { match value {
deltachat::ephemeral::Timer::Disabled => EphemeralTimer::Disabled, deltachat::ephemeral::Timer::Disabled => EphemeralTimer::Disabled,
deltachat::ephemeral::Timer::Enabled { duration } => EphemeralTimer::Enabled { deltachat::ephemeral::Timer::Enabled { duration } => {
duration: duration.get(), EphemeralTimer::Enabled { duration }
}, }
} }
} }
} }
+2 -1
View File
@@ -1,5 +1,4 @@
pub mod account; pub mod account;
pub mod appversions;
pub mod calls; pub mod calls;
pub mod chat; pub mod chat;
pub mod chat_list; pub mod chat_list;
@@ -9,6 +8,8 @@ pub mod http;
pub mod location; pub mod location;
pub mod login_param; pub mod login_param;
pub mod message; pub mod message;
pub mod notify_state;
pub mod provider_info;
pub mod qr; pub mod qr;
pub mod reactions; pub mod reactions;
pub mod webxdc; pub mod webxdc;
@@ -0,0 +1,26 @@
use deltachat::push::NotifyState;
use serde::Serialize;
use typescript_type_def::TypeDef;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "NotifyState")]
pub enum JsonrpcNotifyState {
/// Not subscribed to push notifications.
NotConnected,
/// Subscribed to heartbeat push notifications.
Heartbeat,
/// Subscribed to push notifications for new messages.
Connected,
}
impl From<NotifyState> for JsonrpcNotifyState {
fn from(state: NotifyState) -> Self {
match state {
NotifyState::NotConnected => Self::NotConnected,
NotifyState::Heartbeat => Self::Heartbeat,
NotifyState::Connected => Self::Connected,
}
}
}
@@ -0,0 +1,25 @@
use deltachat::provider::Provider;
use num_traits::cast::ToPrimitive;
use serde::Serialize;
use typescript_type_def::TypeDef;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProviderInfo {
/// Unique ID, corresponding to provider database filename.
pub id: String,
pub before_login_hint: String,
pub overview_page: String,
pub status: u32, // in reality this is an enum, but for simplicity and because it gets converted into a number anyway, we use an u32 here.
}
impl ProviderInfo {
pub fn from_dc_type(provider: Option<&Provider>) -> Option<Self> {
provider.map(|p| ProviderInfo {
id: p.id.to_owned(),
before_login_hint: p.before_login_hint.to_owned(),
overview_page: p.overview_page.to_owned(),
status: p.status.to_u32().unwrap(),
})
}
}
+17 -5
View File
@@ -7,7 +7,7 @@ use typescript_type_def::TypeDef;
#[serde(rename = "Qr", rename_all = "camelCase")] #[serde(rename = "Qr", rename_all = "camelCase")]
#[serde(tag = "kind")] #[serde(tag = "kind")]
pub enum QrObject { pub enum QrObject {
/// Ask the user whether to start chatting with the contact. /// Ask the user whether to verify the contact.
/// ///
/// If the user agrees, pass this QR code to [`crate::securejoin::join_securejoin`]. /// If the user agrees, pass this QR code to [`crate::securejoin::join_securejoin`].
AskVerifyContact { AskVerifyContact {
@@ -61,13 +61,23 @@ pub enum QrObject {
/// Whether the inviter supports the new Securejoin v3 protocol /// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool, is_v3: bool,
}, },
/// Contact fingerprint matches. /// Contact fingerprint is verified.
/// ///
/// Ask the user if they want to start chatting. /// Ask the user if they want to start chatting.
FprOk { FprOk {
/// Contact ID. /// Contact ID.
contact_id: u32, contact_id: u32,
}, },
/// Scanned fingerprint does not match the last seen fingerprint.
FprMismatch {
/// Contact ID.
contact_id: Option<u32>,
},
/// The scanned QR code contains a fingerprint but no e-mail address.
FprWithoutAddr {
/// Key fingerprint.
fingerprint: String,
},
/// Ask the user if they want to create an account on the given domain. /// Ask the user if they want to create an account on the given domain.
Account { Account {
/// Server domain name. /// Server domain name.
@@ -226,7 +236,6 @@ impl From<Qr> for QrObject {
invitenumber, invitenumber,
authcode, authcode,
is_v3, is_v3,
..
} => { } => {
let contact_id = contact_id.to_u32(); let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable(); let fingerprint = fingerprint.human_readable();
@@ -246,7 +255,6 @@ impl From<Qr> for QrObject {
invitenumber, invitenumber,
authcode, authcode,
is_v3, is_v3,
..
} => { } => {
let contact_id = contact_id.to_u32(); let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable(); let fingerprint = fingerprint.human_readable();
@@ -268,7 +276,6 @@ impl From<Qr> for QrObject {
authcode, authcode,
invitenumber, invitenumber,
is_v3, is_v3,
..
} => { } => {
let contact_id = contact_id.to_u32(); let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable(); let fingerprint = fingerprint.human_readable();
@@ -286,6 +293,11 @@ impl From<Qr> for QrObject {
let contact_id = contact_id.to_u32(); let contact_id = contact_id.to_u32();
QrObject::FprOk { contact_id } QrObject::FprOk { contact_id }
} }
Qr::FprMismatch { contact_id } => {
let contact_id = contact_id.map(|contact_id| contact_id.to_u32());
QrObject::FprMismatch { contact_id }
}
Qr::FprWithoutAddr { fingerprint } => QrObject::FprWithoutAddr { fingerprint },
Qr::Account { domain } => QrObject::Account { domain }, Qr::Account { domain } => QrObject::Account { domain },
Qr::Backup2 { Qr::Backup2 {
ref node_addr, ref node_addr,
+18 -14
View File
@@ -1,5 +1,6 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use deltachat::contact::ContactId;
use deltachat::reaction::Reactions; use deltachat::reaction::Reactions;
use serde::Serialize; use serde::Serialize;
use typescript_type_def::TypeDef; use typescript_type_def::TypeDef;
@@ -23,11 +24,8 @@ pub struct JsonrpcReaction {
#[serde(rename = "Reactions", rename_all = "camelCase")] #[serde(rename = "Reactions", rename_all = "camelCase")]
pub struct JsonrpcReactions { pub struct JsonrpcReactions {
/// Map from a contact to it's reaction to message. /// Map from a contact to it's reaction to message.
///
/// There is only a single reaction per contact, /// There is only a single reaction per contact,
/// but this contains a list of reactions for historical reasons. /// but this contains a list of reactions for historical reasons.
///
/// For channels subscribers, this map is empty or contains `ContactId::SELF` only.
reactions_by_contact: BTreeMap<u32, Vec<String>>, reactions_by_contact: BTreeMap<u32, Vec<String>>,
/// Unique reactions and their count, sorted in descending order. /// Unique reactions and their count, sorted in descending order.
reactions: Vec<JsonrpcReaction>, reactions: Vec<JsonrpcReaction>,
@@ -36,24 +34,30 @@ pub struct JsonrpcReactions {
impl From<Reactions> for JsonrpcReactions { impl From<Reactions> for JsonrpcReactions {
fn from(reactions: Reactions) -> Self { fn from(reactions: Reactions) -> Self {
let reactions_by_contact: BTreeMap<u32, Vec<String>> = reactions let reactions_by_contact: BTreeMap<u32, Vec<String>> = reactions
.by_contact
.iter() .iter()
.map(|(key, value)| (key.to_u32(), vec![value.as_str().to_string()])) .map(|(key, value)| (key.to_u32(), vec![value.as_str().to_string()]))
.collect(); .collect();
let self_reaction = reactions_by_contact.get(&ContactId::SELF.to_u32());
let reactions = reactions let mut reactions_v = Vec::new();
.frequencies for (emoji, count) in reactions.emoji_sorted_by_frequency() {
.into_iter() let is_from_self = if let Some(self_reaction) = self_reaction {
.map(|entry| JsonrpcReaction { self_reaction.contains(&emoji)
emoji: entry.reaction.as_str().to_string(), } else {
count: entry.count, false
is_from_self: entry.is_from_self, };
})
.collect(); let reaction = JsonrpcReaction {
emoji,
count,
is_from_self,
};
reactions_v.push(reaction)
}
JsonrpcReactions { JsonrpcReactions {
reactions_by_contact, reactions_by_contact,
reactions, reactions: reactions_v,
} }
} }
} }
@@ -54,5 +54,5 @@
}, },
"type": "module", "type": "module",
"types": "dist/deltachat.d.ts", "types": "dist/deltachat.d.ts",
"version": "2.64.0-dev" "version": "2.51.0-dev"
} }
@@ -44,6 +44,7 @@ const constants = data
key.startsWith("DC_CERTCK_") || key.startsWith("DC_CERTCK_") ||
key.startsWith("DC_SOCKET_") || key.startsWith("DC_SOCKET_") ||
key.startsWith("DC_LP_AUTH_") || key.startsWith("DC_LP_AUTH_") ||
key.startsWith("DC_PUSH_") ||
key.startsWith("DC_TEXT1_") || key.startsWith("DC_TEXT1_") ||
key.startsWith("DC_CHAT_TYPE") key.startsWith("DC_CHAT_TYPE")
); );
@@ -148,6 +148,23 @@ describe("online tests", function () {
expect(message2.text).equal("super secret message"); expect(message2.text).equal("super secret message");
expect(message2.showPadlock).equal(true); expect(message2.showPadlock).equal(true);
}); });
it("get provider info for example.com", async () => {
const acc = await dc.rpc.addAccount();
const info = await dc.rpc.getProviderInfo(acc, "example.com");
expect(info).to.be.not.null;
expect(info?.overviewPage).to.equal(
"https://providers.delta.chat/example-com",
);
expect(info?.status).to.equal(3);
});
it("get provider info - domain and email should give same result", async () => {
const acc = await dc.rpc.addAccount();
const info_domain = await dc.rpc.getProviderInfo(acc, "example.com");
const info_email = await dc.rpc.getProviderInfo(acc, "hi@example.com");
expect(info_email).to.deep.equal(info_domain);
});
}); });
async function waitForEvent<T extends DcEvent["kind"]>( async function waitForEvent<T extends DcEvent["kind"]>(
+1 -1
View File
@@ -2,7 +2,7 @@
name = "ratelimit" name = "ratelimit"
version = "1.0.0" version = "1.0.0"
description = "Token bucket implementation" description = "Token bucket implementation"
edition = "2024" edition = "2021"
license = "MPL-2.0" license = "MPL-2.0"
[dependencies] [dependencies]
+3 -3
View File
@@ -1,14 +1,14 @@
[package] [package]
name = "deltachat-repl" name = "deltachat-repl"
version = "2.64.0-dev" version = "2.51.0-dev"
license = "MPL-2.0" license = "MPL-2.0"
edition = "2024" edition = "2021"
repository = "https://github.com/chatmail/core" repository = "https://github.com/chatmail/core"
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = { workspace = true }
deltachat = { workspace = true, features = ["internals"]} deltachat = { workspace = true, features = ["internals"]}
dirs = "7" dirs = "6"
log = { workspace = true } log = { workspace = true }
nu-ansi-term = { workspace = true } nu-ansi-term = { workspace = true }
qr2term = "0.3.3" qr2term = "0.3.3"
+100 -62
View File
@@ -5,10 +5,9 @@ use std::path::Path;
use std::str::FromStr; use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
use anyhow::{Result, bail, ensure}; use anyhow::{bail, ensure, Result};
use deltachat::chat::{self, Chat, ChatId, ChatItem, ChatVisibility, MuteDuration}; use deltachat::chat::{self, Chat, ChatId, ChatItem, ChatVisibility, MuteDuration};
use deltachat::chatlist::*; use deltachat::chatlist::*;
use deltachat::config;
use deltachat::constants::*; use deltachat::constants::*;
use deltachat::contact::*; use deltachat::contact::*;
use deltachat::context::*; use deltachat::context::*;
@@ -20,13 +19,63 @@ use deltachat::message::{self, Message, MessageState, MsgId, Viewtype};
use deltachat::mimeparser::SystemMessage; use deltachat::mimeparser::SystemMessage;
use deltachat::peer_channels::{send_webxdc_realtime_advertisement, send_webxdc_realtime_data}; use deltachat::peer_channels::{send_webxdc_realtime_advertisement, send_webxdc_realtime_data};
use deltachat::qr::*; use deltachat::qr::*;
use deltachat::qr_code_generator::{create_qr_svg, get_securejoin_qr_svg}; use deltachat::qr_code_generator::create_qr_svg;
use deltachat::reaction::send_reaction; use deltachat::reaction::send_reaction;
use deltachat::receive_imf::*; use deltachat::receive_imf::*;
use deltachat::sql; use deltachat::sql;
use deltachat::tools::*; use deltachat::tools::*;
use deltachat::{config, provider};
use tokio::fs; use tokio::fs;
/// Reset database tables.
/// Argument is a bitmask, executing single or multiple actions in one call.
/// e.g. bitmask 7 triggers actions defined with bits 1, 2 and 4.
async fn reset_tables(context: &Context, bits: i32) {
println!("Resetting tables ({bits})...");
if 0 != bits & 4 {
context
.sql()
.execute("DELETE FROM keypairs;", ())
.await
.unwrap();
println!("(4) Private keypairs reset.");
}
if 0 != bits & 8 {
context
.sql()
.execute("DELETE FROM contacts WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM chats WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM chats_contacts;", ())
.await
.unwrap();
context
.sql()
.execute("DELETE FROM msgs WHERE id>9;", ())
.await
.unwrap();
context
.sql()
.execute(
"DELETE FROM config WHERE keyname LIKE 'imap.%' OR keyname LIKE 'configured%';",
(),
)
.await
.unwrap();
context.sql().config_cache().write().await.clear();
println!("(8) Rest but server config reset.");
}
context.emit_msgs_changed_without_ids();
}
async fn poke_eml_file(context: &Context, filename: &Path) -> Result<()> { async fn poke_eml_file(context: &Context, filename: &Path) -> Result<()> {
let data = read_file(context, filename).await?; let data = read_file(context, filename).await?;
@@ -179,7 +228,7 @@ async fn log_msg(context: &Context, prefix: impl AsRef<str>, msg: &Message) {
async fn log_msglist(context: &Context, msglist: &[MsgId]) -> Result<()> { async fn log_msglist(context: &Context, msglist: &[MsgId]) -> Result<()> {
let mut lines_out = 0; let mut lines_out = 0;
for &msg_id in msglist { for &msg_id in msglist {
if msg_id == MsgId::DAYMARKER { if msg_id == MsgId::new(DC_MSG_ID_DAYMARKER) {
println!( println!(
"--------------------------------------------------------------------------------" "--------------------------------------------------------------------------------"
); );
@@ -210,13 +259,19 @@ async fn log_contactlist(context: &Context, contacts: &[ContactId]) -> Result<()
let contact = Contact::get_by_id(context, *contact_id).await?; let contact = Contact::get_by_id(context, *contact_id).await?;
let name = contact.get_display_name(); let name = contact.get_display_name();
let addr = contact.get_addr(); let addr = contact.get_addr();
let verified_str = if contact.is_verified(context).await? {
" √"
} else {
""
};
let line = format!( let line = format!(
"{} <{}>", "{}{} <{}>",
if !name.is_empty() { if !name.is_empty() {
name name
} else { } else {
"<name unset>" "<name unset>"
}, },
verified_str,
if !addr.is_empty() { addr } else { "addr unset" } if !addr.is_empty() { addr } else { "addr unset" }
); );
@@ -255,6 +310,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
export-keys\n\ export-keys\n\
import-keys <key-file>\n\ import-keys <key-file>\n\
poke [<eml-file>|<folder>|<addr> <key-file>]\n\ poke [<eml-file>|<folder>|<addr> <key-file>]\n\
reset <flags>\n\
stop\n\ stop\n\
=============================================" ============================================="
), ),
@@ -263,6 +319,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
info\n\ info\n\
set <configuration-key> [<value>]\n\ set <configuration-key> [<value>]\n\
get <configuration-key>\n\ get <configuration-key>\n\
oauth2\n\
configure\n\ configure\n\
connect\n\ connect\n\
disconnect\n\ disconnect\n\
@@ -339,6 +396,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
joinqr <qr-content>\n\ joinqr <qr-content>\n\
setqr <qr-content>\n\ setqr <qr-content>\n\
createqrsvg <qr-content>\n\ createqrsvg <qr-content>\n\
providerinfo <addr>\n\
fileinfo <file>\n\ fileinfo <file>\n\
estimatedeletion <seconds>\n\ estimatedeletion <seconds>\n\
clear -- clear screen\n\ clear -- clear screen\n\
@@ -346,57 +404,9 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
=============================================" ============================================="
), ),
}, },
"connect" => {
context.start_io().await;
}
"disconnect" => {
context.stop_io().await;
}
"fetch" => {
context.background_fetch().await?;
}
"configure" => {
context.configure().await?;
}
"has-backup" => { "has-backup" => {
has_backup(&context, blobdir).await?; has_backup(&context, blobdir).await?;
} }
"clear" => {
println!("\n\n\n");
print!("\x1b[1;1H\x1b[2J");
}
"getqr" | "getbadqr" => {
context.start_io().await;
let group = arg1.parse::<u32>().ok().map(ChatId::new);
let mut qr = deltachat::securejoin::get_securejoin_qr(&context, group).await?;
if !qr.is_empty() {
if arg0 == "getbadqr" && qr.len() > 40 {
qr.replace_range(12..22, "0000000000")
}
println!("{qr}");
qr2term::print_qr(qr.as_str())?;
}
}
"getqrsvg" => {
context.start_io().await;
let group = arg1.parse::<u32>().ok().map(ChatId::new);
let file = dirs::home_dir().unwrap_or_default().join("qr.svg");
match get_securejoin_qr_svg(&context, group).await {
Ok(svg) => {
fs::write(&file, svg).await?;
println!("QR code svg written to: {file:#?}");
}
Err(err) => {
bail!("Failed to get QR code svg: {err}");
}
}
}
"joinqr" => {
context.start_io().await;
if !arg0.is_empty() {
deltachat::securejoin::join_securejoin(&context, arg1).await?;
}
}
"export-backup" => { "export-backup" => {
let dir = dirs::home_dir().unwrap_or_default(); let dir = dirs::home_dir().unwrap_or_default();
imex( imex(
@@ -442,6 +452,15 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"poke" => { "poke" => {
ensure!(poke_spec(&context, Some(arg1)).await, "Poke failed"); ensure!(poke_spec(&context, Some(arg1)).await, "Poke failed");
} }
"reset" => {
ensure!(
!arg1.is_empty(),
"Argument <bits> missing: 4=private keys, 8=rest but server config"
);
let bits: i32 = arg1.parse()?;
ensure!(bits < 16, "<bits> must be lower than 16.");
reset_tables(&context, bits).await;
}
"stop" => { "stop" => {
context.stop_ongoing().await; context.stop_ongoing().await;
} }
@@ -599,7 +618,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
let sel_chat = sel_chat.as_ref().unwrap(); let sel_chat = sel_chat.as_ref().unwrap();
let time_start = std::time::SystemTime::now(); let time_start = std::time::SystemTime::now();
let msglist = chat::get_chat_msgs_ext( let msglist = chat::get_chat_msgs_ex(
&context, &context,
sel_chat.get_id(), sel_chat.get_id(),
chat::MessageListOptions { chat::MessageListOptions {
@@ -613,7 +632,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
.into_iter() .into_iter()
.map(|x| match x { .map(|x| match x {
ChatItem::Message { msg_id } => msg_id, ChatItem::Message { msg_id } => msg_id,
ChatItem::DayMarker { .. } => MsgId::DAYMARKER, ChatItem::DayMarker { .. } => MsgId::new(DC_MSG_ID_DAYMARKER),
}) })
.collect(); .collect();
@@ -1102,11 +1121,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
let contact_id = ContactId::new(arg1.parse()?); let contact_id = ContactId::new(arg1.parse()?);
let contact = Contact::get_by_id(&context, contact_id).await?; let contact = Contact::get_by_id(&context, contact_id).await?;
let name = contact.get_display_name(); let name_n_addr = contact.get_name_n_addr();
let addr = contact.get_addr();
let mut res = format!( let mut res = format!(
"Contact info for: {name} ({addr}):\nIcon: {}\n", "Contact info for: {}:\nIcon: {}\n",
name_n_addr,
match contact.get_profile_image(&context).await? { match contact.get_profile_image(&context).await? {
Some(image) => image.to_str().unwrap().to_string(), Some(image) => image.to_str().unwrap().to_string(),
None => "NoIcon".to_string(), None => "NoIcon".to_string(),
@@ -1186,14 +1205,33 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
fs::write(&file, svg).await?; fs::write(&file, svg).await?;
println!("{file:#?} written."); println!("{file:#?} written.");
} }
"providerinfo" => {
ensure!(!arg1.is_empty(), "Argument <addr> missing.");
match provider::get_provider_info(arg1) {
Some(info) => {
println!("Information for provider belonging to {arg1}:");
println!("status: {}", info.status as u32);
println!("before_login_hint: {}", info.before_login_hint);
println!("after_login_hint: {}", info.after_login_hint);
println!("overview_page: {}", info.overview_page);
for server in info.server.iter() {
println!("server: {}:{}", server.hostname, server.port,);
}
}
None => {
println!("No information for provider belonging to {arg1} found.");
}
}
}
"fileinfo" => { "fileinfo" => {
ensure!(!arg1.is_empty(), "Argument <file> missing."); ensure!(!arg1.is_empty(), "Argument <file> missing.");
let Ok(buf) = read_file(&context, Path::new(arg1)).await else { if let Ok(buf) = read_file(&context, Path::new(arg1)).await {
let (width, height) = get_filemeta(&buf)?;
println!("width={width}, height={height}");
} else {
bail!("Command failed."); bail!("Command failed.");
}; }
let (width, height) = get_filemeta(&buf)?;
println!("width={width}, height={height}");
} }
"estimatedeletion" => { "estimatedeletion" => {
ensure!(!arg1.is_empty(), "Argument <seconds> missing"); ensure!(!arg1.is_empty(), "Argument <seconds> missing");
+118 -19
View File
@@ -9,10 +9,14 @@ extern crate deltachat;
use std::borrow::Cow::{self, Borrowed, Owned}; use std::borrow::Cow::{self, Borrowed, Owned};
use anyhow::{Error, bail}; use anyhow::{bail, Error};
use deltachat::EventType;
use deltachat::chat::ChatId; use deltachat::chat::ChatId;
use deltachat::config;
use deltachat::context::*; use deltachat::context::*;
use deltachat::oauth2::*;
use deltachat::qr_code_generator::get_securejoin_qr_svg;
use deltachat::securejoin::*;
use deltachat::EventType;
use log::{error, info, warn}; use log::{error, info, warn};
use nu_ansi_term::Color; use nu_ansi_term::Color;
use rustyline::completion::{Completer, FilenameCompleter, Pair}; use rustyline::completion::{Completer, FilenameCompleter, Pair};
@@ -23,6 +27,7 @@ use rustyline::validate::Validator;
use rustyline::{ use rustyline::{
Cmd, CompletionType, Config, Context as RustyContext, EditMode, Editor, Helper, KeyEvent, Cmd, CompletionType, Config, Context as RustyContext, EditMode, Editor, Helper, KeyEvent,
}; };
use tokio::fs;
use tokio::runtime::Handle; use tokio::runtime::Handle;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@@ -144,7 +149,7 @@ impl Completer for DcHelper {
} }
} }
const IMEX_COMMANDS: [&str; 9] = [ const IMEX_COMMANDS: [&str; 10] = [
"has-backup", "has-backup",
"export-backup", "export-backup",
"import-backup", "import-backup",
@@ -153,13 +158,15 @@ const IMEX_COMMANDS: [&str; 9] = [
"export-keys", "export-keys",
"import-keys", "import-keys",
"poke", "poke",
"reset",
"stop", "stop",
]; ];
const DB_COMMANDS: [&str; 10] = [ const DB_COMMANDS: [&str; 11] = [
"info", "info",
"set", "set",
"get", "get",
"oauth2",
"configure", "configure",
"connect", "connect",
"disconnect", "disconnect",
@@ -169,7 +176,7 @@ const DB_COMMANDS: [&str; 10] = [
"housekeeping", "housekeeping",
]; ];
const CHAT_COMMANDS: [&str; 38] = [ const CHAT_COMMANDS: [&str; 39] = [
"listchats", "listchats",
"listarchived", "listarchived",
"start-realtime", "start-realtime",
@@ -178,6 +185,7 @@ const CHAT_COMMANDS: [&str; 38] = [
"createchat", "createchat",
"creategroup", "creategroup",
"createbroadcast", "createbroadcast",
"createprotected",
"addmember", "addmember",
"removemember", "removemember",
"groupname", "groupname",
@@ -232,7 +240,7 @@ const CONTACT_COMMANDS: [&str; 9] = [
"import-vcard", "import-vcard",
"make-vcard", "make-vcard",
]; ];
const MISC_COMMANDS: [&str; 13] = [ const MISC_COMMANDS: [&str; 14] = [
"getqr", "getqr",
"getqrsvg", "getqrsvg",
"getbadqr", "getbadqr",
@@ -240,6 +248,7 @@ const MISC_COMMANDS: [&str; 13] = [
"joinqr", "joinqr",
"setqr", "setqr",
"createqrsvg", "createqrsvg",
"providerinfo",
"fileinfo", "fileinfo",
"estimatedeletion", "estimatedeletion",
"clear", "clear",
@@ -261,11 +270,10 @@ impl Hinter for DcHelper {
&CONTACT_COMMANDS[..], &CONTACT_COMMANDS[..],
&MISC_COMMANDS[..], &MISC_COMMANDS[..],
] { ] {
if let Some(entry) = cmds.iter().find(|el| el.starts_with(&line[..pos])) if let Some(entry) = cmds.iter().find(|el| el.starts_with(&line[..pos])) {
&& *entry != line if *entry != line && *entry != &line[..pos] {
&& *entry != &line[..pos] return Some(entry[pos..].to_owned());
{ }
return Some(entry[pos..].to_owned());
} }
} }
} }
@@ -349,16 +357,23 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
Ok(line) => { Ok(line) => {
// TODO: ignore "set mail_pw" // TODO: ignore "set mail_pw"
rl.add_history_entry(line.as_str())?; rl.add_history_entry(line.as_str())?;
let line = line.trim(); let should_continue = Handle::current().block_on(async {
if matches!(line, "exit" | "quit") { match handle_cmd(line.trim(), ctx.clone(), &mut selected_chat).await {
println!("Exiting ..."); Ok(ExitResult::Continue) => true,
Ok(ExitResult::Exit) => {
println!("Exiting ...");
false
}
Err(err) => {
eprintln!("Error: {err:#}");
true
}
}
});
if !should_continue {
break; break;
} }
if let Err(err) =
Handle::current().block_on(cmdline(ctx.clone(), line, &mut selected_chat))
{
eprintln!("Error: {err:#}");
}
} }
Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => {
println!("Exiting..."); println!("Exiting...");
@@ -382,6 +397,90 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
Ok(()) Ok(())
} }
#[derive(Debug)]
enum ExitResult {
Continue,
Exit,
}
async fn handle_cmd(
line: &str,
ctx: Context,
selected_chat: &mut ChatId,
) -> Result<ExitResult, Error> {
let mut args = line.splitn(2, ' ');
let arg0 = args.next().unwrap_or_default();
let arg1 = args.next().unwrap_or_default();
match arg0 {
"connect" => {
ctx.start_io().await;
}
"disconnect" => {
ctx.stop_io().await;
}
"fetch" => {
ctx.background_fetch().await?;
}
"configure" => {
ctx.configure().await?;
}
"oauth2" => {
if let Some(addr) = ctx.get_config(config::Config::Addr).await? {
if let Some(oauth2_url) =
get_oauth2_url(&ctx, &addr, "chat.delta:/com.b44t.messenger").await?
{
println!("Open the following url, set mail_pw to the generated token and server_flags to 2:\n{oauth2_url}");
} else {
println!("OAuth2 not available for {addr}.");
}
} else {
println!("oauth2: set addr first.");
}
}
"clear" => {
println!("\n\n\n");
print!("\x1b[1;1H\x1b[2J");
}
"getqr" | "getbadqr" => {
ctx.start_io().await;
let group = arg1.parse::<u32>().ok().map(ChatId::new);
let mut qr = get_securejoin_qr(&ctx, group).await?;
if !qr.is_empty() {
if arg0 == "getbadqr" && qr.len() > 40 {
qr.replace_range(12..22, "0000000000")
}
println!("{qr}");
qr2term::print_qr(qr.as_str())?;
}
}
"getqrsvg" => {
ctx.start_io().await;
let group = arg1.parse::<u32>().ok().map(ChatId::new);
let file = dirs::home_dir().unwrap_or_default().join("qr.svg");
match get_securejoin_qr_svg(&ctx, group).await {
Ok(svg) => {
fs::write(&file, svg).await?;
println!("QR code svg written to: {file:#?}");
}
Err(err) => {
bail!("Failed to get QR code svg: {err}");
}
}
}
"joinqr" => {
ctx.start_io().await;
if !arg0.is_empty() {
join_securejoin(&ctx, arg1).await?;
}
}
"exit" | "quit" => return Ok(ExitResult::Exit),
_ => cmdline(ctx.clone(), line, selected_chat).await?,
}
Ok(ExitResult::Continue)
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Error> { async fn main() -> Result<(), Error> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "deltachat-rpc-client" name = "deltachat-rpc-client"
version = "2.64.0-dev" version = "2.51.0-dev"
license = "MPL-2.0" license = "MPL-2.0"
description = "Python client for Delta Chat core JSON-RPC interface" description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [ classifiers = [
@@ -157,7 +157,7 @@ def parse_system_add_remove(text: str) -> Optional[Tuple[str, str, str]]:
""" """
# You removed member a@b. # You removed member a@b.
# You added member a@b. # You added member a@b.
# You were removed by a@b. # Member Me (x@y) removed by a@b.
# Member x@y added by a@b # Member x@y added by a@b
# Member With space (tmp1@x.org) removed by tmp2@x.org. # Member With space (tmp1@x.org) removed by tmp2@x.org.
# Member With space (tmp1@x.org) removed by Another member (tmp2@x.org).", # Member With space (tmp1@x.org) removed by Another member (tmp2@x.org).",
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, Union from typing import TYPE_CHECKING, Optional, Union
@@ -36,15 +35,6 @@ class Account:
if event_type is None or next_event.kind == event_type: if event_type is None or next_event.kind == event_type:
return next_event return next_event
def wait_for_realtime_data(self, msg_id: int) -> bytes:
"""Wait for the next realtime data received for the given webxdc message and return it."""
logging.info(f"account {self.id}: waiting for realtime data for msg {msg_id}")
while True:
event = self.wait_for_event(EventType.WEBXDC_REALTIME_DATA)
if event.msg_id == msg_id:
logging.info(f"account {self.id}: got realtime data for msg {msg_id}: {event.data[:20]}")
return bytes(event.data)
def clear_all_events(self): def clear_all_events(self):
"""Remove all queued-up events for a given account. """Remove all queued-up events for a given account.
@@ -139,18 +129,6 @@ class Account:
"""Add a new transport using a QR code.""" """Add a new transport using a QR code."""
yield self._rpc.add_transport_from_qr.future(self.id, qr) yield self._rpc.add_transport_from_qr.future(self.id, qr)
@futuremethod
def init_transports(self, qr: Optional[str] = None):
"""Add an initial transport on the chatmail relay that answers fastest.
The profile then adds further ones in the background.
A ``DCACCOUNT:`` or ``DCLOGIN:`` ``qr`` code adds a single transport
while securejoin codes add the inviter's relays to the candidates.
Does nothing if the profile already has a transport.
"""
yield self._rpc.init_transports.future(self.id, qr)
def delete_transport(self, addr: str): def delete_transport(self, addr: str):
"""Delete a transport.""" """Delete a transport."""
self._rpc.delete_transport(self.id, addr) self._rpc.delete_transport(self.id, addr)
@@ -162,10 +140,9 @@ class Account:
return transports return transports
def bring_online(self): def bring_online(self):
"""Start I/O, wait until all transports became IDLE and drop the events seen so far.""" """Start I/O and wait until IMAP becomes IDLE."""
self.start_io() self.start_io()
self._rpc.wait_for_all_work_done(self.id) self.wait_for_event(EventType.IMAP_INBOX_IDLE)
self.clear_all_events()
def create_contact(self, obj: Union[int, str, Contact, "Account"], name: Optional[str] = None) -> Contact: def create_contact(self, obj: Union[int, str, Contact, "Account"], name: Optional[str] = None) -> Contact:
"""Create a new Contact or return an existing one. """Create a new Contact or return an existing one.
@@ -205,7 +182,7 @@ class Account:
return [Contact(self, contact_id) for contact_id in contact_ids] return [Contact(self, contact_id) for contact_id in contact_ids]
def create_chat(self, account: "Account") -> Chat: def create_chat(self, account: "Account") -> Chat:
"""Create a single chat with another account.""" """Create a 1:1 chat with another account."""
return self.create_contact(account).create_chat() return self.create_contact(account).create_chat()
def get_device_chat(self) -> Chat: def get_device_chat(self) -> Chat:
@@ -241,7 +218,7 @@ class Account:
return [AttrDict(contact=Contact(self, contact["id"]), **contact) for contact in contacts] return [AttrDict(contact=Contact(self, contact["id"]), **contact) for contact in contacts]
def get_chat_by_contact(self, contact: Union[int, Contact]) -> Optional[Chat]: def get_chat_by_contact(self, contact: Union[int, Contact]) -> Optional[Chat]:
"""Return single chat for a contact if it exists.""" """Return 1:1 chat for a contact if it exists."""
if isinstance(contact, Contact): if isinstance(contact, Contact):
assert contact.account == self assert contact.account == self
contact_id = contact.id contact_id = contact.id
@@ -284,7 +261,7 @@ class Account:
return Contact(self, SpecialContactId.SELF) return Contact(self, SpecialContactId.SELF)
@property @property
def device_contact(self) -> Contact: def device_contact(self) -> Chat:
"""Account's device contact.""" """Account's device contact."""
return Contact(self, SpecialContactId.DEVICE) return Contact(self, SpecialContactId.DEVICE)
@@ -372,7 +349,7 @@ class Account:
return Chat(self, chat_id) return Chat(self, chat_id)
def secure_join(self, qrdata: str) -> Chat: def secure_join(self, qrdata: str) -> Chat:
"""Continue the SecureJoin protocol started on another device. """Continue a Setup-Contact or Verified-Group-Invite protocol started on another device.
The function returns immediately and the handshake runs in background, sending The function returns immediately and the handshake runs in background, sending
and receiving several messages. and receiving several messages.
@@ -252,7 +252,7 @@ class Chat:
def get_contacts(self) -> list[Contact]: def get_contacts(self) -> list[Contact]:
"""Get the contacts belonging to this chat. """Get the contacts belonging to this chat.
For single chats self-address is not included. For single/direct chats self-address is not included.
""" """
contacts = self._rpc.get_chat_contacts(self.account.id, self.id) contacts = self._rpc.get_chat_contacts(self.account.id, self.id)
return [Contact(self.account, contact_id) for contact_id in contacts] return [Contact(self.account, contact_id) for contact_id in contacts]
@@ -38,6 +38,7 @@ class EventType(str, Enum):
IMAP_CONNECTED = "ImapConnected" IMAP_CONNECTED = "ImapConnected"
SMTP_MESSAGE_SENT = "SmtpMessageSent" SMTP_MESSAGE_SENT = "SmtpMessageSent"
IMAP_MESSAGE_DELETED = "ImapMessageDeleted" IMAP_MESSAGE_DELETED = "ImapMessageDeleted"
IMAP_MESSAGE_MOVED = "ImapMessageMoved"
IMAP_INBOX_IDLE = "ImapInboxIdle" IMAP_INBOX_IDLE = "ImapInboxIdle"
NEW_BLOB_FILE = "NewBlobFile" NEW_BLOB_FILE = "NewBlobFile"
DELETED_BLOB_FILE = "DeletedBlobFile" DELETED_BLOB_FILE = "DeletedBlobFile"
@@ -53,7 +54,6 @@ class EventType(str, Enum):
MSG_DELIVERED = "MsgDelivered" MSG_DELIVERED = "MsgDelivered"
MSG_FAILED = "MsgFailed" MSG_FAILED = "MsgFailed"
MSG_READ = "MsgRead" MSG_READ = "MsgRead"
MSG_READ_COUNT_CHANGED = "MsgReadCountChanged"
MSG_DELETED = "MsgDeleted" MSG_DELETED = "MsgDeleted"
CHAT_MODIFIED = "ChatModified" CHAT_MODIFIED = "ChatModified"
CHAT_DELETED = "ChatDeleted" CHAT_DELETED = "ChatDeleted"
@@ -69,7 +69,6 @@ class EventType(str, Enum):
SELFAVATAR_CHANGED = "SelfavatarChanged" SELFAVATAR_CHANGED = "SelfavatarChanged"
WEBXDC_STATUS_UPDATE = "WebxdcStatusUpdate" WEBXDC_STATUS_UPDATE = "WebxdcStatusUpdate"
WEBXDC_INSTANCE_DELETED = "WebxdcInstanceDeleted" WEBXDC_INSTANCE_DELETED = "WebxdcInstanceDeleted"
ACCOUNTS_BACKGROUND_FETCH_DONE = "AccountsBackgroundFetchDone"
CHATLIST_CHANGED = "ChatlistChanged" CHATLIST_CHANGED = "ChatlistChanged"
CHATLIST_ITEM_CHANGED = "ChatlistItemChanged" CHATLIST_ITEM_CHANGED = "ChatlistItemChanged"
ACCOUNTS_CHANGED = "AccountsChanged" ACCOUNTS_CHANGED = "AccountsChanged"
@@ -97,7 +96,7 @@ class ChatType(str, Enum):
"""Chat type.""" """Chat type."""
SINGLE = "Single" SINGLE = "Single"
"""Single chat (a chat with a with a single contact)""" """1:1 chat, i.e. a direct chat with a single contact"""
GROUP = "Group" GROUP = "Group"
@@ -231,6 +230,14 @@ class KeyGenType(IntEnum):
RSA4096 = 3 RSA4096 = 3
# "Lp" means "login parameters"
class LpAuthFlag(IntEnum):
"""Authorization flags."""
OAUTH2 = 0x2
NORMAL = 0x4
class MediaQuality(IntEnum): class MediaQuality(IntEnum):
"""Media quality setting.""" """Media quality setting."""
@@ -246,6 +253,14 @@ class ProviderStatus(IntEnum):
BROKEN = 3 BROKEN = 3
class PushNotifyState(IntEnum):
"""Push notifications state."""
NOT_CONNECTED = 0
HEARTBEAT = 1
CONNECTED = 2
class ShowEmails(IntEnum): class ShowEmails(IntEnum):
"""Show emails mode.""" """Show emails mode."""
@@ -55,7 +55,7 @@ class Contact:
return snapshot return snapshot
def create_chat(self) -> "Chat": def create_chat(self) -> "Chat":
"""Create or get an existing single chat for this contact.""" """Create or get an existing 1:1 chat for this contact."""
from .chat import Chat from .chat import Chat
return Chat( return Chat(
@@ -48,13 +48,6 @@ class DeltaChat:
"""Stop ongoing background fetch.""" """Stop ongoing background fetch."""
self.rpc.stop_background_fetch() self.rpc.stop_background_fetch()
def wait_for_event(self, event_type=None) -> AttrDict:
"""Wait until the next account manager event and return it."""
while True:
next_event = AttrDict(self.rpc.wait_for_event(0))
if event_type is None or next_event.kind == event_type:
return next_event
def maybe_network(self) -> None: def maybe_network(self) -> None:
"""Indicate that the network conditions might have changed.""" """Indicate that the network conditions might have changed."""
self.rpc.maybe_network() self.rpc.maybe_network()
@@ -74,7 +67,3 @@ class DeltaChat:
def stop_sending_locations(self) -> None: def stop_sending_locations(self) -> None:
"""Stop sending locations to all chats.""" """Stop sending locations to all chats."""
return self.rpc.stop_sending_locations() return self.rpc.stop_sending_locations()
def is_sending_finished(self) -> bool:
"""Return true if sending queues of all accounts are empty."""
return self.rpc.is_sending_finished()
@@ -121,7 +121,7 @@ class Message:
yield self._rpc.send_webxdc_realtime_advertisement.future(self.account.id, self.id) yield self._rpc.send_webxdc_realtime_advertisement.future(self.account.id, self.id)
@futuremethod @futuremethod
def send_webxdc_realtime_data(self, data): def send_webxdc_realtime_data(self, data) -> None:
"""Send data to the realtime channel.""" """Send data to the realtime channel."""
yield self._rpc.send_webxdc_realtime_data.future(self.account.id, self.id, list(data)) yield self._rpc.send_webxdc_realtime_data.future(self.account.id, self.id, list(data))
@@ -7,13 +7,12 @@ import os
import pathlib import pathlib
import platform import platform
import random import random
import socket
import subprocess import subprocess
import sys import sys
import time from typing import AsyncGenerator, Optional
import urllib.parse
from typing import Iterator, Optional
import execnet
import py
import pytest import pytest
from . import Account, AttrDict, Bot, Chat, Client, DeltaChat, EventType, Message from . import Account, AttrDict, Bot, Chat, Client, DeltaChat, EventType, Message
@@ -27,42 +26,19 @@ Currently this is "Messages are end-to-end encrypted."
""" """
def pytest_configure(config):
# Run only in the xdist controller, before the workers exist.
if not hasattr(config, "workerinput"):
domain = os.environ.get("CHATMAIL_DOMAIN")
if domain:
check_chatmail_domain_and_warmup_dns_cache(domain)
def check_chatmail_domain_and_warmup_dns_cache(domain):
for i in range(6):
try:
socket.getaddrinfo(domain, 443)
return
except socket.gaierror as e:
error = e
logging.warning(f"DNS resolution of {domain} failed (attempt {i}): {e}")
time.sleep(10)
pytest.exit(f"cannot resolve chatmail relay domain {domain}: {error}")
def pytest_report_header(): def pytest_report_header():
headers = [f"CHATMAIL_DOMAIN: {os.environ.get('CHATMAIL_DOMAIN')}"]
for base in os.get_exec_path(): for base in os.get_exec_path():
fn = pathlib.Path(base).joinpath(base, "deltachat-rpc-server") fn = pathlib.Path(base).joinpath(base, "deltachat-rpc-server")
if fn.exists(): if fn.exists():
proc = subprocess.Popen([str(fn), "--version"], stderr=subprocess.PIPE) proc = subprocess.Popen([str(fn), "--version"], stderr=subprocess.PIPE)
proc.wait() proc.wait()
version = proc.stderr.read().decode().strip() version = proc.stderr.read().decode().strip()
headers.append(f"RPC-SERVER: {fn} [{version}]") return f"deltachat-rpc-server: {fn} [{version}]"
break
return headers return None
class RPCAccountFactory: class ACFactory:
"""Test account factory.""" """Test account factory."""
def __init__(self, deltachat: DeltaChat) -> None: def __init__(self, deltachat: DeltaChat) -> None:
@@ -76,7 +52,7 @@ class RPCAccountFactory:
"""Create a new unconfigured bot.""" """Create a new unconfigured bot."""
return Bot(self.get_unconfigured_account()) return Bot(self.get_unconfigured_account())
def get_credentials(self) -> tuple[str, str]: def get_credentials(self) -> (str, str):
"""Generate new credentials for chatmail account.""" """Generate new credentials for chatmail account."""
domain = os.environ["CHATMAIL_DOMAIN"] domain = os.environ["CHATMAIL_DOMAIN"]
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6)) username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
@@ -128,7 +104,7 @@ class RPCAccountFactory:
return ac_clone return ac_clone
def get_accepted_chat(self, ac1: Account, ac2: Account) -> Chat: def get_accepted_chat(self, ac1: Account, ac2: Account) -> Chat:
"""Create a new single chat between ac1 and ac2 accepted on both sides. """Create a new 1:1 chat between ac1 and ac2 accepted on both sides.
Returned chat is a chat with ac2 from ac1 point of view. Returned chat is a chat with ac2 from ac1 point of view.
""" """
@@ -175,7 +151,7 @@ class RPCAccountFactory:
@pytest.fixture @pytest.fixture
def rpc(tmp_path) -> Iterator[Rpc]: def rpc(tmp_path) -> AsyncGenerator:
"""RPC client fixture.""" """RPC client fixture."""
rpc_server = Rpc(accounts_dir=str(tmp_path / "accounts")) rpc_server = Rpc(accounts_dir=str(tmp_path / "accounts"))
with rpc_server: with rpc_server:
@@ -189,20 +165,20 @@ def dc(rpc) -> DeltaChat:
@pytest.fixture @pytest.fixture
def acf(dc) -> RPCAccountFactory: def acfactory(dc) -> AsyncGenerator:
"""Return account factory fixture.""" """Return account factory fixture."""
return RPCAccountFactory(dc) return ACFactory(dc)
@pytest.fixture @pytest.fixture
def rpcdata(): def data():
"""Test data.""" """Test data."""
class Data: class Data:
def __init__(self) -> None: def __init__(self) -> None:
for path in pathlib.Path(__file__).parents: for path in reversed(py.path.local(__file__).parts()):
datadir = path / "test-data" datadir = path.join("test-data")
if datadir.is_dir(): if datadir.isdir():
self.path = datadir self.path = datadir
return return
raise Exception("Data path cannot be found") raise Exception("Data path cannot be found")
@@ -292,33 +268,26 @@ def get_core_python_env(tmp_path_factory):
@pytest.fixture @pytest.fixture
def alice_and_remote_bob(tmp_path, acf, get_core_python_env): def alice_and_remote_bob(tmp_path, acfactory, get_core_python_env):
"""return local Alice account, a contact to bob, and a remote 'eval' function for bob. """return local Alice account, a contact to bob, and a remote 'eval' function for bob.
The 'eval' function allows to remote-execute arbitrary expressions The 'eval' function allows to remote-execute arbitrary expressions
that can use the `bob` online account, and the `bob_contact_alice`. that can use the `bob` online account, and the `bob_contact_alice`.
""" """
from execnet import makegateway
def factory(core_version): def factory(core_version):
python, rpc_server_path = get_core_python_env(core_version) python, rpc_server_path = get_core_python_env(core_version)
gw = makegateway(f"popen//python={python}") gw = execnet.makegateway(f"popen//python={python}")
accounts_dir = str(tmp_path.joinpath("account1_venv1")) accounts_dir = str(tmp_path.joinpath("account1_venv1"))
channel = gw.remote_exec(remote_bob_loop) channel = gw.remote_exec(remote_bob_loop)
cm = os.environ.get("CHATMAIL_DOMAIN")
# old cores need "ic=3" to accept
# the self-signed cert of an underscore domain
addr, password = acf.get_credentials()
dclogin_qr = f"dclogin://{urllib.parse.quote(addr, safe='@')}?p={urllib.parse.quote(password)}&v=1"
if os.environ["CHATMAIL_DOMAIN"].startswith("_"):
dclogin_qr += "&ic=3"
# trigger getting an online account on bob's side # trigger getting an online account on bob's side
channel.send((accounts_dir, str(rpc_server_path), dclogin_qr)) channel.send((accounts_dir, str(rpc_server_path), cm))
# meanwhile get a local alice account # meanwhile get a local alice account
alice = acf.get_online_account() alice = acfactory.get_online_account()
channel.send(alice.self_contact.make_vcard()) channel.send(alice.self_contact.make_vcard())
# wait for bob to have started # wait for bob to have started
@@ -347,8 +316,10 @@ def remote_bob_loop(channel):
import os import os
from deltachat_rpc_client import DeltaChat, Rpc from deltachat_rpc_client import DeltaChat, Rpc
from deltachat_rpc_client.pytestplugin import ACFactory
accounts_dir, rpc_server_path, dclogin_qr = channel.receive() accounts_dir, rpc_server_path, chatmail_domain = channel.receive()
os.environ["CHATMAIL_DOMAIN"] = chatmail_domain
# older core versions don't support specifying rpc_server_path # older core versions don't support specifying rpc_server_path
# so we can't just pass `rpc_server_path` argument to Rpc constructor # so we can't just pass `rpc_server_path` argument to Rpc constructor
@@ -359,13 +330,8 @@ def remote_bob_loop(channel):
with rpc: with rpc:
dc = DeltaChat(rpc) dc = DeltaChat(rpc)
channel.send(dc.rpc.get_system_info()["deltachat_core_version"]) channel.send(dc.rpc.get_system_info()["deltachat_core_version"])
acfactory = ACFactory(dc)
# RPCAccountFactory would configure from a "dcaccount" QR, bob = acfactory.get_online_account()
# which old cores cannot use on underscore domains
bob = dc.add_account()
bob.add_transport_from_qr(dclogin_qr)
bob.bring_online()
alice_vcard = channel.receive() alice_vcard = channel.receive()
[alice_contact] = bob.import_vcard(alice_vcard) [alice_contact] = bob.import_vcard(alice_vcard)
ns = {"bob": bob, "bob_contact_alice": alice_contact} ns = {"bob": bob, "bob_contact_alice": alice_contact}
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import itertools import itertools
import json import json
import logging import logging
@@ -39,15 +38,8 @@ class RpcMethod:
"params": args, "params": args,
"id": request_id, "id": request_id,
} }
queue: Queue = Queue() self.rpc.request_results[request_id] = queue = Queue()
# Register before testing for shutdown, so that either the reader loop self.rpc.request_queue.put(request)
# finds this request while draining, or the test below catches it here.
# Testing first would race with the reader loop finishing in between.
self.rpc.request_results[request_id] = queue
if self.rpc.request_queue_closed:
self.rpc._fail_request(request_id)
else:
self.rpc.request_queue.put(request)
def rpc_future(): def rpc_future():
"""Wait for the request to receive a result.""" """Wait for the request to receive a result."""
@@ -86,10 +78,6 @@ class Rpc:
# Map from request ID to a Queue which provides a single result # Map from request ID to a Queue which provides a single result
self.request_results: dict[int, Queue] self.request_results: dict[int, Queue]
self.request_queue: Queue[Any] self.request_queue: Queue[Any]
# Emulates `request_queue.shutdown(immediate=False)`, which needs Python 3.13:
# https://github.com/python/cpython/blob/v3.13.0/Lib/queue.py#L236-L257
# Note that `request_queue_closed` is set by the reader loop.
self.request_queue_closed: bool
self.closing: bool self.closing: bool
self.reader_thread: Thread self.reader_thread: Thread
self.writer_thread: Thread self.writer_thread: Thread
@@ -119,7 +107,6 @@ class Rpc:
self.event_queues = {} self.event_queues = {}
self.request_results = {} self.request_results = {}
self.request_queue = Queue() self.request_queue = Queue()
self.request_queue_closed = False
self.closing = False self.closing = False
self.reader_thread = Thread(target=self.reader_loop) self.reader_thread = Thread(target=self.reader_loop)
self.reader_thread.start() self.reader_thread.start()
@@ -136,8 +123,6 @@ class Rpc:
# The reader_loop already saw EOF on stdout, so the process # The reader_loop already saw EOF on stdout, so the process
# has exited and stderr is available. # has exited and stderr is available.
stderr = self.process.stderr.read().decode(errors="replace").strip() stderr = self.process.stderr.read().decode(errors="replace").strip()
self.closing = True
self._shutdown_loops()
if stderr: if stderr:
raise JsonRpcError(f"RPC server failed to start: {stderr}") from e raise JsonRpcError(f"RPC server failed to start: {stderr}") from e
raise JsonRpcError(f"RPC server startup check failed: {e}") from e raise JsonRpcError(f"RPC server startup check failed: {e}") from e
@@ -149,35 +134,12 @@ class Rpc:
def close(self) -> None: def close(self) -> None:
"""Terminate RPC server process and wait until the reader loop finishes.""" """Terminate RPC server process and wait until the reader loop finishes."""
self.closing = True self.closing = True
# JSON-RPC error may happen if RPC server process has crashed already. self.stop_io_for_all_accounts()
# We still want to shutdown all threads in this case.
with contextlib.suppress(JsonRpcError):
self.stop_io_for_all_accounts()
# Let `events_loop` stop cleanly on `closing` before the pipe goes away,
# otherwise it might exit through an "RPC server closed" error instead.
self.events_thread.join() self.events_thread.join()
self._shutdown_loops() self.process.stdin.close()
def _shutdown_loops(self) -> None:
"""Close the server pipe and wait for the loop threads to finish.
The writer blocks on an empty request queue,
so it needs the sentinel to notice the shutdown.
"""
with contextlib.suppress(BrokenPipeError):
# An exited server may leave data unflushed,
# which close() would try to write out again.
self.process.stdin.close()
self.request_queue.put(None)
self.reader_thread.join() self.reader_thread.join()
self.request_queue.put(None)
self.writer_thread.join() self.writer_thread.join()
self.events_thread.join()
def _fail_request(self, request_id: int) -> None:
"""Answer a registered request with an error, unless it was answered already."""
queue = self.request_results.pop(request_id, None)
if queue is not None:
queue.put({"error": {"code": -32000, "message": "RPC server closed"}})
def __enter__(self): def __enter__(self):
self.start() self.start()
@@ -200,11 +162,9 @@ class Rpc:
# Log an exception if the reader loop dies. # Log an exception if the reader loop dies.
logging.exception("Exception in the reader loop") logging.exception("Exception in the reader loop")
finally: finally:
# Shut the request queue first, so that requests registered from now # Unblock any pending requests when the server closes stdout.
# on are failed by their caller, then answer the pending ones here. for _request_id, queue in self.request_results.items():
self.request_queue_closed = True queue.put({"error": {"code": -32000, "message": "RPC server closed"}})
for request_id in list(self.request_results):
self._fail_request(request_id)
def writer_loop(self) -> None: def writer_loop(self) -> None:
"""Writer loop ensuring only a single thread writes requests.""" """Writer loop ensuring only a single thread writes requests."""
+8 -19
View File
@@ -22,18 +22,8 @@ ALL = "1:*"
class DirectImap: class DirectImap:
"""Internal Python-level IMAP handling.""" """Internal Python-level IMAP handling."""
def __init__(self, account: Account, addr=None, password=None) -> None: def __init__(self, account: Account) -> None:
self.account = account self.account = account
if addr is None or password is None:
transport = account.list_transports()[-1]
if addr is None:
self.addr = transport["addr"]
else:
self.addr = addr
if password is None:
self.password = transport["password"]
else:
self.password = password
self.logid = account.get_config("displayname") or id(account) self.logid = account.get_config("displayname") or id(account)
self._idling = False self._idling = False
self.connect() self.connect()
@@ -43,15 +33,11 @@ class DirectImap:
host = self.account.get_config("configured_mail_server") host = self.account.get_config("configured_mail_server")
port = 993 port = 993
user = self.addr user = self.account.get_config("addr")
host = user.rsplit("@")[-1] host = user.rsplit("@")[-1]
pw = self.password pw = self.account.get_config("mail_pw")
ssl_context = ssl.create_default_context() self.conn = MailBox(host, port, ssl_context=ssl.create_default_context())
if host.startswith("_"):
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
self.conn = MailBox(host, port, ssl_context=ssl_context)
self.conn.login(user, pw) self.conn.login(user, pw)
self.select_folder("INBOX") self.select_folder("INBOX")
@@ -179,7 +165,7 @@ class DirectImap:
self.conn.append(bytes(msg, encoding="ascii"), folder) self.conn.append(bytes(msg, encoding="ascii"), folder)
def get_uid_by_message_id(self, message_id) -> str: def get_uid_by_message_id(self, message_id) -> str:
msgs = [msg.uid for msg in self.conn.fetch(AND(header=Header("MESSAGE-ID", message_id)), mark_seen=False)] msgs = [msg.uid for msg in self.conn.fetch(AND(header=Header("MESSAGE-ID", message_id)))]
if len(msgs) == 0: if len(msgs) == 0:
raise Exception("Did not find message " + message_id + ", maybe you forgot to select the correct folder?") raise Exception("Did not find message " + message_id + ", maybe you forgot to select the correct folder?")
return msgs[0] return msgs[0]
@@ -188,6 +174,9 @@ class DirectImap:
class IdleManager: class IdleManager:
def __init__(self, direct_imap) -> None: def __init__(self, direct_imap) -> None:
self.direct_imap = direct_imap self.direct_imap = direct_imap
# fetch latest messages before starting idle so that it only
# returns messages that arrive anew
self.direct_imap.conn.fetch("1:*")
self.direct_imap.conn.idle.start() self.direct_imap.conn.idle.start()
def check(self, timeout=None) -> list[bytes]: def check(self, timeout=None) -> list[bytes]:
@@ -5,16 +5,16 @@ from typing import TYPE_CHECKING
from deltachat_rpc_client import EventType from deltachat_rpc_client import EventType
if TYPE_CHECKING: if TYPE_CHECKING:
from deltachat_rpc_client.pytestplugin import RPCAccountFactory from deltachat_rpc_client.pytestplugin import ACFactory
def test_event_on_configuration(acf: RPCAccountFactory) -> None: def test_event_on_configuration(acfactory: ACFactory) -> None:
""" """
Test if ACCOUNTS_ITEM_CHANGED event is emitted on configure Test if ACCOUNTS_ITEM_CHANGED event is emitted on configure
""" """
addr, password = acf.get_credentials() addr, password = acfactory.get_credentials()
account = acf.get_unconfigured_account() account = acfactory.get_unconfigured_account()
account.clear_all_events() account.clear_all_events()
assert not account.is_configured() assert not account.is_configured()
future = account.add_or_update_transport.future({"addr": addr, "password": password}) future = account.add_or_update_transport.future({"addr": addr, "password": password})
+14 -14
View File
@@ -1,8 +1,8 @@
from deltachat_rpc_client import EventType, Message from deltachat_rpc_client import EventType, Message
def test_calls(acf) -> None: def test_calls(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
place_call_info = "offer" place_call_info = "offer"
accept_call_info = "answer" accept_call_info = "answer"
@@ -35,14 +35,14 @@ def test_calls(acf) -> None:
assert incoming_call_message.get_call_info().state.kind == "Completed" assert incoming_call_message.get_call_info().state.kind == "Completed"
def test_video_call(acf) -> None: def test_video_call(acfactory) -> None:
# Example from <https://datatracker.ietf.org/doc/rfc9143/> # Example from <https://datatracker.ietf.org/doc/rfc9143/>
# with `s= ` replaced with `s=-`. # with `s= ` replaced with `s=-`.
# #
# `s=` cannot be empty according to RFC 3264, # `s=` cannot be empty according to RFC 3264,
# so it is more clear as `s=-`. # so it is more clear as `s=-`.
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
bob.create_chat(alice) # Accept the chat so incoming call causes a notification. bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
@@ -57,8 +57,8 @@ def test_video_call(acf) -> None:
assert incoming_call_message.get_call_info().has_video assert incoming_call_message.get_call_info().has_video
def test_audio_call(acf) -> None: def test_audio_call(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
bob.create_chat(alice) # Accept the chat so incoming call causes a notification. bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
@@ -73,15 +73,15 @@ def test_audio_call(acf) -> None:
assert not incoming_call_message.get_call_info().has_video assert not incoming_call_message.get_call_info().has_video
def test_ice_servers(acf) -> None: def test_ice_servers(acfactory) -> None:
alice = acf.get_online_account() alice = acfactory.get_online_account()
ice_servers = alice.ice_servers() ice_servers = alice.ice_servers()
assert len(ice_servers) == 1 assert len(ice_servers) == 1
def test_no_contact_request_call(acf) -> None: def test_no_contact_request_call(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob) alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer", has_video_initially=True) alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
@@ -101,8 +101,8 @@ def test_no_contact_request_call(acf) -> None:
break break
def test_who_can_call_me_nobody(acf) -> None: def test_who_can_call_me_nobody(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
# Bob sets "who can call me" to "nobody" (2) # Bob sets "who can call me" to "nobody" (2)
bob.set_config("who_can_call_me", "2") bob.set_config("who_can_call_me", "2")
@@ -128,9 +128,9 @@ def test_who_can_call_me_nobody(acf) -> None:
break break
def test_who_can_call_me_everybody(acf) -> None: def test_who_can_call_me_everybody(acfactory) -> None:
"""Test that if "who can call me" setting is set to "everybody", calls arrive even in contact request chats.""" """Test that if "who can call me" setting is set to "everybody", calls arrive even in contact request chats."""
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
# Bob sets "who can call me" to "nobody" (0) # Bob sets "who can call me" to "nobody" (0)
bob.set_config("who_can_call_me", "0") bob.set_config("who_can_call_me", "0")
@@ -2,10 +2,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from deltachat_rpc_client import EventType, const from deltachat_rpc_client import Account, EventType, const
if TYPE_CHECKING: if TYPE_CHECKING:
from deltachat_rpc_client.pytestplugin import RPCAccountFactory from deltachat_rpc_client.pytestplugin import ACFactory
def wait_for_chatlist_and_specific_item(account, chat_id): def wait_for_chatlist_and_specific_item(account, chat_id):
@@ -40,11 +40,11 @@ def wait_for_chatlist(account):
break break
def test_delivery_status(acf: RPCAccountFactory) -> None: def test_delivery_status(acfactory: ACFactory) -> None:
""" """
Test change status on chatlistitem when status changes (delivered, read) Test change status on chatlistitem when status changes (delivered, read)
""" """
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob = alice_contact_bob.create_chat()
@@ -82,11 +82,11 @@ def test_delivery_status(acf: RPCAccountFactory) -> None:
assert chat_item["summaryStatus"] == const.MessageState.OUT_MDN_RCVD assert chat_item["summaryStatus"] == const.MessageState.OUT_MDN_RCVD
def test_delivery_status_failed(acf: RPCAccountFactory) -> None: def test_delivery_status_failed(acfactory: ACFactory) -> None:
""" """
Test change status on chatlistitem when status changes failed Test change status on chatlistitem when status changes failed
""" """
(alice,) = acf.get_online_accounts(1) (alice,) = acfactory.get_online_accounts(1)
alice.set_config("force_encryption", "0") alice.set_config("force_encryption", "0")
invalid_contact = alice.create_contact("example@example.com", "invalid address") invalid_contact = alice.create_contact("example@example.com", "invalid address")
@@ -110,12 +110,12 @@ def test_delivery_status_failed(acf: RPCAccountFactory) -> None:
assert failing_message.get_snapshot().state == const.MessageState.OUT_FAILED assert failing_message.get_snapshot().state == const.MessageState.OUT_FAILED
def test_download_on_demand(acf: RPCAccountFactory, rpcdata) -> None: def test_download_on_demand(acfactory: ACFactory) -> None:
""" """
Test if download on demand emits chatlist update events. Test if download on demand emits chatlist update events.
This is only needed for last message in chat, but finding that out is too expensive, so it's always emitted This is only needed for last message in chat, but finding that out is too expensive, so it's always emitted
""" """
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob = alice_contact_bob.create_chat()
@@ -128,7 +128,7 @@ def test_download_on_demand(acf: RPCAccountFactory, rpcdata) -> None:
msg.get_snapshot().chat.accept() msg.get_snapshot().chat.accept()
bob.get_chat_by_id(chat_id).send_message( bob.get_chat_by_id(chat_id).send_message(
"Hello World, this message is bigger than 5 bytes", "Hello World, this message is bigger than 5 bytes",
file=rpcdata.get_path("image/screenshot.jpg"), file="../test-data/image/screenshot.jpg",
) )
message = alice.wait_for_incoming_msg() message = alice.wait_for_incoming_msg()
@@ -144,8 +144,8 @@ def test_download_on_demand(acf: RPCAccountFactory, rpcdata) -> None:
wait_for_chatlist_specific_item(alice, chat_id) wait_for_chatlist_specific_item(alice, chat_id)
def get_multi_account_test_setup(acf: RPCAccountFactory) -> list: def get_multi_account_test_setup(acfactory: ACFactory) -> [Account, Account, Account]:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob = alice_contact_bob.create_chat()
@@ -161,12 +161,12 @@ def get_multi_account_test_setup(acf: RPCAccountFactory) -> list:
return [alice, alice_second_device, bob, alice_chat_bob] return [alice, alice_second_device, bob, alice_chat_bob]
def test_imap_sync_seen_msgs(acf: RPCAccountFactory) -> None: def test_imap_sync_seen_msgs(acfactory: ACFactory) -> None:
""" """
Test that chatlist changed events are emitted for the second device Test that chatlist changed events are emitted for the second device
when the message is marked as read on the first device when the message is marked as read on the first device
""" """
alice, alice_second_device, bob, alice_chat_bob = get_multi_account_test_setup(acf) alice, alice_second_device, bob, alice_chat_bob = get_multi_account_test_setup(acfactory)
bob.create_chat(alice) bob.create_chat(alice)
@@ -191,11 +191,11 @@ def test_imap_sync_seen_msgs(acf: RPCAccountFactory) -> None:
wait_for_chatlist_specific_item(alice, alice_chat_bob.id) wait_for_chatlist_specific_item(alice, alice_chat_bob.id)
def test_multidevice_sync_chat(acf: RPCAccountFactory) -> None: def test_multidevice_sync_chat(acfactory: ACFactory) -> None:
""" """
Test multidevice sync: syncing chat visibility and muting across multiple devices Test multidevice sync: syncing chat visibility and muting across multiple devices
""" """
alice, alice_second_device, bob, alice_chat_bob = get_multi_account_test_setup(acf) alice, alice_second_device, bob, alice_chat_bob = get_multi_account_test_setup(acfactory)
alice_chat_bob.archive() alice_chat_bob.archive()
wait_for_chatlist_specific_item(alice_second_device, alice_chat_bob.id) wait_for_chatlist_specific_item(alice_second_device, alice_chat_bob.id)
+9 -165
View File
@@ -1,5 +1,4 @@
import subprocess import subprocess
import time
import pytest import pytest
@@ -17,7 +16,7 @@ def test_install_venv_and_use_other_core(tmp_path, get_core_python_env):
@pytest.mark.parametrize("version", ["2.24.0"]) @pytest.mark.parametrize("version", ["2.24.0"])
def test_qr_setup_contact(acf, alice_and_remote_bob, version) -> None: def test_qr_setup_contact(acfactory, alice_and_remote_bob, version) -> None:
"""Test other-core Bob profile can do securejoin with Alice on current core.""" """Test other-core Bob profile can do securejoin with Alice on current core."""
alice, alice_contact_bob, remote_eval = alice_and_remote_bob(version) alice, alice_contact_bob, remote_eval = alice_and_remote_bob(version)
@@ -25,48 +24,28 @@ def test_qr_setup_contact(acf, alice_and_remote_bob, version) -> None:
remote_eval(f"bob.secure_join({qr_code!r})") remote_eval(f"bob.secure_join({qr_code!r})")
alice.wait_for_securejoin_inviter_success() alice.wait_for_securejoin_inviter_success()
# Test that Alice verified Bob's profile.
alice_contact_bob_snapshot = alice_contact_bob.get_snapshot() alice_contact_bob_snapshot = alice_contact_bob.get_snapshot()
assert alice_contact_bob_snapshot.e2ee_avail assert alice_contact_bob_snapshot.is_verified
remote_eval("bob.wait_for_securejoin_joiner_success()") remote_eval("bob.wait_for_securejoin_joiner_success()")
# The old core still marks Alice as verified, so the handshake is unchanged on the wire. # Test that Bob verified Alice's profile.
assert remote_eval("bob_contact_alice.get_snapshot().is_verified") assert remote_eval("bob_contact_alice.get_snapshot().is_verified")
# Test that Bob can also scan a QR code # Test that Bob can also scan a QR code
# of Alice for which the key is not known yet. # of Alice for which the key is not known yet.
# For the test above Bob already knew the key from a vCard. # For the test above Bob already knew the key from a vCard.
alice2 = acf.get_online_account() alice2 = acfactory.get_online_account()
qr_code = alice2.get_qr_code() qr_code = alice2.get_qr_code()
remote_eval(f"bob.secure_join({qr_code!r})") remote_eval(f"bob.secure_join({qr_code!r})")
remote_eval("bob.wait_for_securejoin_joiner_success()") remote_eval("bob.wait_for_securejoin_joiner_success()")
alice2.wait_for_securejoin_inviter_success() alice2.wait_for_securejoin_inviter_success()
@pytest.mark.parametrize("version", ["2.24.0"])
def test_qr_setup_contact_multitransport(acf, alice_and_remote_bob, version) -> None:
"""Test other-core Bob profile can do securejoin with Alice on current core, with multiple transports."""
alice, alice_contact_bob, remote_eval = alice_and_remote_bob(version)
relay_qr = acf.get_account_qr()
alice.add_transport_from_qr(relay_qr)
alice.add_transport_from_qr(relay_qr)
qr_code = alice.get_qr_code()
remote_eval(f"bob.secure_join({qr_code!r})")
alice.wait_for_securejoin_inviter_success()
alice_contact_bob_snapshot = alice_contact_bob.get_snapshot()
assert alice_contact_bob_snapshot.e2ee_avail
remote_eval("bob.wait_for_securejoin_joiner_success()")
# The old core still marks Alice as verified, so the handshake is unchanged on the wire.
assert remote_eval("bob_contact_alice.get_snapshot().is_verified")
def test_send_and_receive_message(alice_and_remote_bob) -> None: def test_send_and_receive_message(alice_and_remote_bob) -> None:
"""Test other-core Bob profile can send a message to Alice on current core.""" """Test other-core Bob profile can send a message to Alice on current core."""
alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.23.0") alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.20.0")
remote_eval("bob_contact_alice.create_chat().send_text('hello')") remote_eval("bob_contact_alice.create_chat().send_text('hello')")
@@ -74,149 +53,14 @@ def test_send_and_receive_message(alice_and_remote_bob) -> None:
assert msg.get_snapshot().text == "hello" assert msg.get_snapshot().text == "hello"
def test_second_device(acf, alice_and_remote_bob) -> None: def test_second_device(acfactory, alice_and_remote_bob) -> None:
"""Test setting up current version as a second device for old version.""" """Test setting up current version as a second device for old version."""
_alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.23.0") _alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.20.0")
remote_eval("locals().setdefault('future', bob._rpc.provide_backup.future(bob.id))") remote_eval("locals().setdefault('future', bob._rpc.provide_backup.future(bob.id))")
qr = remote_eval("bob._rpc.get_backup_qr(bob.id)") qr = remote_eval("bob._rpc.get_backup_qr(bob.id)")
new_account = acf.get_unconfigured_account() new_account = acfactory.get_unconfigured_account()
new_account._rpc.get_backup(new_account.id, qr) new_account._rpc.get_backup(new_account.id, qr)
remote_eval("locals()['future']()") remote_eval("locals()['future']()")
assert new_account.get_config("addr") == remote_eval("bob.get_config('addr')") assert new_account.get_config("addr") == remote_eval("bob.get_config('addr')")
@pytest.mark.parametrize("replace_relay", [False, True], ids=["add", "replace"])
def test_keyupdate_against_core_2_48_march_2026(acf, alice_and_remote_bob, replace_relay):
"""Test 2.48 Bob learns a relay change of Alice from a keyupdate, and is shown nothing."""
alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.48.0")
def bob_sees():
return remote_eval(
"{'chats': len(bob.get_chatlist()),"
" 'fresh': len(bob._rpc.get_fresh_msgs(bob.id)),"
" 'contacts': len(bob.get_contacts()),"
" 'alice_chat': bob._rpc.get_chat_id_by_contact_id(bob.id, bob_contact_alice.id) or 0}",
)
# Keyupdates go to contacts who plausibly hold our key:
# an accepted chat alone is not enough, a message must have flowed.
alice_chat = alice_contact_bob.create_chat()
alice.set_config("keyupdate_debounce", "1")
old_addr = alice.get_config("configured_addr")
alice_chat.send_text("hi")
assert remote_eval("bob.wait_for_incoming_msg().get_snapshot().text") == "hi"
before = bob_sees()
# Certificate merging keeps the newest direct key signature,
# and signature timestamps have one-second resolution:
# without waiting, the re-signed key can tie with the copy Bob holds, keeping his.
time.sleep(2)
alice.add_transport_from_qr(acf.get_account_qr())
(new_addr,) = [t["addr"] for t in alice.list_transports() if t["addr"] != old_addr]
if replace_relay:
alice.delete_transport(old_addr)
alice.bring_online()
# The 2.48 core has no encryption enforcement, but the keyupdate MDN without
# referenced message keeps it invisible; merging happens before the trashing.
for _ in range(60):
if new_addr in remote_eval("bob_contact_alice.get_encryption_info()"):
break
time.sleep(1)
else:
pytest.fail("Bob never received the keyupdate")
# It also leaves no trace: no chat with Alice, no message anywhere,
# and no address-contact for the address it was sent from.
assert bob_sees() == before
if replace_relay:
remote_eval("bob_contact_alice.create_chat().send_text('hello after replacement')")
assert alice.wait_for_incoming_msg().get_snapshot().text == "hello after replacement"
class LocalSide:
"""Alice on the core under test."""
def __init__(self, account, peer_contact):
self.account = account
self.peer_contact = peer_contact
self.chat = None
def make_qr(self, invite):
if invite == "group":
self.chat = self.account.create_group("Group")
elif invite == "broadcast":
self.chat = self.account.create_broadcast("Channel")
return self.chat.get_qr_code() if self.chat else self.account.get_qr_code()
def join(self, qr):
self.account.secure_join(qr)
def wait_inviter(self):
self.account.wait_for_securejoin_inviter_success()
def wait_joiner(self):
self.account.wait_for_securejoin_joiner_success()
def send_text(self, text):
chat = self.chat or self.peer_contact.create_chat()
chat.send_text(text)
def next_text(self):
return self.account.wait_for_incoming_msg().get_snapshot().text
class RemoteSide:
"""Bob on the other core, driven through remote_eval."""
def __init__(self, remote_eval):
self.remote_eval = remote_eval
self.chat = None
def make_qr(self, invite):
if invite == "contact":
return self.remote_eval("bob.get_qr_code()")
create = {"group": "bob.create_group('Group')", "broadcast": "bob.create_broadcast('Channel')"}[invite]
self.remote_eval(f"locals().update(chat={create})")
self.chat = "chat"
return self.remote_eval("chat.get_qr_code()")
def join(self, qr):
self.remote_eval(f"bob.secure_join({qr!r})")
def wait_inviter(self):
self.remote_eval("bob.wait_for_securejoin_inviter_success()")
def wait_joiner(self):
self.remote_eval("bob.wait_for_securejoin_joiner_success()")
def send_text(self, text):
chat = self.chat or "bob_contact_alice.create_chat()"
self.remote_eval(f"{chat}.send_text({text!r})")
def next_text(self):
return self.remote_eval("bob.wait_for_incoming_msg().get_snapshot().text")
@pytest.mark.parametrize("version", ["2.48.0"])
@pytest.mark.parametrize("invite", ["contact", "group", "broadcast"])
@pytest.mark.parametrize("remote_invites", [False, True], ids=["local-invites", "remote-invites"])
def test_securejoin_invite(alice_and_remote_bob, version, invite, remote_invites):
"""Every invite link type works with either core as the inviter."""
alice, alice_contact_bob, remote_eval = alice_and_remote_bob(version)
local = LocalSide(alice, alice_contact_bob)
remote = RemoteSide(remote_eval)
inviter, joiner = (remote, local) if remote_invites else (local, remote)
joiner.join(inviter.make_qr(invite))
joiner.wait_joiner()
inviter.wait_inviter()
# Group and broadcast joins add an info message first.
if invite != "contact":
joiner.next_text()
inviter.send_text("hello")
assert joiner.next_text() == "hello"
+14 -18
View File
@@ -5,12 +5,12 @@ from imap_tools import AND, U
from deltachat_rpc_client import EventType from deltachat_rpc_client import EventType
def test_moved_markseen(acf, direct_imap, log): def test_moved_markseen(acfactory, direct_imap, log):
"""Test that message already moved to DeltaChat folder is marked as seen.""" """Test that message already moved to DeltaChat folder is marked as seen."""
ac1 = acf.get_online_account() ac1 = acfactory.get_online_account()
addr, password = acf.get_credentials() addr, password = acfactory.get_credentials()
ac2 = acf.get_unconfigured_account() ac2 = acfactory.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password}) ac2.add_or_update_transport({"addr": addr, "password": password})
ac2.bring_online() ac2.bring_online()
@@ -57,28 +57,24 @@ def test_moved_markseen(acf, direct_imap, log):
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True, uid=U(1, "*")), mark_seen=False))) == 1 assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True, uid=U(1, "*")), mark_seen=False))) == 1
def test_markseen_message_and_mdn(acf, direct_imap): def test_markseen_message_and_mdn(acfactory, direct_imap):
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
# Make sure that messages are not immediately auto-deleted on the server: # Make sure that messages are not immediately auto-deleted on the server:
ac1.set_config("bcc_self", "1") ac1.set_config("bcc_self", "1")
ac2.set_config("bcc_self", "1") ac2.set_config("bcc_self", "1")
acf.get_accepted_chat(ac1, ac2).send_text("hi") acfactory.get_accepted_chat(ac1, ac2).send_text("hi")
msg = ac2.wait_for_incoming_msg() msg = ac2.wait_for_incoming_msg()
msg.mark_seen() msg.mark_seen()
rex = re.compile("Marked messages ([0-9,:]+) in folder INBOX as seen.") rex = re.compile("Marked messages [0-9]+ in folder INBOX as seen.")
# Each profile flags two messages but the logged UID set
# covers a varying number of them, so just count UIDs mentioned.
# We are not processing UID ranges, here we just care for two UIDs.
for ac in ac1, ac2: for ac in ac1, ac2:
uids = set() while True:
while len(uids) < 2:
event = ac.wait_for_event() event = ac.wait_for_event()
if event.kind == EventType.INFO and (match := rex.search(event.msg)): if event.kind == EventType.INFO and rex.search(event.msg):
uids.update(re.split("[,:]", match.group(1))) break
ac1_direct_imap = direct_imap(ac1) ac1_direct_imap = direct_imap(ac1)
ac2_direct_imap = direct_imap(ac2) ac2_direct_imap = direct_imap(ac2)
@@ -91,8 +87,8 @@ def test_markseen_message_and_mdn(acf, direct_imap):
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 2 assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 2
def test_trash_multiple_messages(acf, direct_imap, log): def test_trash_multiple_messages(acfactory, direct_imap, log):
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
ac2.stop_io() ac2.stop_io()
# Make sure that messages are not immediately auto-deleted on the server: # Make sure that messages are not immediately auto-deleted on the server:
@@ -101,7 +97,7 @@ def test_trash_multiple_messages(acf, direct_imap, log):
ac2.set_config("sync_msgs", "0") ac2.set_config("sync_msgs", "0")
ac2.start_io() ac2.start_io()
chat12 = acf.get_accepted_chat(ac1, ac2) chat12 = acfactory.get_accepted_chat(ac1, ac2)
log.section("ac1: sending 3 messages") log.section("ac1: sending 3 messages")
texts = ["first", "second", "third"] texts = ["first", "second", "third"]
+103 -69
View File
@@ -7,25 +7,15 @@ If you want to debug iroh at rust-trace/log level set
RUST_LOG=iroh_net=trace,iroh_gossip=trace RUST_LOG=iroh_net=trace,iroh_gossip=trace
""" """
import itertools
import logging import logging
import os import os
import threading import threading
from contextlib import contextmanager import time
import pytest import pytest
from deltachat_rpc_client import EventType from deltachat_rpc_client import EventType
# Relays on underscore domains advertise themselves as iroh relay
# but serve a self-signed certificate that iroh's TLS stack rejects.
# Skipping instead of xfailing keeps the run fast:
# these tests only fail after waiting for realtime connections to time out.
pytestmark = pytest.mark.skipif(
os.environ.get("CHATMAIL_DOMAIN", "").startswith("_"),
reason="iroh does not accept the self-signed certificate of an underscore domain",
)
@pytest.fixture @pytest.fixture
def path_to_webxdc(request): def path_to_webxdc(request):
@@ -45,11 +35,7 @@ def log(msg):
logging.info(msg) logging.info(msg)
# payload used to probe/establish realtime connectivity, filtered out by tests def setup_realtime_webxdc(ac1, ac2, path_to_webxdc):
SETUP_DATA = b"realtime-setup"
def setup_realtime_webxdc(ac1, ac2, path_to_webxdc, wait=True):
assert ac1.get_config("webxdc_realtime_enabled") == "1" assert ac1.get_config("webxdc_realtime_enabled") == "1"
assert ac2.get_config("webxdc_realtime_enabled") == "1" assert ac2.get_config("webxdc_realtime_enabled") == "1"
ac1_ac2_chat = ac1.create_chat(ac2) ac1_ac2_chat = ac1.create_chat(ac2)
@@ -66,44 +52,45 @@ def setup_realtime_webxdc(ac1, ac2, path_to_webxdc, wait=True):
log("sending ac2 -> ac1 realtime advertisement and additional message") log("sending ac2 -> ac1 realtime advertisement and additional message")
ac2_webxdc_msg.send_webxdc_realtime_advertisement() ac2_webxdc_msg.send_webxdc_realtime_advertisement()
if wait:
wait_realtime_connected([(ac1_webxdc_msg, ac2_webxdc_msg)])
return ac1_webxdc_msg, ac2_webxdc_msg return ac1_webxdc_msg, ac2_webxdc_msg
@contextmanager def setup_thread_send_realtime_data(msg, data):
def send_realtime_data_forever(msgs, data=None): def thread_run():
stop = threading.Event() for _i in range(10):
data = data or [SETUP_DATA] * len(msgs) msg.send_webxdc_realtime_data(data)
time.sleep(1)
def thread_run(msg, payload): threading.Thread(target=thread_run, daemon=True).start()
for i in itertools.count():
msg.send_webxdc_realtime_data(payload(i) if callable(payload) else payload)
if stop.wait(1):
return
for msg_payload in zip(msgs, data, strict=True):
threading.Thread(target=thread_run, args=msg_payload, daemon=True).start()
try:
yield
finally:
stop.set()
def wait_realtime_connected(msg_pairs): def wait_receive_realtime_data(msg_data_list):
with send_realtime_data_forever([sender for sender, _ in msg_pairs]): account = msg_data_list[0][0].account
for _, receiver in msg_pairs: msg_data_list = msg_data_list[:]
receiver.account.wait_for_realtime_data(receiver.id)
log(f"account {account.id}: waiting for realtime data {msg_data_list}")
while msg_data_list:
event = account.wait_for_event()
if event.kind == EventType.WEBXDC_REALTIME_DATA:
for i, (msg, data) in enumerate(msg_data_list):
if msg.id == event.msg_id:
assert list(data) == event.data
log(f"msg {msg.id}: got correct realtime data {data}")
del msg_data_list[i]
break
def test_realtime_sequentially(acf, path_to_webxdc): def test_realtime_sequentially(acfactory, path_to_webxdc):
"""Test two peers trying to establish connection sequentially.""" """Test two peers trying to establish connection sequentially."""
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("webxdc_realtime_enabled", "1")
ac2.set_config("webxdc_realtime_enabled", "1")
ac1.create_chat(ac2) ac1.create_chat(ac2)
ac2.create_chat(ac1) ac2.create_chat(ac1)
# share a webxdc app between ac1 and ac2 # share a webxdc app between ac1 and ac2
ac1_webxdc_msg = acf.send_message(from_account=ac1, to_account=ac2, text="play", file=path_to_webxdc) ac1_webxdc_msg = acfactory.send_message(from_account=ac1, to_account=ac2, text="play", file=path_to_webxdc)
ac2_webxdc_msg = ac2.wait_for_incoming_msg() ac2_webxdc_msg = ac2.wait_for_incoming_msg()
snapshot = ac2_webxdc_msg.get_snapshot() snapshot = ac2_webxdc_msg.get_snapshot()
assert snapshot.text == "play" assert snapshot.text == "play"
@@ -111,7 +98,7 @@ def test_realtime_sequentially(acf, path_to_webxdc):
# send iroh announcements sequentially # send iroh announcements sequentially
log("sending ac1 -> ac2 realtime advertisement and additional message") log("sending ac1 -> ac2 realtime advertisement and additional message")
ac1_webxdc_msg.send_webxdc_realtime_advertisement() ac1_webxdc_msg.send_webxdc_realtime_advertisement()
acf.send_message(from_account=ac1, to_account=ac2, text="ping1") acfactory.send_message(from_account=ac1, to_account=ac2, text="ping1")
log("waiting for incoming message on ac2") log("waiting for incoming message on ac2")
snapshot = ac2.wait_for_incoming_msg().get_snapshot() snapshot = ac2.wait_for_incoming_msg().get_snapshot()
@@ -119,7 +106,7 @@ def test_realtime_sequentially(acf, path_to_webxdc):
log("sending ac2 -> ac1 realtime advertisement and additional message") log("sending ac2 -> ac1 realtime advertisement and additional message")
ac2_webxdc_msg.send_webxdc_realtime_advertisement() ac2_webxdc_msg.send_webxdc_realtime_advertisement()
acf.send_message(from_account=ac2, to_account=ac1, text="ping2") acfactory.send_message(from_account=ac2, to_account=ac1, text="ping2")
log("waiting for incoming message on ac1") log("waiting for incoming message on ac1")
snapshot = ac1.wait_for_incoming_msg().get_snapshot() snapshot = ac1.wait_for_incoming_msg().get_snapshot()
@@ -130,27 +117,50 @@ def test_realtime_sequentially(acf, path_to_webxdc):
data = os.urandom(128000) data = os.urandom(128000)
ac1_webxdc_msg.send_webxdc_realtime_data(data) ac1_webxdc_msg.send_webxdc_realtime_data(data)
assert ac2.wait_for_realtime_data(ac2_webxdc_msg.id) == data log("ac2: waiting for realtime data")
while 1:
event = ac2.wait_for_event()
if event.kind == EventType.WEBXDC_REALTIME_DATA:
assert event.data == list(data)
break
def test_realtime_simultaneously(acf, path_to_webxdc): def test_realtime_simultaneously(acfactory, path_to_webxdc):
"""Test two peers trying to establish connection simultaneously.""" """Test two peers trying to establish connection simultaneously."""
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
setup_realtime_webxdc(ac1, ac2, path_to_webxdc) ac1.set_config("webxdc_realtime_enabled", "1")
ac2.set_config("webxdc_realtime_enabled", "1")
ac1_webxdc_msg, ac2_webxdc_msg = setup_realtime_webxdc(ac1, ac2, path_to_webxdc)
setup_thread_send_realtime_data(ac1_webxdc_msg, [10])
wait_receive_realtime_data([(ac2_webxdc_msg, [10])])
def test_two_parallel_realtime_simultaneously(acf, path_to_webxdc): def test_two_parallel_realtime_simultaneously(acfactory, path_to_webxdc):
"""Test two peers trying to establish connection simultaneously.""" """Test two peers trying to establish connection simultaneously."""
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
ac1_webxdc_msg, ac2_webxdc_msg = setup_realtime_webxdc(ac1, ac2, path_to_webxdc, wait=False) ac1.set_config("webxdc_realtime_enabled", "1")
ac1_webxdc_msg2, ac2_webxdc_msg2 = setup_realtime_webxdc(ac1, ac2, path_to_webxdc, wait=False) ac2.set_config("webxdc_realtime_enabled", "1")
wait_realtime_connected([(ac1_webxdc_msg, ac2_webxdc_msg), (ac2_webxdc_msg, ac1_webxdc_msg)])
wait_realtime_connected([(ac1_webxdc_msg2, ac2_webxdc_msg2), (ac2_webxdc_msg2, ac1_webxdc_msg2)]) ac1_webxdc_msg, ac2_webxdc_msg = setup_realtime_webxdc(ac1, ac2, path_to_webxdc)
ac1_webxdc_msg2, ac2_webxdc_msg2 = setup_realtime_webxdc(ac1, ac2, path_to_webxdc)
setup_thread_send_realtime_data(ac1_webxdc_msg, [10])
setup_thread_send_realtime_data(ac1_webxdc_msg2, [20])
setup_thread_send_realtime_data(ac2_webxdc_msg, [30])
setup_thread_send_realtime_data(ac2_webxdc_msg2, [40])
wait_receive_realtime_data([(ac1_webxdc_msg, [30]), (ac1_webxdc_msg2, [40])])
wait_receive_realtime_data([(ac2_webxdc_msg, [10]), (ac2_webxdc_msg2, [20])])
def test_no_duplicate_messages(acf, path_to_webxdc): def test_no_duplicate_messages(acfactory, path_to_webxdc):
"""Test that messages are received only once.""" """Test that messages are received only once."""
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("webxdc_realtime_enabled", "1")
ac2.set_config("webxdc_realtime_enabled", "1")
ac1_ac2_chat = ac1.create_chat(ac2) ac1_ac2_chat = ac1.create_chat(ac2)
ac1_webxdc_msg = ac1_ac2_chat.send_message(text="webxdc", file=path_to_webxdc) ac1_webxdc_msg = ac1_ac2_chat.send_message(text="webxdc", file=path_to_webxdc)
@@ -164,29 +174,50 @@ def test_no_duplicate_messages(acf, path_to_webxdc):
ac2_webxdc_msg.send_webxdc_realtime_data.future(b"foobar") ac2_webxdc_msg.send_webxdc_realtime_data.future(b"foobar")
ac2_webxdc_msg.send_webxdc_realtime_advertisement() ac2_webxdc_msg.send_webxdc_realtime_advertisement()
with send_realtime_data_forever([ac1_webxdc_msg], data=[lambda i: str(i).encode()]): def thread_run():
n = int(ac2.wait_for_realtime_data(ac2_webxdc_msg.id).decode()) for i in range(10):
assert int(ac2.wait_for_realtime_data(ac2_webxdc_msg.id).decode()) > n data = str(i).encode()
ac1_webxdc_msg.send_webxdc_realtime_data(data)
time.sleep(1)
threading.Thread(target=thread_run, daemon=True).start()
event = ac2.wait_for_event(EventType.WEBXDC_REALTIME_DATA)
n = int(bytes(event.data).decode())
event = ac2.wait_for_event(EventType.WEBXDC_REALTIME_DATA)
assert int(bytes(event.data).decode()) > n
def test_no_reordering(acf, path_to_webxdc): def test_no_reordering(acfactory, path_to_webxdc):
"""Test that sending a lot of realtime messages does not result in reordering.""" """Test that sending a lot of realtime messages does not result in reordering."""
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
ac1_webxdc_msg, ac2_webxdc_msg = setup_realtime_webxdc(ac1, ac2, path_to_webxdc, wait=True) ac1.set_config("webxdc_realtime_enabled", "1")
ac2.set_config("webxdc_realtime_enabled", "1")
ac1_webxdc_msg, ac2_webxdc_msg = setup_realtime_webxdc(ac1, ac2, path_to_webxdc)
setup_thread_send_realtime_data(ac1_webxdc_msg, b"hello")
wait_receive_realtime_data([(ac2_webxdc_msg, b"hello")])
for i in range(200): for i in range(200):
ac1_webxdc_msg.send_webxdc_realtime_data([i]) ac1_webxdc_msg.send_webxdc_realtime_data([i])
for i in range(200): for i in range(200):
# lingering SETUP_DATA payloads from the wait_realtime_connected() barrier may still arrive while 1:
while (data := ac2.wait_for_realtime_data(ac2_webxdc_msg.id)) == SETUP_DATA: event = ac2.wait_for_event()
pass if event.kind == EventType.WEBXDC_REALTIME_DATA and bytes(event.data) != b"hello":
assert data == bytes([i]), "Reordering detected" if event.data[0] == i:
break
pytest.fail("Reordering detected")
def test_advertisement_after_chatting(acf, path_to_webxdc): def test_advertisement_after_chatting(acfactory, path_to_webxdc):
"""Test that realtime advertisement is assigned to the correct message after chatting.""" """Test that realtime advertisement is assigned to the correct message after chatting."""
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("webxdc_realtime_enabled", "1")
ac2.set_config("webxdc_realtime_enabled", "1")
ac1_ac2_chat = ac1.create_chat(ac2) ac1_ac2_chat = ac1.create_chat(ac2)
ac1_webxdc_msg = ac1_ac2_chat.send_message(text="WebXDC", file=path_to_webxdc) ac1_webxdc_msg = ac1_ac2_chat.send_message(text="WebXDC", file=path_to_webxdc)
ac2_webxdc_msg = ac2.wait_for_incoming_msg() ac2_webxdc_msg = ac2.wait_for_incoming_msg()
@@ -205,14 +236,17 @@ def test_advertisement_after_chatting(acf, path_to_webxdc):
assert event.msg_id == ac1_webxdc_msg.id assert event.msg_id == ac1_webxdc_msg.id
def test_realtime_large_webxdc(acf, path_to_large_webxdc): def test_realtime_large_webxdc(acfactory, path_to_large_webxdc):
"""Tests initializing realtime channel on a large webxdc. """Tests initializing realtime channel on a large webxdc.
This is a regression test for a bug that existed in version 2.42.0. This is a regression test for a bug that existed in version 2.42.0.
Large webxdc is split into pre- and post- message, Large webxdc is split into pre- and post- message,
and this previously resulted in failure to initialize realtime. and this previously resulted in failure to initialize realtime.
""" """
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("webxdc_realtime_enabled", "1")
ac2.set_config("webxdc_realtime_enabled", "1")
ac2.create_chat(ac1) ac2.create_chat(ac1)
ac1_ac2_chat = ac1.create_chat(ac2) ac1_ac2_chat = ac1.create_chat(ac2)
ac1_webxdc_msg = ac1_ac2_chat.send_message(text="realtime check", file=path_to_large_webxdc) ac1_webxdc_msg = ac1_ac2_chat.send_message(text="realtime check", file=path_to_large_webxdc)
+4 -4
View File
@@ -1,15 +1,15 @@
def test_set_location(dc, acf) -> None: def test_set_location(dc, acfactory) -> None:
# Try setting location without any accounts. # Try setting location without any accounts.
assert not dc.set_location(1.0, 2.0, 0.1) assert not dc.set_location(1.0, 2.0, 0.1)
# Create one account that does not stream, # Create one account that does not stream,
# set location. # set location.
acf.new_configured_account() acfactory.new_configured_account()
assert not dc.set_location(3.0, 4.0, 0.1) assert not dc.set_location(3.0, 4.0, 0.1)
def test_send_locations_to_chat(dc, acf): def test_send_locations_to_chat(dc, acfactory):
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
assert not alice.is_sending_locations() assert not alice.is_sending_locations()
alice_chat_bob = alice.create_chat(bob) alice_chat_bob = alice.create_chat(bob)
-33
View File
@@ -1,33 +0,0 @@
import os
import sys
import pytest
def anonymous_mib(pid):
with open(f"/proc/{pid}/smaps_rollup") as f:
for line in f:
if line.startswith("Anonymous:"):
return int(line.split()[1]) // 1024
raise LookupError("Anonymous")
@pytest.mark.skipif(sys.platform != "linux", reason="reads /proc")
def test_attachment_memory_is_returned(acf, rpc, tmp_path):
# See also comments for `tune_malloc` in `deltachat-rpc-server/src/main.rs`
ac1, ac2 = acf.get_online_accounts(2)
chat1 = acf.get_accepted_chat(ac1, ac2)
chat2 = ac2.create_chat(ac1)
blob = tmp_path / "blob.bin"
blob.write_bytes(os.urandom(20 << 20))
before = anonymous_mib(rpc.process.pid)
for sender_chat, receiver in ((chat1, ac2), (chat2, ac1)):
sender_chat.send_file(str(blob))
event = receiver.wait_for_incoming_msg_event()
assert receiver.get_message_by_id(event.msg_id).get_snapshot().file_bytes == 20 << 20
for ac in (ac1, ac2):
rpc.wait_for_all_work_done(ac.id)
grown = anonymous_mib(rpc.process.pid) - before
assert grown < 64, f"the server kept {grown} MiB after two 20 MiB attachments"
+18 -40
View File
@@ -4,8 +4,8 @@ from deltachat_rpc_client import EventType
from deltachat_rpc_client.const import MessageState from deltachat_rpc_client.const import MessageState
def test_bcc_self_is_enabled_when_setting_up_second_device(acf): def test_bcc_self_is_enabled_when_setting_up_second_device(acfactory):
ac = acf.get_online_account() ac = acfactory.get_online_account()
# Initially after getting online # Initially after getting online
# the setting bcc_self is set to 0 because there is only one device # the setting bcc_self is set to 0 because there is only one device
@@ -29,22 +29,27 @@ def test_bcc_self_is_enabled_when_setting_up_second_device(acf):
assert ac.get_config("bcc_self") == "1" assert ac.get_config("bcc_self") == "1"
def test_one_account_send_bcc_setting(acf, log, direct_imap): def test_one_account_send_bcc_setting(acfactory, log, direct_imap):
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone() ac1_clone = ac1.clone()
ac1_clone.bring_online() ac1_clone.bring_online()
log.section("send out message without bcc to ourselves") log.section("send out message without bcc to ourselves")
ac1.set_config("bcc_self", "0") ac1.set_config("bcc_self", "0")
chat = ac1.create_chat(ac2) chat = ac1.create_chat(ac2)
self_addr = ac1.get_config("addr")
other_addr = ac2.get_config("addr")
msg_out = chat.send_text("message1") msg_out = chat.send_text("message1")
assert not msg_out.get_snapshot().is_forwarded assert not msg_out.get_snapshot().is_forwarded
# wait for send out (no BCC) # wait for send out (no BCC)
ac1.wait_for_event(EventType.SMTP_MESSAGE_SENT) ev = ac1.wait_for_event(EventType.SMTP_MESSAGE_SENT)
assert ac1.get_config("bcc_self") == "0" assert ac1.get_config("bcc_self") == "0"
assert self_addr not in ev.msg
assert other_addr in ev.msg
log.section("ac1: setting bcc_self=1") log.section("ac1: setting bcc_self=1")
ac1.set_config("bcc_self", "1") ac1.set_config("bcc_self", "1")
@@ -52,16 +57,20 @@ def test_one_account_send_bcc_setting(acf, log, direct_imap):
msg_out = chat.send_text("message2") msg_out = chat.send_text("message2")
# wait for send out (BCC) # wait for send out (BCC)
ac1.wait_for_event(EventType.SMTP_MESSAGE_SENT) ev = ac1.wait_for_event(EventType.SMTP_MESSAGE_SENT)
assert ac1.get_config("bcc_self") == "1" assert ac1.get_config("bcc_self") == "1"
# Second client receives only the second message, but not the first. # Second client receives only second message, but not the first.
ev_msg = ac1_clone.wait_for_event(EventType.MSGS_CHANGED) ev_msg = ac1_clone.wait_for_event(EventType.MSGS_CHANGED)
assert ac1_clone.get_message_by_id(ev_msg.msg_id).get_snapshot().text == "Messages are end-to-end encrypted." assert ac1_clone.get_message_by_id(ev_msg.msg_id).get_snapshot().text == "Messages are end-to-end encrypted."
ev_msg = ac1_clone.wait_for_event(EventType.MSGS_CHANGED) ev_msg = ac1_clone.wait_for_event(EventType.MSGS_CHANGED)
assert ac1_clone.get_message_by_id(ev_msg.msg_id).get_snapshot().text == msg_out.get_snapshot().text assert ac1_clone.get_message_by_id(ev_msg.msg_id).get_snapshot().text == msg_out.get_snapshot().text
# now make sure we are sending message to ourselves too
assert self_addr in ev.msg
assert self_addr in ev.msg
# BCC-self messages are marked as seen by the sender device. # BCC-self messages are marked as seen by the sender device.
while True: while True:
event = ac1.wait_for_event() event = ac1.wait_for_event()
@@ -75,9 +84,9 @@ def test_one_account_send_bcc_setting(acf, log, direct_imap):
assert len(list(ac1_direct_imap.conn.fetch(AND(seen=True)))) == 1 assert len(list(ac1_direct_imap.conn.fetch(AND(seen=True)))) == 1
def test_multidevice_sync_seen(acf, log): def test_multidevice_sync_seen(acfactory, log):
"""Test that message marked as seen on one device is marked as seen on another.""" """Test that message marked as seen on one device is marked as seen on another."""
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone() ac1_clone = ac1.clone()
ac1_clone.bring_online() ac1_clone.bring_online()
@@ -127,34 +136,3 @@ def test_multidevice_sync_seen(acf, log):
assert ac1_clone_message.get_snapshot().state == MessageState.IN_SEEN assert ac1_clone_message.get_snapshot().state == MessageState.IN_SEEN
# Test that the timer is started on the second device after synchronizing the seen status. # Test that the timer is started on the second device after synchronizing the seen status.
assert "Expires: " in ac1_clone_message.get_info() assert "Expires: " in ac1_clone_message.get_info()
def test_multidevice_sync_seen_mdns_off(acf, log):
"""Test that MDNs to self are sent even if MDNs are disabled."""
ac1, ac2 = acf.get_online_accounts(2)
ac1.set_config("mdns_enabled", "0")
ac1_clone = ac1.clone()
ac1_clone.bring_online()
assert ac1.get_config("bcc_self") == "1"
assert ac1.get_config("mdns_enabled") == "0"
assert ac1_clone.get_config("bcc_self") == "1"
assert ac1_clone.get_config("mdns_enabled") == "0"
ac1.create_chat(ac2)
ac1_clone_chat = ac1_clone.create_chat(ac2)
ac2_chat = ac2.create_chat(ac1)
log.section("Send a message from ac2 to ac1 and check that it's 'fresh'")
ac2_chat.send_text("Hi")
ac1_message = ac1.wait_for_incoming_msg()
ac1_clone_message = ac1_clone.wait_for_incoming_msg()
ac1_message.mark_seen()
assert ac1_message.get_snapshot().state == MessageState.IN_SEEN
log.section("ac1 clone detects that message is marked as seen")
ev = ac1_clone.wait_for_event(EventType.MSGS_NOTICED)
assert ev.chat_id == ac1_clone_chat.id
assert ac1_clone_message.get_snapshot().state == MessageState.IN_SEEN
+80 -174
View File
@@ -1,6 +1,3 @@
import time
import urllib.parse
import pytest import pytest
from deltachat_rpc_client import EventType from deltachat_rpc_client import EventType
@@ -8,33 +5,11 @@ from deltachat_rpc_client.const import ChatType, DownloadState
from deltachat_rpc_client.rpc import JsonRpcError from deltachat_rpc_client.rpc import JsonRpcError
def alice_with_two_transports_and_bob(acf): def test_add_second_address(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) account = acfactory.new_configured_account()
alice.add_transport_from_qr(acf.get_account_qr())
alice.bring_online()
return alice, alice.create_chat(bob), bob.create_chat(alice)
def messages_with_text(chat, text):
return [msg for msg in chat.get_messages() if msg.get_snapshot().text == text]
def wait_for_imap_message(imap):
while not imap.get_all_messages():
time.sleep(1)
def test_init_transports(acf):
account = acf.get_unconfigured_account()
account.init_transports(acf.get_account_qr())
assert len(account.list_transports()) == 1 assert len(account.list_transports()) == 1
qr = acfactory.get_account_qr()
def test_add_second_address(acf) -> None:
account = acf.new_configured_account()
assert len(account.list_transports()) == 1
qr = acf.get_account_qr()
account.add_transport_from_qr(qr) account.add_transport_from_qr(qr)
assert len(account.list_transports()) == 2 assert len(account.list_transports()) == 2
@@ -43,24 +18,20 @@ def test_add_second_address(acf) -> None:
first_addr = account.list_transports()[0]["addr"] first_addr = account.list_transports()[0]["addr"]
second_addr = account.list_transports()[1]["addr"] second_addr = account.list_transports()[1]["addr"]
third_addr = account.list_transports()[2]["addr"]
assert account.get_config("configured_addr") == first_addr # Cannot delete the first address.
account.delete_transport(first_addr) with pytest.raises(JsonRpcError):
assert len(account.list_transports()) == 2 account.delete_transport(first_addr)
assert account.get_config("configured_addr") != first_addr
account.delete_transport(second_addr) account.delete_transport(second_addr)
assert len(account.list_transports()) == 1 assert len(account.list_transports()) == 2
with pytest.raises(JsonRpcError):
account.delete_transport(third_addr)
def test_change_address(acf) -> None: def test_change_address(acfactory) -> None:
"""Test Alice configuring a second transport and removing the first one.""" """Test Alice configuring a second transport and setting it as a primary one."""
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
bob_addr = bob.get_config("configured_addr")
bob.create_chat(alice) bob.create_chat(alice)
alice_chat_bob = alice.create_chat(bob) alice_chat_bob = alice.create_chat(bob)
@@ -70,18 +41,28 @@ def test_change_address(acf) -> None:
sender_addr1 = msg1.sender.get_snapshot().address sender_addr1 = msg1.sender.get_snapshot().address
alice.stop_io() alice.stop_io()
old_alice_addr = alice.list_transports()[0]["addr"] old_alice_addr = alice.get_config("configured_addr")
alice_vcard = alice.self_contact.make_vcard() alice_vcard = alice.self_contact.make_vcard()
assert old_alice_addr in alice_vcard assert old_alice_addr in alice_vcard
qr = acf.get_account_qr() qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr) alice.add_transport_from_qr(qr)
new_alice_addr = alice.list_transports()[1]["addr"] new_alice_addr = alice.list_transports()[1]["addr"]
with pytest.raises(JsonRpcError):
# Cannot use the address that is not
# configured for any transport.
alice.set_config("configured_addr", bob_addr)
alice.delete_transport(old_alice_addr) # Load old address so it is cached.
assert alice.get_config("configured_addr") == old_alice_addr
alice.set_config("configured_addr", new_alice_addr)
# Make sure that setting `configured_addr` invalidated the cache.
assert alice.get_config("configured_addr") == new_alice_addr
alice_vcard = alice.self_contact.make_vcard() alice_vcard = alice.self_contact.make_vcard()
assert old_alice_addr not in alice_vcard assert old_alice_addr not in alice_vcard
assert new_alice_addr in alice_vcard assert new_alice_addr in alice_vcard
with pytest.raises(JsonRpcError):
alice.delete_transport(new_alice_addr)
alice.start_io() alice.start_io()
alice_chat_bob.send_text("Hello again!") alice_chat_bob.send_text("Hello again!")
@@ -95,37 +76,18 @@ def test_change_address(acf) -> None:
assert sender_addr2 == new_alice_addr assert sender_addr2 == new_alice_addr
def test_remove_transport_keep_messages(acf) -> None: def test_download_on_demand(acfactory) -> None:
"""Test that deleting current sending transport keeps queued messages.""" alice, bob = acfactory.get_online_accounts(2)
alice, bob = acf.get_online_accounts(2)
qr = acf.get_account_qr()
alice.add_transport_from_qr(qr)
alice.stop_io()
alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_text("Hello!")
new_alice_addr = alice.list_transports()[1]["addr"]
alice.delete_transport(alice.list_transports()[0]["addr"])
alice.start_io()
bob_msg = bob.wait_for_incoming_msg().get_snapshot()
assert bob_msg.sender.get_snapshot().address == new_alice_addr
def test_download_on_demand(acf, rpcdata) -> None:
alice, bob = acf.get_online_accounts(2)
alice.set_config("download_limit", "1") alice.set_config("download_limit", "1")
alice.stop_io() alice.stop_io()
qr = acf.get_account_qr() qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr) alice.add_transport_from_qr(qr)
alice.start_io() alice.start_io()
alice.create_chat(bob) alice.create_chat(bob)
chat_bob_alice = bob.create_chat(alice) chat_bob_alice = bob.create_chat(alice)
chat_bob_alice.send_message(file=rpcdata.get_path("image/screenshot.jpg")) chat_bob_alice.send_message(file="../test-data/image/screenshot.jpg")
msg = alice.wait_for_incoming_msg() msg = alice.wait_for_incoming_msg()
snapshot = msg.get_snapshot() snapshot = msg.get_snapshot()
assert snapshot.download_state == DownloadState.AVAILABLE assert snapshot.download_state == DownloadState.AVAILABLE
@@ -141,15 +103,15 @@ def test_download_on_demand(acf, rpcdata) -> None:
assert msg.get_snapshot().download_state == dstate assert msg.get_snapshot().download_state == dstate
def test_reconfigure_transport(acf) -> None: def test_reconfigure_transport(acfactory) -> None:
"""Test that reconfiguring the transport works.""" """Test that reconfiguring the transport works."""
account = acf.get_online_account() account = acfactory.get_online_account()
[transport] = account.list_transports() [transport] = account.list_transports()
account.add_or_update_transport(transport) account.add_or_update_transport(transport)
def test_transport_synchronization(acf, log) -> None: def test_transport_synchronization(acfactory, log) -> None:
"""Test synchronization of transports between devices.""" """Test synchronization of transports between devices."""
def wait_for_io_started(ac): def wait_for_io_started(ac):
@@ -158,24 +120,22 @@ def test_transport_synchronization(acf, log) -> None:
if "scheduler is running" in ev.msg: if "scheduler is running" in ev.msg:
return return
def wait_transports(ac, n): ac1, ac2 = acfactory.get_online_accounts(2)
while len(ac.list_transports()) != n:
ac.wait_for_event(EventType.TRANSPORTS_MODIFIED)
ac1, ac2 = acf.get_online_accounts(2)
ac1_clone = ac1.clone() ac1_clone = ac1.clone()
ac1_clone.bring_online() ac1_clone.bring_online()
qr = acf.get_account_qr() qr = acfactory.get_account_qr()
ac1.add_transport_from_qr(qr) ac1.add_transport_from_qr(qr)
wait_transports(ac1_clone, 2) ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1_clone) wait_for_io_started(ac1_clone)
assert len(ac1.list_transports()) == 2 assert len(ac1.list_transports()) == 2
assert len(ac1_clone.list_transports()) == 2
ac1_clone.add_transport_from_qr(qr) ac1_clone.add_transport_from_qr(qr)
wait_transports(ac1, 3) ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1) wait_for_io_started(ac1)
assert len(ac1.list_transports()) == 3
assert len(ac1_clone.list_transports()) == 3 assert len(ac1_clone.list_transports()) == 3
log.section("ac1 clone removes second transport") log.section("ac1 clone removes second transport")
@@ -183,17 +143,24 @@ def test_transport_synchronization(acf, log) -> None:
addr3 = transport3["addr"] addr3 = transport3["addr"]
ac1_clone.delete_transport(transport2["addr"]) ac1_clone.delete_transport(transport2["addr"])
wait_transports(ac1, 2) ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1) wait_for_io_started(ac1)
[transport1, transport3] = ac1.list_transports() [transport1, transport3] = ac1.list_transports()
log.section("ac1 changes the sending transport") log.section("ac1 changes the primary transport")
ac1.set_config("configured_addr", transport3["addr"]) ac1.set_config("configured_addr", transport3["addr"])
# One event for updated `add_timestamp` of the new primary transport,
# one event for the `configured_addr` update.
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
[transport1, transport3] = ac1_clone.list_transports()
assert ac1_clone.get_config("configured_addr") == addr3
log.section("ac1 removes the first transport") log.section("ac1 removes the first transport")
ac1.delete_transport(transport1["addr"]) ac1.delete_transport(transport1["addr"])
wait_transports(ac1_clone, 1) ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
wait_for_io_started(ac1_clone) wait_for_io_started(ac1_clone)
[transport3] = ac1_clone.list_transports() [transport3] = ac1_clone.list_transports()
assert transport3["addr"] == addr3 assert transport3["addr"] == addr3
@@ -206,16 +173,15 @@ def test_transport_synchronization(acf, log) -> None:
assert ac1_clone.wait_for_incoming_msg().get_snapshot().text == "Hello!" assert ac1_clone.wait_for_incoming_msg().get_snapshot().text == "Hello!"
def test_transport_sync_new_as_primary(acf, log) -> None: def test_transport_sync_new_as_primary(acfactory, log) -> None:
"""Test that a transport promoted on one device is usable on other devices.""" """Test synchronization of new transport as primary between devices."""
ac1, bob = acf.get_online_accounts(2) ac1, bob = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone() ac1_clone = ac1.clone()
ac1_clone.bring_online() ac1_clone.bring_online()
qr = acf.get_account_qr() qr = acfactory.get_account_qr()
ac1.add_transport_from_qr(qr) ac1.add_transport_from_qr(qr)
ac1.wait_for_event(EventType.TRANSPORTS_MODIFIED)
ac1_transports = ac1.list_transports() ac1_transports = ac1.list_transports()
assert len(ac1_transports) == 2 assert len(ac1_transports) == 2
[transport1, transport2] = ac1_transports [transport1, transport2] = ac1_transports
@@ -226,7 +192,10 @@ def test_transport_sync_new_as_primary(acf, log) -> None:
log.section("ac1 changes the primary transport") log.section("ac1 changes the primary transport")
ac1.set_config("configured_addr", transport2["addr"]) ac1.set_config("configured_addr", transport2["addr"])
log.section("ac1_clone receives a message via the new transport") ac1_clone.wait_for_event(EventType.TRANSPORTS_MODIFIED)
assert ac1_clone.get_config("configured_addr") == transport2["addr"]
log.section("ac1_clone receives a message via the new primary transport")
ac1_chat = ac1.create_chat(bob) ac1_chat = ac1.create_chat(bob)
ac1_chat.send_text("Hello!") ac1_chat.send_text("Hello!")
bob_chat_id = bob.wait_for_incoming_msg_event().chat_id bob_chat_id = bob.wait_for_incoming_msg_event().chat_id
@@ -236,12 +205,12 @@ def test_transport_sync_new_as_primary(acf, log) -> None:
assert ac1_clone.wait_for_incoming_msg().get_snapshot().text == "hello back" assert ac1_clone.wait_for_incoming_msg().get_snapshot().text == "hello back"
def test_recognize_self_address(acf) -> None: def test_recognize_self_address(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
bob_chat = bob.create_chat(alice) bob_chat = bob.create_chat(alice)
qr = acf.get_account_qr() qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr) alice.add_transport_from_qr(qr)
new_alice_addr = alice.list_transports()[1]["addr"] new_alice_addr = alice.list_transports()[1]["addr"]
@@ -252,10 +221,10 @@ def test_recognize_self_address(acf) -> None:
assert msg.chat == alice.create_chat(bob) assert msg.chat == alice.create_chat(bob)
def test_transport_limit(acf) -> None: def test_transport_limit(acfactory) -> None:
"""Test transports limit.""" """Test transports limit."""
account = acf.get_online_account() account = acfactory.get_online_account()
qr = acf.get_account_qr() qr = acfactory.get_account_qr()
limit = 5 limit = 5
@@ -268,22 +237,22 @@ def test_transport_limit(acf) -> None:
account.add_transport_from_qr(qr) account.add_transport_from_qr(qr)
second_addr = account.list_transports()[1]["addr"] second_addr = account.list_transports()[1]["addr"]
account.delete_transport(second_addr) account.delete_transport(second_addr)
# test that adding a transport after deleting one works again
account.add_transport_from_qr(qr) account.add_transport_from_qr(qr)
with pytest.raises(JsonRpcError):
account.add_transport_from_qr(qr)
def test_message_info_imap_urls(acf) -> None: def test_message_info_imap_urls(acfactory) -> None:
"""Test that message info contains IMAP URLs of where the message was received.""" """Test that message info contains IMAP URLs of where the message was received."""
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
qr = acf.get_account_qr() qr = acfactory.get_account_qr()
for _ in range(3): for i in range(3):
alice.add_transport_from_qr(qr) alice.add_transport_from_qr(qr)
# Wait for all transports to go IDLE after adding each one. # Wait for all transports to go IDLE after adding each one.
alice.bring_online() for _ in range(i + 1):
alice.bring_online()
# Enable multi-device mode so messages are not deleted immediately. # Enable multi-device mode so messages are not deleted immediately.
alice.set_config("bcc_self", "1") alice.set_config("bcc_self", "1")
@@ -313,12 +282,20 @@ def test_message_info_imap_urls(acf) -> None:
assert f"{new_alice_addr}/INBOX" in msg_info assert f"{new_alice_addr}/INBOX" in msg_info
def test_remove_primary_transport(acf, log) -> None: def test_remove_primary_transport(acfactory, log) -> None:
"""Test that after removing the primary relay, Alice can still receive messages.""" """Test that after removing the primary relay, Alice can still receive messages."""
alice, alice_chat, bob_chat = alice_with_two_transports_and_bob(acf) alice, bob = acfactory.get_online_accounts(2)
qr = acfactory.get_account_qr()
alice.add_transport_from_qr(qr)
alice.bring_online()
bob_chat = bob.create_chat(alice)
alice.create_chat(bob)
log.section("Alice sets up second transport") log.section("Alice sets up second transport")
[transport1, transport2] = alice.list_transports() [transport1, transport2] = alice.list_transports()
alice.set_config("configured_addr", transport2["addr"])
bob_chat.send_text("Hello!") bob_chat.send_text("Hello!")
msg1 = alice.wait_for_incoming_msg().get_snapshot() msg1 = alice.wait_for_incoming_msg().get_snapshot()
@@ -326,7 +303,6 @@ def test_remove_primary_transport(acf, log) -> None:
log.section("Alice removes the primary relay") log.section("Alice removes the primary relay")
alice.delete_transport(transport1["addr"]) alice.delete_transport(transport1["addr"])
assert alice.get_config("configured_addr") == transport2["addr"]
alice.stop_io() alice.stop_io()
alice.start_io() alice.start_io()
@@ -334,74 +310,4 @@ def test_remove_primary_transport(acf, log) -> None:
msg2 = alice.wait_for_incoming_msg().get_snapshot() msg2 = alice.wait_for_incoming_msg().get_snapshot()
assert msg2.text == "Hello again!" assert msg2.text == "Hello again!"
assert msg2.chat.get_basic_snapshot().chat_type == ChatType.SINGLE assert msg2.chat.get_basic_snapshot().chat_type == ChatType.SINGLE
assert msg2.chat == alice_chat assert msg2.chat == alice.create_chat(bob)
def test_qr_works_after_removing_primary_transport(acf, log) -> None:
log.section("Alice setups an account and adds two additional relays")
alice = acf.new_configured_account()
relay_qr = acf.get_account_qr()
alice.add_transport_from_qr(relay_qr)
alice.add_transport_from_qr(relay_qr)
first_addr = alice.list_transports()[0]["addr"]
second_addr = alice.list_transports()[1]["addr"]
third_addr = alice.list_transports()[2]["addr"]
log.section("Alice creates a QR code")
chat_qr = alice.get_qr_code()
chat_qr_unquoted = urllib.parse.unquote(chat_qr)
assert f"&a={third_addr}" in chat_qr_unquoted
assert f"&r={second_addr},{first_addr}" in chat_qr_unquoted
log.section("Alice removes the transport named by the a= parameter")
alice.delete_transport(third_addr)
alice.delete_transport(second_addr)
log.section("Bob scans the QR code, which still works")
alice.bring_online()
bob = acf.get_online_account()
bob.secure_join(chat_qr)
alice.wait_for_securejoin_inviter_success()
bob.wait_for_securejoin_joiner_success()
def test_background_fetch_from_second_transport(acf, direct_imap, dc):
alice, alice_chat, bob_chat = alice_with_two_transports_and_bob(acf)
[transport1, transport2] = alice.list_transports()
assert alice.get_config("configured_addr") == transport1["addr"]
alice.stop_io()
bob_chat.send_text("hello")
imap1 = direct_imap(alice, transport1["addr"], transport1["password"])
wait_for_imap_message(direct_imap(alice, transport2["addr"], transport2["password"]))
wait_for_imap_message(imap1)
# Leave the message on the second transport only.
imap1.delete("1:*")
dc.background_fetch(300)
assert len(messages_with_text(alice_chat, "hello")) == 1
def test_background_fetch_no_duplicates(acf, direct_imap, dc):
alice, alice_chat, bob_chat = alice_with_two_transports_and_bob(acf)
alice.stop_io()
bob_chat.send_text("hello")
for transport in alice.list_transports():
wait_for_imap_message(direct_imap(alice, transport["addr"], transport["password"]))
dc.background_fetch(300)
assert len(messages_with_text(alice_chat, "hello")) == 1
def test_multitransport_mdn(acf):
"""Test sending an MDN right after configuring two transports."""
alice, bob = acf.get_online_accounts(2)
alice.add_transport_from_qr(acf.get_account_qr())
alice.bring_online()
alice.create_chat(bob)
bob_msg = bob.create_chat(alice).send_text("Hello!")
alice.wait_for_incoming_msg().mark_seen()
assert bob.wait_for_event(EventType.MSG_READ).msg_id == bob_msg.id
+172 -127
View File
@@ -1,34 +1,52 @@
import logging import logging
import time
import pytest import pytest
from deltachat_rpc_client import Chat, EventType, SpecialContactId from deltachat_rpc_client import Chat, EventType, SpecialContactId
from deltachat_rpc_client.const import ChatType from deltachat_rpc_client.const import ChatType
from deltachat_rpc_client.rpc import JsonRpcError
def test_qr_setup_contact(acf) -> None: def test_qr_setup_contact(acfactory, tmp_path) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
qr_code = alice.get_qr_code() qr_code = alice.get_qr_code()
bob.secure_join(qr_code) bob.secure_join(qr_code)
alice.wait_for_securejoin_inviter_success() alice.wait_for_securejoin_inviter_success()
# Test that Alice verified Bob's profile.
alice_contact_bob = alice.create_contact(bob) alice_contact_bob = alice.create_contact(bob)
alice_contact_bob_snapshot = alice_contact_bob.get_snapshot() alice_contact_bob_snapshot = alice_contact_bob.get_snapshot()
assert alice_contact_bob_snapshot.e2ee_avail assert alice_contact_bob_snapshot.is_verified
bob.wait_for_securejoin_joiner_success() bob.wait_for_securejoin_joiner_success()
# Test that Bob verified Alice's profile.
bob_contact_alice = bob.create_contact(alice) bob_contact_alice = bob.create_contact(alice)
bob_contact_alice_snapshot = bob_contact_alice.get_snapshot() bob_contact_alice_snapshot = bob_contact_alice.get_snapshot()
assert bob_contact_alice_snapshot.e2ee_avail assert bob_contact_alice_snapshot.is_verified
# Test that if Bob imports a key,
# backwards verification is not lost
# because default key is not changed.
logging.info("Bob 2 is created")
bob2 = acfactory.new_configured_account()
bob2.export_self_keys(tmp_path)
logging.info("Bob tries to import a key")
# Importing a second key is not allowed.
with pytest.raises(JsonRpcError):
bob.import_self_keys(tmp_path)
assert bob.get_config("key_id") == "1"
bob_contact_alice_snapshot = bob_contact_alice.get_snapshot()
assert bob_contact_alice_snapshot.is_verified
def test_qr_setup_contact_svg(acf) -> None: def test_qr_setup_contact_svg(acfactory) -> None:
alice = acf.new_configured_account() alice = acfactory.new_configured_account()
_, _, domain = alice.list_transports()[0]["addr"].rpartition("@") _, _, domain = alice.get_config("addr").rpartition("@")
_qr_code, svg = alice.get_qr_code_svg() _qr_code, svg = alice.get_qr_code_svg()
@@ -41,10 +59,9 @@ def test_qr_setup_contact_svg(acf) -> None:
assert "Alice" in svg assert "Alice" in svg
def test_qr_securejoin(acf): def test_qr_securejoin(acfactory):
alice, bob, fiona = acf.get_online_accounts(3) alice, bob, fiona = acfactory.get_online_accounts(3)
alice.set_config("displayname", "Alice")
# Setup second device for Alice # Setup second device for Alice
# to test observing securejoin protocol. # to test observing securejoin protocol.
alice2 = alice.clone() alice2 = alice.clone()
@@ -64,24 +81,26 @@ def test_qr_securejoin(acf):
ac.wait_for_event(EventType.IMAP_MESSAGE_DELETED) ac.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
bob.wait_for_securejoin_joiner_success() bob.wait_for_securejoin_joiner_success()
# Test that Alice verified Bob's profile.
alice_contact_bob = alice.create_contact(bob) alice_contact_bob = alice.create_contact(bob)
alice_contact_bob_snapshot = alice_contact_bob.get_snapshot() alice_contact_bob_snapshot = alice_contact_bob.get_snapshot()
assert alice_contact_bob_snapshot.e2ee_avail assert alice_contact_bob_snapshot.is_verified
snapshot = bob.wait_for_incoming_msg().get_snapshot() snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You were added by Alice." assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr"))
# Test that Bob verified Alice's profile.
bob_contact_alice = bob.create_contact(alice) bob_contact_alice = bob.create_contact(alice)
bob_contact_alice_snapshot = bob_contact_alice.get_snapshot() bob_contact_alice_snapshot = bob_contact_alice.get_snapshot()
assert bob_contact_alice_snapshot.e2ee_avail assert bob_contact_alice_snapshot.is_verified
# Start second Alice device. # Start second Alice device.
# Alice observes the securejoin protocol on the second device. # Alice observes securejoin protocol and verifies Bob on second device.
alice2.start_io() alice2.start_io()
alice2.wait_for_securejoin_inviter_success() alice2.wait_for_securejoin_inviter_success()
alice2_contact_bob = alice2.create_contact(bob) alice2_contact_bob = alice2.create_contact(bob)
alice2_contact_bob_snapshot = alice2_contact_bob.get_snapshot() alice2_contact_bob_snapshot = alice2_contact_bob.get_snapshot()
assert alice2_contact_bob_snapshot.e2ee_avail assert alice2_contact_bob_snapshot.is_verified
# The QR code token is synced, so alice2 must be able to handle join requests. # The QR code token is synced, so alice2 must be able to handle join requests.
logging.info("Fiona joins the group via alice2") logging.info("Fiona joins the group via alice2")
@@ -91,33 +110,9 @@ def test_qr_securejoin(acf):
fiona.wait_for_securejoin_joiner_success() fiona.wait_for_securejoin_joiner_success()
def test_qr_securejoin_request_deleted_on_all_transports(acf):
alice, bob = acf.get_online_accounts(2)
alice.add_transport_from_qr(acf.get_account_qr())
alice.bring_online()
alice_chat = alice.create_group("Group")
qr_code = alice_chat.get_qr_code()
alice2 = alice.clone()
bob.secure_join(qr_code)
alice.wait_for_securejoin_inviter_success()
alice_chat.remove_contact(bob)
# Stop and start io in order to wait until the remove message is sent out.
alice.stop_io()
alice.bring_online()
# Membership timestamps have a resolution of one second.
time.sleep(1)
# By now, the securejoin message sent by Bob must be deleted on Alice's relays.
# Otherwise, alice2 would execute securejoin again and re-add Bob.
alice2.bring_online()
alice2_chat = alice2.get_chat_by_id(alice_chat.id)
assert alice2.create_contact(bob) not in alice2_chat.get_contacts()
@pytest.mark.parametrize("all_devices_online", [True, False]) @pytest.mark.parametrize("all_devices_online", [True, False])
def test_qr_securejoin_broadcast(acf, all_devices_online): def test_qr_securejoin_broadcast(acfactory, all_devices_online):
alice, bob, fiona = acf.get_online_accounts(3) alice, bob, fiona = acfactory.get_online_accounts(3)
alice2 = alice.clone() alice2 = alice.clone()
bob2 = bob.clone() bob2 = bob.clone()
@@ -156,9 +151,9 @@ def test_qr_securejoin_broadcast(acf, all_devices_online):
assert snapshot2.chat_id == chat.id assert snapshot2.chat_id == chat.id
def check_account(ac, contact, inviter_side, please_wait_info_msg=False): def check_account(ac, contact, inviter_side, please_wait_info_msg=False):
# Check that the chat partner's key is known. # Check that the chat partner is verified.
contact_snapshot = contact.get_snapshot() contact_snapshot = contact.get_snapshot()
assert contact_snapshot.e2ee_avail assert contact_snapshot.is_verified
chat = get_broadcast(ac) chat = get_broadcast(ac)
chat_msgs = chat.get_messages() chat_msgs = chat.get_messages()
@@ -172,13 +167,17 @@ def test_qr_securejoin_broadcast(acf, all_devices_online):
assert "invited you to join this channel" in first_msg.text assert "invited you to join this channel" in first_msg.text
assert first_msg.is_info assert first_msg.is_info
if not inviter_side: if inviter_side:
member_added_msg = chat_msgs.pop(0).get_snapshot()
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
assert member_added_msg.info_contact_id == contact_snapshot.id
else:
if chat_msgs[0].get_snapshot().text == "You joined the channel.": if chat_msgs[0].get_snapshot().text == "You joined the channel.":
member_added_msg = chat_msgs.pop(0).get_snapshot() member_added_msg = chat_msgs.pop(0).get_snapshot()
else: else:
member_added_msg = chat_msgs.pop(1).get_snapshot() member_added_msg = chat_msgs.pop(1).get_snapshot()
assert member_added_msg.text == "You joined the channel." assert member_added_msg.text == "You joined the channel."
assert member_added_msg.is_info assert member_added_msg.is_info
hello_msg = chat_msgs.pop(0).get_snapshot() hello_msg = chat_msgs.pop(0).get_snapshot()
assert hello_msg.text == "Hello everyone!" assert hello_msg.text == "Hello everyone!"
@@ -243,7 +242,7 @@ def test_qr_securejoin_broadcast(acf, all_devices_online):
snapshot = fiona.wait_for_incoming_msg().get_snapshot() snapshot = fiona.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You joined the channel." assert snapshot.text == "You joined the channel."
get_broadcast(alice2).get_messages()[1].resend() get_broadcast(alice2).get_messages()[2].resend()
snapshot = fiona.wait_for_incoming_msg().get_snapshot() snapshot = fiona.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello everyone!" assert snapshot.text == "Hello everyone!"
@@ -253,9 +252,9 @@ def test_qr_securejoin_broadcast(acf, all_devices_online):
check_account(bob, bob.create_contact(alice), inviter_side=False, please_wait_info_msg=True) check_account(bob, bob.create_contact(alice), inviter_side=False, please_wait_info_msg=True)
def test_qr_securejoin_contact_request(acf) -> None: def test_qr_securejoin_contact_request(acfactory) -> None:
"""Alice invites Bob to a group when Bob's chat with Alice is in a contact request mode.""" """Alice invites Bob to a group when Bob's chat with Alice is in a contact request mode."""
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob = alice_contact_bob.create_chat()
@@ -279,8 +278,8 @@ def test_qr_securejoin_contact_request(acf) -> None:
assert bob_chat_alice.get_basic_snapshot().is_contact_request assert bob_chat_alice.get_basic_snapshot().is_contact_request
def test_qr_readreceipt(acf) -> None: def test_qr_readreceipt(acfactory) -> None:
alice, bob, charlie = acf.get_online_accounts(3) alice, bob, charlie = acfactory.get_online_accounts(3)
logging.info("Bob and Charlie setup contact with Alice") logging.info("Bob and Charlie setup contact with Alice")
qr_code = alice.get_qr_code() qr_code = alice.get_qr_code()
@@ -336,24 +335,24 @@ def test_qr_readreceipt(acf) -> None:
assert not bob.get_chat_by_contact(bob_contact_charlie) assert not bob.get_chat_by_contact(bob_contact_charlie)
def test_setup_contact_resetup(acf) -> None: def test_setup_contact_resetup(acfactory) -> None:
"""Tests that setup contact works after Alice resets the device and changes the key.""" """Tests that setup contact works after Alice resets the device and changes the key."""
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
qr_code = alice.get_qr_code() qr_code = alice.get_qr_code()
bob.secure_join(qr_code) bob.secure_join(qr_code)
bob.wait_for_securejoin_joiner_success() bob.wait_for_securejoin_joiner_success()
alice = acf.resetup_account(alice) alice = acfactory.resetup_account(alice)
qr_code = alice.get_qr_code() qr_code = alice.get_qr_code()
bob.secure_join(qr_code) bob.secure_join(qr_code)
bob.wait_for_securejoin_joiner_success() bob.wait_for_securejoin_joiner_success()
def test_group_member_added_recovery(acf) -> None: def test_verified_group_member_added_recovery(acfactory) -> None:
"""Tests group recovery after a member resets its key.""" """Tests verified group recovery by reverifying then removing and adding a member back."""
ac1, ac2, ac3 = acf.get_online_accounts(3) ac1, ac2, ac3 = acfactory.get_online_accounts(3)
logging.info("ac1 creates a group") logging.info("ac1 creates a group")
chat = ac1.create_group("Group") chat = ac1.create_group("Group")
@@ -363,7 +362,11 @@ def test_group_member_added_recovery(acf) -> None:
ac2.secure_join(qr_code) ac2.secure_join(qr_code)
ac2.wait_for_securejoin_joiner_success() ac2.wait_for_securejoin_joiner_success()
logging.info("ac3 joins the group") # ac1 has ac2 directly verified.
ac1_contact_ac2 = ac1.create_contact(ac2)
assert ac1_contact_ac2.get_snapshot().verifier_id == SpecialContactId.SELF
logging.info("ac3 joins verified group")
ac3_chat = ac3.secure_join(qr_code) ac3_chat = ac3.secure_join(qr_code)
ac3.wait_for_securejoin_joiner_success() ac3.wait_for_securejoin_joiner_success()
ac3.wait_for_incoming_msg_event() # Member added ac3.wait_for_incoming_msg_event() # Member added
@@ -371,9 +374,9 @@ def test_group_member_added_recovery(acf) -> None:
ac3_contact_ac2_old = ac3.create_contact(ac2) ac3_contact_ac2_old = ac3.create_contact(ac2)
logging.info("ac2 logs in on a new device") logging.info("ac2 logs in on a new device")
ac2 = acf.resetup_account(ac2) ac2 = acfactory.resetup_account(ac2)
logging.info("ac2 scans ac3's QR code again") logging.info("ac2 reverifies with ac3")
qr_code = ac3.get_qr_code() qr_code = ac3.get_qr_code()
ac2.secure_join(qr_code) ac2.secure_join(qr_code)
ac2.wait_for_securejoin_joiner_success() ac2.wait_for_securejoin_joiner_success()
@@ -413,20 +416,28 @@ def test_group_member_added_recovery(acf) -> None:
snapshot = ac1.wait_for_incoming_msg().get_snapshot() snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Works again!" assert snapshot.text == "Works again!"
ac1_contact_ac2 = ac1.create_contact(ac2)
ac1_contact_ac3 = ac1.create_contact(ac3)
ac1_contact_ac2_snapshot = ac1_contact_ac2.get_snapshot()
# Until we reset verifications and then send the _verified header,
# verification is not gossiped here:
assert not ac1_contact_ac2_snapshot.is_verified
assert ac1_contact_ac2_snapshot.verifier_id != ac1_contact_ac3.id
def test_qr_join_chat_with_pending_bobstate_issue4894(acf):
def test_qr_join_chat_with_pending_bobstate_issue4894(acfactory):
"""Regression test for """Regression test for
issue <https://github.com/chatmail/core/issues/4894>. issue <https://github.com/chatmail/core/issues/4894>.
""" """
ac1, ac2, ac3, ac4 = acf.get_online_accounts(4) ac1, ac2, ac3, ac4 = acfactory.get_online_accounts(4)
logging.info("ac3: set up contact with ac2") logging.info("ac3: verify with ac2")
qr_code = ac2.get_qr_code() qr_code = ac2.get_qr_code()
ac3.secure_join(qr_code) ac3.secure_join(qr_code)
ac2.wait_for_securejoin_inviter_success() ac2.wait_for_securejoin_inviter_success()
# in order for ac2 to have pending bobstate with a group # in order for ac2 to have pending bobstate with a verified group
# we first create a fully joined group, and then start # we first create a fully joined verified group, and then start
# joining a second time but interrupt it, to create pending bob state # joining a second time but interrupt it, to create pending bob state
logging.info("ac1: create a group that ac2 fully joins") logging.info("ac1: create a group that ac2 fully joins")
@@ -435,7 +446,7 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acf):
ac2.secure_join(qr_code) ac2.secure_join(qr_code)
ac1.wait_for_securejoin_inviter_success() ac1.wait_for_securejoin_inviter_success()
# ensure ac1 can write and ac2 receives messages in the chat # ensure ac1 can write and ac2 receives messages in verified chat
ch1.send_text("ac1 says hello") ch1.send_text("ac1 says hello")
while 1: while 1:
snapshot = ac2.wait_for_incoming_msg().get_snapshot() snapshot = ac2.wait_for_incoming_msg().get_snapshot()
@@ -448,11 +459,11 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acf):
ac1.remove() ac1.remove()
logging.info("ac2 now has pending bobstate but ac1 is shutoff") logging.info("ac2 now has pending bobstate but ac1 is shutoff")
# we meanwhile expect the ac3/ac2 setup-contact started in the beginning to have completed # we meanwhile expect ac3/ac2 verification started in the beginning to have completed
assert ac3.create_contact(ac2).get_snapshot().e2ee_avail assert ac3.create_contact(ac2).get_snapshot().is_verified
assert ac2.create_contact(ac3).get_snapshot().e2ee_avail assert ac2.create_contact(ac3).get_snapshot().is_verified
logging.info("ac3: create a group VG with ac2") logging.info("ac3: create a verified group VG with ac2")
vg = ac3.create_group("ac3-created") vg = ac3.create_group("ac3-created")
vg.add_contact(ac3.create_contact(ac2)) vg.add_contact(ac3.create_contact(ac2))
@@ -473,17 +484,17 @@ def test_qr_join_chat_with_pending_bobstate_issue4894(acf):
return return
def test_qr_new_group_unblocked(acf): def test_qr_new_group_unblocked(acfactory):
"""Regression test for a bug introduced in core v1.113.0. """Regression test for a bug introduced in core v1.113.0.
ac2 scans a group QR code created by ac1. ac2 scans a verified group QR code created by ac1.
This results in creation of a blocked single chat with ac1 on ac2, This results in creation of a blocked 1:1 chat with ac1 on ac2,
but ac1 contact is not blocked on ac2. but ac1 contact is not blocked on ac2.
Then ac1 creates a group, adds ac2 there and promotes it by sending a message. Then ac1 creates a group, adds ac2 there and promotes it by sending a message.
ac2 should receive a message and create a contact request for the group. ac2 should receive a message and create a contact request for the group.
Due to a bug previously ac2 created a blocked group. Due to a bug previously ac2 created a blocked group.
""" """
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
ac1_chat = ac1.create_group("Group for joining") ac1_chat = ac1.create_group("Group for joining")
qr_code = ac1_chat.get_qr_code() qr_code = ac1_chat.get_qr_code()
ac2.secure_join(qr_code) ac2.secure_join(qr_code)
@@ -502,13 +513,13 @@ def test_qr_new_group_unblocked(acf):
@pytest.mark.skip(reason="AEAP is disabled for now") @pytest.mark.skip(reason="AEAP is disabled for now")
def test_aeap_flow(acf): def test_aeap_flow_verified(acfactory):
"""Test that a new address is added to a contact when it changes its address.""" """Test that a new address is added to a contact when it changes its address."""
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
addr, password = acf.get_credentials() addr, password = acfactory.get_credentials()
logging.info("ac1: create group QR, ac2 scans and joins") logging.info("ac1: create verified-group QR, ac2 scans and joins")
chat = ac1.create_group("hello") chat = ac1.create_group("hello")
qr_code = chat.get_qr_code() qr_code = chat.get_qr_code()
logging.info("ac2: start QR-code based join-group protocol") logging.info("ac2: start QR-code based join-group protocol")
@@ -525,9 +536,11 @@ def test_aeap_flow(acf):
assert msg_in_1.text == msg_out.text assert msg_in_1.text == msg_out.text
logging.info("changing email account") logging.info("changing email account")
old_addr = ac1.list_transports()[0]["addr"] ac1.set_config("addr", addr)
ac1.add_transport_from_qr(acf.get_account_qr()) ac1.set_config("mail_pw", password)
ac1.delete_transport(old_addr) ac1.stop_io()
ac1.configure()
ac1.start_io()
logging.info("sending second message") logging.info("sending second message")
msg_out = chat.send_text("changed address").get_snapshot() msg_out = chat.send_text("changed address").get_snapshot()
@@ -542,16 +555,65 @@ def test_aeap_flow(acf):
assert addr in [contact.get_snapshot().address for contact in msg_in_2_snapshot.chat.get_contacts()] assert addr in [contact.get_snapshot().address for contact in msg_in_2_snapshot.chat.get_contacts()]
def test_securejoin_after_contact_resetup(acf) -> None: def test_gossip_verification(acfactory) -> None:
""" alice, bob, carol = acfactory.get_online_accounts(3)
Regression test for a bug that prevented joining a group with a QR code
if the group already contains a contact with the same address as the inviter,
but different key fingerprint while a securejoin with that contact is still pending.
"""
ac1, ac2, ac3 = acf.get_online_accounts(3)
ac3.set_config("displayname", "ac3")
# ac3 creates a group with ac1. # Bob verifies Alice.
qr_code = alice.get_qr_code()
bob.secure_join(qr_code)
bob.wait_for_securejoin_joiner_success()
# Bob verifies Carol.
qr_code = carol.get_qr_code()
bob.secure_join(qr_code)
bob.wait_for_securejoin_joiner_success()
bob_contact_alice = bob.create_contact(alice, "Alice")
bob_contact_carol = bob.create_contact(carol, "Carol")
carol_contact_alice = carol.create_contact(alice, "Alice")
logging.info("Bob creates an Autocrypt group")
bob_group_chat = bob.create_group("Autocrypt Group")
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")
snapshot = carol.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Autocrypt group"
assert snapshot.show_padlock
# Group propagates verification using Autocrypt-Gossip header.
carol_contact_alice_snapshot = carol_contact_alice.get_snapshot()
# Until we reset verifications and then send the _verified header,
# verification is not gossiped here:
assert not carol_contact_alice_snapshot.is_verified
logging.info("Bob creates a Securejoin group")
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")
snapshot = carol.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "Hello Securejoin group"
assert snapshot.show_padlock
# Securejoin propagates verification.
carol_contact_alice_snapshot = carol_contact_alice.get_snapshot()
# Until we reset verifications and then send the _verified header,
# verification is not gossiped here:
assert not carol_contact_alice_snapshot.is_verified
def test_securejoin_after_contact_resetup(acfactory) -> None:
"""
Regression test for a bug that prevented joining verified group with a QR code
if the group is already created and contains
a contact with inconsistent (Autocrypt and verified keys exist but don't match) key state.
"""
ac1, ac2, ac3 = acfactory.get_online_accounts(3)
# ac3 creates protected group with ac1.
ac3_chat = ac3.create_group("Group") ac3_chat = ac3.create_group("Group")
# ac1 joins ac3 group. # ac1 joins ac3 group.
@@ -561,27 +623,31 @@ def test_securejoin_after_contact_resetup(acf) -> None:
# ac1 waits for member added message and creates a QR code. # ac1 waits for member added message and creates a QR code.
snapshot = ac1.wait_for_incoming_msg().get_snapshot() snapshot = ac1.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You were added by ac3." assert snapshot.text == "Member Me added by {}.".format(ac3.get_config("addr"))
ac1_qr_code = snapshot.chat.get_qr_code() ac1_qr_code = snapshot.chat.get_qr_code()
# ac2 sets up contact with ac1 # ac2 verifies ac1
qr_code = ac1.get_qr_code() qr_code = ac1.get_qr_code()
ac2.secure_join(qr_code) ac2.secure_join(qr_code)
ac2.wait_for_securejoin_joiner_success() ac2.wait_for_securejoin_joiner_success()
# ac1 is verified for ac2.
ac2_contact_ac1 = ac2.create_contact(ac1, "") ac2_contact_ac1 = ac2.create_contact(ac1, "")
assert ac2_contact_ac1.get_snapshot().e2ee_avail assert ac2_contact_ac1.get_snapshot().is_verified
# ac1 resetups the account. # ac1 resetups the account.
ac1 = acf.resetup_account(ac1) ac1 = acfactory.resetup_account(ac1)
ac2_contact_ac1 = ac2.create_contact(ac1, "")
assert not ac2_contact_ac1.get_snapshot().is_verified
# ac1 goes offline. # ac1 goes offline.
ac1.remove() ac1.remove()
# Scanning a QR code creates a group with the inviter, here ac1. # Scanning a QR code results in creating an unprotected group with an inviter.
# Normally the securejoin protocol # In this case inviter is ac1 which has an inconsistent key state.
# would complete and "Member added" would arrive, # Normally inviter becomes verified as a result of Securejoin protocol
# but ac1 is offline so it never finishes. # and then the group chat becomes verified when "Member added" is received,
# but in this case ac1 is offline and this Securejoin process will never finish.
logging.info("ac2 scans ac1 QR code, this is not expected to finish") logging.info("ac2 scans ac1 QR code, this is not expected to finish")
ac2.secure_join(ac1_qr_code) ac2.secure_join(ac1_qr_code)
@@ -598,15 +664,16 @@ def test_securejoin_after_contact_resetup(acf) -> None:
ac2_chat = snapshot.chat ac2_chat = snapshot.chat
assert len(ac2_chat.get_contacts()) == 3 assert len(ac2_chat.get_contacts()) == 3
# ac1 is still "not verified" for ac2 due to inconsistent state.
assert not ac2_contact_ac1.get_snapshot().is_verified
def test_withdraw_securejoin_qr(acf):
alice, bob = acf.get_online_accounts(2) def test_withdraw_securejoin_qr(acfactory):
alice.set_config("displayname", "Alice") alice, bob = acfactory.get_online_accounts(2)
bob.set_config("displayname", "Bob")
logging.info("Alice creates a group") logging.info("Alice creates a group")
alice_chat = alice.create_group("Group") alice_chat = alice.create_group("Group")
logging.info("Bob joins the group") logging.info("Bob joins verified group")
qr_code = alice_chat.get_qr_code() qr_code = alice_chat.get_qr_code()
bob_chat = bob.secure_join(qr_code) bob_chat = bob.secure_join(qr_code)
@@ -615,11 +682,11 @@ def test_withdraw_securejoin_qr(acf):
alice.clear_all_events() alice.clear_all_events()
snapshot = bob.wait_for_incoming_msg().get_snapshot() snapshot = bob.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "You were added by Alice." assert snapshot.text == "Member Me added by {}.".format(alice.get_config("addr"))
bob_chat.leave() bob_chat.leave()
snapshot = alice.get_message_by_id(alice.wait_for_msgs_changed_event().msg_id).get_snapshot() snapshot = alice.get_message_by_id(alice.wait_for_msgs_changed_event().msg_id).get_snapshot()
assert snapshot.text == "Group left by Bob." assert snapshot.text == "Group left by {}.".format(bob.get_config("addr"))
logging.info("Alice withdraws QR code.") logging.info("Alice withdraws QR code.")
qr = alice.check_qr(qr_code) qr = alice.check_qr(qr_code)
@@ -637,25 +704,3 @@ def test_withdraw_securejoin_qr(acf):
and "Ignoring RequestWithAuth message because of invalid auth code." in event.msg and "Ignoring RequestWithAuth message because of invalid auth code." in event.msg
): ):
break break
def test_qr_scan_updates_new_relay_address(acf):
alice, bob = acf.get_online_accounts(2)
bob_alice_chat = bob.secure_join(alice.get_qr_code())
alice.wait_for_securejoin_inviter_success()
bob.wait_for_securejoin_joiner_success()
for ac in [alice, bob]:
old_addr = ac.get_config("configured_addr")
ac.add_transport_from_qr(acf.get_account_qr())
ac.set_config("configured_addr", ac.list_transports()[1]["addr"])
ac.delete_transport(old_addr)
bob.secure_join(alice.get_qr_code())
alice.wait_for_securejoin_inviter_success()
bob.wait_for_securejoin_joiner_success()
bob_alice_chat.send_text("hi")
snapshot = alice.wait_for_incoming_msg().get_snapshot()
assert snapshot.text == "hi"
+140 -236
View File
@@ -35,19 +35,6 @@ def test_sleep(rpc) -> None:
assert sleep_5_future in pending assert sleep_5_future in pending
def test_shutdown(rpc) -> None:
"""Test RPC client shutdown if RPC server process is terminated.
This is a regression test, at the time of adding it RPC client
did not terminate all threads and python process did not exit.
Shutting down all threads is tested implicitly by pytest-timeout.
"""
rpc.process.kill()
rpc.process.wait()
with pytest.raises(JsonRpcError):
rpc.get_system_info()
def test_email_address_validity(rpc) -> None: def test_email_address_validity(rpc) -> None:
valid_addresses = [ valid_addresses = [
"email@example.com", "email@example.com",
@@ -61,8 +48,8 @@ def test_email_address_validity(rpc) -> None:
assert not rpc.check_email_validity(addr) assert not rpc.check_email_validity(addr)
def test_acf(acf) -> None: def test_acfactory(acfactory) -> None:
account = acf.new_configured_account() account = acfactory.new_configured_account()
while True: while True:
event = account.wait_for_event() event = account.wait_for_event()
if event.kind == EventType.CONFIGURE_PROGRESS: if event.kind == EventType.CONFIGURE_PROGRESS:
@@ -74,9 +61,9 @@ def test_acf(acf) -> None:
logging.info("Successful configuration") logging.info("Successful configuration")
def test_configure_starttls(acf) -> None: def test_configure_starttls(acfactory) -> None:
addr, password = acf.get_credentials() addr, password = acfactory.get_credentials()
account = acf.get_unconfigured_account() account = acfactory.get_unconfigured_account()
account.add_or_update_transport( account.add_or_update_transport(
{ {
"addr": addr, "addr": addr,
@@ -88,10 +75,10 @@ def test_configure_starttls(acf) -> None:
assert account.is_configured() assert account.is_configured()
def test_lowercase_address(acf) -> None: def test_lowercase_address(acfactory) -> None:
addr, password = acf.get_credentials() addr, password = acfactory.get_credentials()
addr_upper = addr.upper() addr_upper = addr.upper()
account = acf.get_unconfigured_account() account = acfactory.get_unconfigured_account()
account.add_or_update_transport( account.add_or_update_transport(
{ {
"addr": addr_upper, "addr": addr_upper,
@@ -116,9 +103,9 @@ def test_lowercase_address(acf) -> None:
assert addr_upper not in param assert addr_upper not in param
def test_configure_ip(acf) -> None: def test_configure_ip(acfactory) -> None:
addr, password = acf.get_credentials() addr, password = acfactory.get_credentials()
account = acf.get_unconfigured_account() account = acfactory.get_unconfigured_account()
ip_address = socket.gethostbyname(addr.rsplit("@")[-1]) ip_address = socket.gethostbyname(addr.rsplit("@")[-1])
with pytest.raises(JsonRpcError): with pytest.raises(JsonRpcError):
@@ -132,10 +119,10 @@ def test_configure_ip(acf) -> None:
) )
def test_configure_alternative_port(acf) -> None: def test_configure_alternative_port(acfactory) -> None:
"""Test that configuration with alternative port 443 works.""" """Test that configuration with alternative port 443 works."""
addr, password = acf.get_credentials() addr, password = acfactory.get_credentials()
account = acf.get_unconfigured_account() account = acfactory.get_unconfigured_account()
account.add_or_update_transport( account.add_or_update_transport(
{ {
"addr": addr, "addr": addr,
@@ -147,9 +134,9 @@ def test_configure_alternative_port(acf) -> None:
assert account.is_configured() assert account.is_configured()
def test_list_transports(acf) -> None: def test_list_transports(acfactory) -> None:
addr, password = acf.get_credentials() addr, password = acfactory.get_credentials()
account = acf.get_unconfigured_account() account = acfactory.get_unconfigured_account()
account.add_or_update_transport( account.add_or_update_transport(
{ {
"addr": addr, "addr": addr,
@@ -165,10 +152,10 @@ def test_list_transports(acf) -> None:
assert params["imapUser"] == addr assert params["imapUser"] == addr
def test_account(acf) -> None: def test_account(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
bob_addr = bob.get_config("configured_addr") bob_addr = bob.get_config("addr")
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_text("Hello!") alice_chat_bob.send_text("Hello!")
@@ -234,32 +221,8 @@ def test_account(acf) -> None:
alice.stop_io() alice.stop_io()
def test_mark_fresh_vs_self_mdn(acf) -> None: def test_chat(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
bob.set_config("bcc_self", "1")
alice_contact_bob = alice.create_contact(bob)
alice_chat = alice_contact_bob.create_chat()
alice_chat.send_text("Hello!")
event = bob.wait_for_incoming_msg_event()
chat_id = event.chat_id
msg_id = event.msg_id
bob_chat = bob.get_chat_by_id(chat_id)
message = bob.get_message_by_id(msg_id)
bob_chat.accept()
bob.mark_seen_messages([message])
bob_chat.mark_fresh()
assert bob_chat.get_fresh_message_count() == 1
alice.wait_for_event(EventType.MSG_READ)
alice_chat.send_text("You've read 'Hello!'")
bob.wait_for_incoming_msg_event()
assert bob_chat.get_fresh_message_count() == 2
def test_chat(acf) -> None:
alice, bob = acf.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob = alice_contact_bob.create_chat()
@@ -288,7 +251,7 @@ def test_chat(acf) -> None:
bob_chat_alice.unpin() bob_chat_alice.unpin()
bob_chat_alice.archive() bob_chat_alice.archive()
bob_chat_alice.unarchive() bob_chat_alice.unarchive()
with pytest.raises(JsonRpcError): # can't set name for single chats with pytest.raises(JsonRpcError): # can't set name for 1:1 chats
bob_chat_alice.set_name("test") bob_chat_alice.set_name("test")
bob_chat_alice.set_ephemeral_timer(300) bob_chat_alice.set_ephemeral_timer(300)
bob_chat_alice.get_encryption_info() bob_chat_alice.get_encryption_info()
@@ -328,10 +291,10 @@ def test_chat(acf) -> None:
group.get_locations() group.get_locations()
def test_contact(acf) -> None: def test_contact(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
bob_addr = bob.get_config("configured_addr") bob_addr = bob.get_config("addr")
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
assert alice_contact_bob == alice.get_contact_by_id(alice_contact_bob.id) assert alice_contact_bob == alice.get_contact_by_id(alice_contact_bob.id)
@@ -345,8 +308,8 @@ def test_contact(acf) -> None:
alice_contact_bob.create_chat() alice_contact_bob.create_chat()
def test_message(acf) -> None: def test_message(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob = alice_contact_bob.create_chat()
@@ -376,8 +339,8 @@ def test_message(acf) -> None:
assert reactions == snapshot.reactions assert reactions == snapshot.reactions
def test_receive_imf_failure(acf) -> None: def test_receive_imf_failure(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob = alice_contact_bob.create_chat()
@@ -393,7 +356,7 @@ def test_receive_imf_failure(acf) -> None:
snapshot.text == "❌ Failed to receive a message:" snapshot.text == "❌ Failed to receive a message:"
" Condition failed: `!context.get_config_bool(Config::SimulateReceiveImfError).await?`." " Condition failed: `!context.get_config_bool(Config::SimulateReceiveImfError).await?`."
f" Core version {version}." f" Core version {version}."
" Please report this bug to delta@merlinux.eu or https://support.delta.chat/" " Please report this bug to delta@merlinux.eu or https://support.delta.chat/."
) )
# The failed message doesn't break the IMAP loop. # The failed message doesn't break the IMAP loop.
@@ -405,8 +368,8 @@ def test_receive_imf_failure(acf) -> None:
assert snapshot.error is None assert snapshot.error is None
def test_selfavatar_sync(acf, rpcdata, log) -> None: def test_selfavatar_sync(acfactory, data, log) -> None:
alice = acf.get_online_account() alice = acfactory.get_online_account()
log.section("Alice adds a second device") log.section("Alice adds a second device")
alice2 = alice.clone() alice2 = alice.clone()
@@ -415,7 +378,7 @@ def test_selfavatar_sync(acf, rpcdata, log) -> None:
alice2.start_io() alice2.start_io()
log.section("First device changes avatar") log.section("First device changes avatar")
image = rpcdata.get_path("image/avatar1000x1000.jpg") image = data.get_path("image/avatar1000x1000.jpg")
alice.set_config("selfavatar", image) alice.set_config("selfavatar", image)
avatar_config = alice.get_config("selfavatar") avatar_config = alice.get_config("selfavatar")
avatar_hash = os.path.basename(avatar_config) avatar_hash = os.path.basename(avatar_config)
@@ -430,10 +393,11 @@ def test_selfavatar_sync(acf, rpcdata, log) -> None:
assert avatar_config != avatar_config2 assert avatar_config != avatar_config2
def test_dont_move_sync_msgs(acf, direct_imap): def test_dont_move_sync_msgs(acfactory, direct_imap):
addr, password = acf.get_credentials() addr, password = acfactory.get_credentials()
ac1 = acf.get_unconfigured_account() ac1 = acfactory.get_unconfigured_account()
ac1.set_config("bcc_self", "1") ac1.set_config("bcc_self", "1")
ac1.set_config("fix_is_chatmail", "1")
ac1.add_or_update_transport({"addr": addr, "password": password}) ac1.add_or_update_transport({"addr": addr, "password": password})
ac1.start_io() ac1.start_io()
ac1_direct_imap = direct_imap(ac1) ac1_direct_imap = direct_imap(ac1)
@@ -460,8 +424,8 @@ def test_dont_move_sync_msgs(acf, direct_imap):
time.sleep(1) time.sleep(1)
def test_reaction_seen_on_another_dev(acf) -> None: def test_reaction_seen_on_another_dev(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice2 = alice.clone() alice2 = alice.clone()
alice2.start_io() alice2.start_io()
@@ -486,38 +450,9 @@ def test_reaction_seen_on_another_dev(acf) -> None:
assert chat_id == alice2_chat_bob.id assert chat_id == alice2_chat_bob.id
def test_2nd_device_events_when_msgs_are_seen(acf) -> None: def test_is_bot(acfactory) -> None:
alice, bob = acf.get_online_accounts(2)
alice2 = alice.clone()
alice2.start_io()
# Get an accepted chat, otherwise alice2 won't be notified about the 2nd message.
chat_alice2 = alice2.create_chat(bob)
chat_id_alice2 = chat_alice2.get_basic_snapshot().id
chat_bob_alice = bob.create_chat(alice)
chat_bob_alice.send_text("Hello!")
msg_alice = alice.wait_for_incoming_msg()
assert alice2.wait_for_incoming_msg_event().chat_id == chat_id_alice2
chat_bob_alice.send_text("What's new?")
assert alice2.wait_for_incoming_msg_event().chat_id == chat_id_alice2
chat_alice2 = alice2.get_chat_by_id(chat_id_alice2)
assert chat_alice2.get_fresh_message_count() == 2
msg_alice.mark_seen()
assert alice2.wait_for_msgs_changed_event().chat_id == chat_id_alice2
assert chat_alice2.get_fresh_message_count() == 1
msg_id = alice.wait_for_msgs_changed_event().msg_id
msg = alice.get_message_by_id(msg_id)
msg.mark_seen()
assert alice2.wait_for_event(EventType.MSGS_NOTICED).chat_id == chat_id_alice2
assert chat_alice2.get_fresh_message_count() == 0
def test_is_bot(acf) -> None:
"""Test that we can recognize messages submitted by bots.""" """Test that we can recognize messages submitted by bots."""
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob = alice_contact_bob.create_chat()
@@ -531,18 +466,18 @@ def test_is_bot(acf) -> None:
assert snapshot.is_bot assert snapshot.is_bot
def test_bot(acf) -> None: def test_bot(acfactory) -> None:
mock = MagicMock() mock = MagicMock()
user = (acf.get_online_accounts(1))[0] user = (acfactory.get_online_accounts(1))[0]
bot = acf.new_configured_bot() bot = acfactory.new_configured_bot()
bot2 = acf.new_configured_bot() bot2 = acfactory.new_configured_bot()
assert bot.is_configured() assert bot.is_configured()
assert bot.account.get_config("bot") == "1" assert bot.account.get_config("bot") == "1"
hook = lambda e: mock.hook(e.msg_id) and None, events.RawEvent(EventType.INCOMING_MSG) hook = lambda e: mock.hook(e.msg_id) and None, events.RawEvent(EventType.INCOMING_MSG)
bot.add_hook(*hook) bot.add_hook(*hook)
event = acf.process_message(from_account=user, to_client=bot, text="Hello!") event = acfactory.process_message(from_account=user, to_client=bot, text="Hello!")
snapshot = bot.account.get_message_by_id(event.msg_id).get_snapshot() snapshot = bot.account.get_message_by_id(event.msg_id).get_snapshot()
assert not snapshot.is_bot assert not snapshot.is_bot
mock.hook.assert_called_once_with(event.msg_id) mock.hook.assert_called_once_with(event.msg_id)
@@ -555,28 +490,28 @@ def test_bot(acf) -> None:
hook = track, events.NewMessage(r"hello") hook = track, events.NewMessage(r"hello")
bot.add_hook(*hook) bot.add_hook(*hook)
bot.add_hook(track, events.NewMessage(command="/help")) bot.add_hook(track, events.NewMessage(command="/help"))
event = acf.process_message(from_account=user, to_client=bot, text="hello") event = acfactory.process_message(from_account=user, to_client=bot, text="hello")
mock.hook.assert_called_with(event.msg_id) mock.hook.assert_called_with(event.msg_id)
event = acf.process_message(from_account=user, to_client=bot, text="hello!") event = acfactory.process_message(from_account=user, to_client=bot, text="hello!")
mock.hook.assert_called_with(event.msg_id) mock.hook.assert_called_with(event.msg_id)
acf.process_message(from_account=bot2.account, to_client=bot, text="hello") acfactory.process_message(from_account=bot2.account, to_client=bot, text="hello")
assert len(mock.hook.mock_calls) == 2 # bot messages are ignored between bots assert len(mock.hook.mock_calls) == 2 # bot messages are ignored between bots
acf.process_message(from_account=user, to_client=bot, text="hey!") acfactory.process_message(from_account=user, to_client=bot, text="hey!")
assert len(mock.hook.mock_calls) == 2 assert len(mock.hook.mock_calls) == 2
bot.remove_hook(*hook) bot.remove_hook(*hook)
mock.hook.reset_mock() mock.hook.reset_mock()
acf.process_message(from_account=user, to_client=bot, text="hello") acfactory.process_message(from_account=user, to_client=bot, text="hello")
event = acf.process_message(from_account=user, to_client=bot, text="/help") event = acfactory.process_message(from_account=user, to_client=bot, text="/help")
mock.hook.assert_called_once_with(event.msg_id) mock.hook.assert_called_once_with(event.msg_id)
def test_wait_next_messages(acf) -> None: def test_wait_next_messages(acfactory) -> None:
alice = acf.get_online_account() alice = acfactory.get_online_account()
# Create a bot account so it does not receive device messages in the beginning. # Create a bot account so it does not receive device messages in the beginning.
addr, password = acf.get_credentials() addr, password = acfactory.get_credentials()
bot = acf.get_unconfigured_account() bot = acfactory.get_unconfigured_account()
bot.set_config("bot", "1") bot.set_config("bot", "1")
bot.add_or_update_transport({"addr": addr, "password": password}) bot.add_or_update_transport({"addr": addr, "password": password})
assert bot.is_configured() assert bot.is_configured()
@@ -602,26 +537,27 @@ def test_wait_next_messages(acf) -> None:
assert snapshot.text == "Hello!" assert snapshot.text == "Hello!"
def test_import_export_backup(acf, tmp_path) -> None: def test_import_export_backup(acfactory, tmp_path) -> None:
alice = acf.new_configured_account() alice = acfactory.new_configured_account()
alice.export_backup(tmp_path) alice.export_backup(tmp_path)
files = list(tmp_path.glob("*.tar")) files = list(tmp_path.glob("*.tar"))
alice2 = acf.get_unconfigured_account() alice2 = acfactory.get_unconfigured_account()
alice2.import_backup(files[0]) alice2.import_backup(files[0])
assert alice2.manager.get_system_info() assert alice2.manager.get_system_info()
def test_import_export_online_all(acf, tmp_path, rpcdata, log) -> None: def test_import_export_online_all(acfactory, tmp_path, data, log) -> None:
(ac1, some1) = acf.get_online_accounts(2) (ac1, some1) = acfactory.get_online_accounts(2)
log.section("create some chat content") log.section("create some chat content")
some1_addr = some1.get_config("addr")
chat1 = ac1.create_contact(some1).create_chat() chat1 = ac1.create_contact(some1).create_chat()
chat1.send_text("msg1") chat1.send_text("msg1")
assert len(ac1.get_contacts()) == 1 assert len(ac1.get_contacts()) == 1
original_image_path = rpcdata.get_path("image/avatar64x64.png") original_image_path = data.get_path("image/avatar64x64.png")
chat1.send_file(str(original_image_path)) chat1.send_file(str(original_image_path))
# Add another 100KB file that ensures that the progress is smooth enough # Add another 100KB file that ensures that the progress is smooth enough
@@ -634,6 +570,7 @@ def test_import_export_online_all(acf, tmp_path, rpcdata, log) -> None:
contacts = ac.get_contacts() contacts = ac.get_contacts()
assert len(contacts) == 1 assert len(contacts) == 1
contact2 = contacts[0] contact2 = contacts[0]
assert contact2.get_snapshot().address == some1_addr
chat2 = contact2.create_chat() chat2 = contact2.create_chat()
messages = chat2.get_messages() messages = chat2.get_messages()
assert len(messages) == 3 + E2EE_INFO_MSGS assert len(messages) == 3 + E2EE_INFO_MSGS
@@ -671,7 +608,7 @@ def test_import_export_online_all(acf, tmp_path, rpcdata, log) -> None:
ac1.start_io() ac1.start_io()
log.section("get fresh empty account") log.section("get fresh empty account")
ac2 = acf.get_unconfigured_account() ac2 = acfactory.get_unconfigured_account()
log.section("import backup and check it's proper") log.section("import backup and check it's proper")
ac2.import_backup(files_written[0]) ac2.import_backup(files_written[0])
@@ -708,8 +645,8 @@ def test_import_export_online_all(acf, tmp_path, rpcdata, log) -> None:
assert len(list(backupdir.glob("*.tar"))) == 2 assert len(list(backupdir.glob("*.tar"))) == 2
def test_import_export_keys(acf, tmp_path) -> None: def test_import_export_keys(acfactory, tmp_path) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob) alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_text("Hello Bob!") alice_chat_bob.send_text("Hello Bob!")
@@ -721,7 +658,7 @@ def test_import_export_keys(acf, tmp_path) -> None:
alice_keys_path = tmp_path / "alice_keys" alice_keys_path = tmp_path / "alice_keys"
alice_keys_path.mkdir() alice_keys_path.mkdir()
alice.export_self_keys(alice_keys_path) alice.export_self_keys(alice_keys_path)
alice = acf.resetup_account(alice) alice = acfactory.resetup_account(alice)
alice.import_self_keys(alice_keys_path) alice.import_self_keys(alice_keys_path)
snapshot.chat.accept() snapshot.chat.accept()
@@ -756,14 +693,31 @@ def test_early_failure(tmp_path) -> None:
with pytest.raises(JsonRpcError, match="invalid_dir"): with pytest.raises(JsonRpcError, match="invalid_dir"):
rpc.start() rpc.start()
# Requests issued after the server exited must fail immediately
# instead of waiting forever for the finished reader loop. def test_provider_info(rpc) -> None:
with pytest.raises(JsonRpcError, match="RPC server closed"): account_id = rpc.add_account()
rpc.get_system_info()
provider_info = rpc.get_provider_info(account_id, "example.org")
assert provider_info["id"] == "example.com"
provider_info = rpc.get_provider_info(account_id, "uep7oiw4ahtaizuloith.org")
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 is None
# Disable MX record resolution.
rpc.set_config(account_id, "proxy_enabled", "1")
provider_info = rpc.get_provider_info(account_id, "github.com")
assert provider_info is None
def test_mdn_doesnt_break_autocrypt(acf) -> None: def test_mdn_doesnt_break_autocrypt(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
@@ -793,10 +747,10 @@ def test_mdn_doesnt_break_autocrypt(acf) -> None:
@pytest.mark.parametrize("n_accounts", [3, 2]) @pytest.mark.parametrize("n_accounts", [3, 2])
def test_download_limit_chat_assignment(acf, tmp_path, n_accounts): def test_download_limit_chat_assignment(acfactory, tmp_path, n_accounts):
download_limit = 300000 download_limit = 300000
alice, *others = acf.get_online_accounts(n_accounts) alice, *others = acfactory.get_online_accounts(n_accounts)
bob = others[0] bob = others[0]
alice_group = alice.create_group("test group") alice_group = alice.create_group("test group")
@@ -832,10 +786,10 @@ def test_download_limit_chat_assignment(acf, tmp_path, n_accounts):
assert snapshot.chat == bob_group assert snapshot.chat == bob_group
def test_download_small_msg_first(acf, tmp_path): def test_download_small_msg_first(acfactory, tmp_path):
download_limit = 70000 download_limit = 70000
alice, bob0 = acf.get_online_accounts(2) alice, bob0 = acfactory.get_online_accounts(2)
bob1 = bob0.clone() bob1 = bob0.clone()
bob1.set_config("download_limit", str(download_limit)) bob1.set_config("download_limit", str(download_limit))
@@ -856,14 +810,14 @@ def test_download_small_msg_first(acf, tmp_path):
@pytest.mark.parametrize("delete_chat", [False, True]) @pytest.mark.parametrize("delete_chat", [False, True])
def test_delete_available_msg(acf, tmp_path, direct_imap, delete_chat): def test_delete_available_msg(acfactory, tmp_path, direct_imap, delete_chat):
""" """
Tests `DownloadState.AVAILABLE` message deletion on the receiver side. Tests `DownloadState.AVAILABLE` message deletion on the receiver side.
Also tests pre- and post-message deletion on the sender side. Also tests pre- and post-message deletion on the sender side.
""" """
# Min. UI setting as of v2.35 # Min. UI setting as of v2.35
download_limit = 163840 download_limit = 163840
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
bob.set_config("download_limit", str(download_limit)) bob.set_config("download_limit", str(download_limit))
# Avoid immediate deletion from the server # Avoid immediate deletion from the server
alice.set_config("bcc_self", "1") alice.set_config("bcc_self", "1")
@@ -906,8 +860,8 @@ def test_delete_available_msg(acf, tmp_path, direct_imap, delete_chat):
break break
def test_delete_fully_downloaded_msg(acf, tmp_path, direct_imap): def test_delete_fully_downloaded_msg(acfactory, tmp_path, direct_imap):
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
# Avoid immediate deletion from the server # Avoid immediate deletion from the server
bob.set_config("bcc_self", "1") bob.set_config("bcc_self", "1")
@@ -942,8 +896,8 @@ def test_delete_fully_downloaded_msg(acf, tmp_path, direct_imap):
break break
def test_imap_autodelete_fully_downloaded_msg(acf, tmp_path, direct_imap): def test_imap_autodelete_fully_downloaded_msg(acfactory, tmp_path, direct_imap):
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
chat_alice = alice.create_chat(bob) chat_alice = alice.create_chat(bob)
path = tmp_path / "large" path = tmp_path / "large"
@@ -971,12 +925,12 @@ def test_imap_autodelete_fully_downloaded_msg(acf, tmp_path, direct_imap):
break break
def test_markseen_contact_request(acf): def test_markseen_contact_request(acfactory):
""" """
Test that seen status is synchronized for contact request messages Test that seen status is synchronized for contact request messages
even though read receipt is not sent. even though read receipt is not sent.
""" """
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
# Bob sets up a second device. # Bob sets up a second device.
bob2 = bob.clone() bob2 = bob.clone()
@@ -995,11 +949,11 @@ def test_markseen_contact_request(acf):
@pytest.mark.parametrize("team_profile", [True, False]) @pytest.mark.parametrize("team_profile", [True, False])
def test_no_markseen_in_team_profile(team_profile, acf): def test_no_markseen_in_team_profile(team_profile, acfactory):
""" """
Test that seen status is synchronized iff `team_profile` isn't set. Test that seen status is synchronized iff `team_profile` isn't set.
""" """
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
if team_profile: if team_profile:
bob.set_config("team_profile", "1") bob.set_config("team_profile", "1")
@@ -1018,11 +972,6 @@ def test_no_markseen_in_team_profile(team_profile, acf):
message.mark_seen() message.mark_seen()
# The MDN is queued in `smtp_mdns`, which is drained only after the regular
# `smtp` queue, so "Outgoing message" would otherwise overtake it on the wire.
# Wait for the read receipt to reach Alice before queueing "Outgoing message".
alice.wait_for_event(EventType.MSG_READ)
# Send a message and wait until it arrives # Send a message and wait until it arrives
# in order to wait until Bob2 gets the markseen message. # in order to wait until Bob2 gets the markseen message.
# This also tests that outgoing messages # This also tests that outgoing messages
@@ -1040,11 +989,11 @@ def test_no_markseen_in_team_profile(team_profile, acf):
assert message2.get_snapshot().state == MessageState.IN_SEEN assert message2.get_snapshot().state == MessageState.IN_SEEN
def test_read_receipt(acf): def test_read_receipt(acfactory):
""" """
Test sending a read receipt and ensure it is attributed to the correct contact. Test sending a read receipt and ensure it is attributed to the correct contact.
""" """
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob) alice_chat_bob = alice.create_chat(bob)
alice_contact_bob = alice.create_contact(bob) alice_contact_bob = alice.create_contact(bob)
@@ -1063,15 +1012,15 @@ def test_read_receipt(acf):
assert read_receipt_cnt == 1 assert read_receipt_cnt == 1
def test_get_http_response(acf): def test_get_http_response(acfactory):
alice = acf.new_configured_account() alice = acfactory.new_configured_account()
http_response = alice._rpc.get_http_response(alice.id, "https://example.org") http_response = alice._rpc.get_http_response(alice.id, "https://example.org")
assert http_response["mimetype"] == "text/html" assert http_response["mimetype"] == "text/html"
assert b"<title>Example Domain</title>" in base64.b64decode((http_response["blob"] + "==").encode()) assert b"<title>Example Domain</title>" in base64.b64decode((http_response["blob"] + "==").encode())
def test_configured_imap_certificate_checks(acf): def test_configured_imap_certificate_checks(acfactory):
alice = acf.new_configured_account() alice = acfactory.new_configured_account()
# Certificate checks should be configured (not None) # Certificate checks should be configured (not None)
assert "cert_strict" in alice.get_info().used_transport_settings assert "cert_strict" in alice.get_info().used_transport_settings
@@ -1090,8 +1039,8 @@ def test_configured_imap_certificate_checks(acf):
assert "cert_old_automatic" not in alice.get_info().used_transport_settings assert "cert_old_automatic" not in alice.get_info().used_transport_settings
def test_no_old_msg_is_fresh(acf): def test_no_old_msg_is_fresh(acfactory):
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone() ac1_clone = ac1.clone()
ac1_clone.start_io() ac1_clone.start_io()
@@ -1118,9 +1067,9 @@ def test_no_old_msg_is_fresh(acf):
assert len(list(ac1.get_fresh_messages())) == 0 assert len(list(ac1.get_fresh_messages())) == 0
def test_rename_synchronization(acf): def test_rename_synchronization(acfactory):
"""Test synchronization of contact renaming.""" """Test synchronization of contact renaming."""
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice2 = alice.clone() alice2 = alice.clone()
alice2.bring_online() alice2.bring_online()
@@ -1135,9 +1084,9 @@ def test_rename_synchronization(acf):
assert alice2_msg.sender.get_snapshot().display_name == "Bobby" assert alice2_msg.sender.get_snapshot().display_name == "Bobby"
def test_rename_group(acf): def test_rename_group(acfactory):
"""Test renaming the group.""" """Test renaming the group."""
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_group = alice.create_group("Test group") alice_group = alice.create_group("Test group")
alice_contact_bob = alice.create_contact(bob) alice_contact_bob = alice.create_contact(bob)
@@ -1150,7 +1099,6 @@ def test_rename_group(acf):
bob.wait_for_event(EventType.CHATLIST_ITEM_CHANGED) bob.wait_for_event(EventType.CHATLIST_ITEM_CHANGED)
for name in ["Baz", "Foo bar", "Xyzzy"]: for name in ["Baz", "Foo bar", "Xyzzy"]:
time.sleep(1)
alice_group.set_name(name) alice_group.set_name(name)
bob.wait_for_event(EventType.CHATLIST_ITEM_CHANGED) bob.wait_for_event(EventType.CHATLIST_ITEM_CHANGED)
bob.wait_for_event(EventType.CHATLIST_ITEM_CHANGED) bob.wait_for_event(EventType.CHATLIST_ITEM_CHANGED)
@@ -1166,8 +1114,8 @@ def test_get_all_accounts_deadlock(rpc):
@pytest.mark.parametrize("all_devices_online", [True, False]) @pytest.mark.parametrize("all_devices_online", [True, False])
def test_leave_broadcast(acf, all_devices_online): def test_leave_broadcast(acfactory, all_devices_online):
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
bob2 = bob.clone() bob2 = bob.clone()
@@ -1198,6 +1146,7 @@ def test_leave_broadcast(acf, all_devices_online):
def check_account(ac, contact, inviter_side, please_wait_info_msg=False): def check_account(ac, contact, inviter_side, please_wait_info_msg=False):
chat = get_broadcast(ac) chat = get_broadcast(ac)
contact_snapshot = contact.get_snapshot()
chat_msgs = chat.get_messages() chat_msgs = chat.get_messages()
encrypted_msg = chat_msgs.pop(0).get_snapshot() encrypted_msg = chat_msgs.pop(0).get_snapshot()
@@ -1209,11 +1158,14 @@ def test_leave_broadcast(acf, all_devices_online):
assert "invited you to join this channel" in first_msg.text assert "invited you to join this channel" in first_msg.text
assert first_msg.is_info assert first_msg.is_info
if not inviter_side: member_added_msg = chat_msgs.pop(0).get_snapshot()
member_added_msg = chat_msgs.pop(0).get_snapshot() if inviter_side:
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
else:
assert member_added_msg.text == "You joined the channel." assert member_added_msg.text == "You joined the channel."
assert member_added_msg.is_info assert member_added_msg.is_info
if not inviter_side:
leave_msg = chat_msgs.pop(0).get_snapshot() leave_msg = chat_msgs.pop(0).get_snapshot()
assert leave_msg.text == "You left the channel." assert leave_msg.text == "You left the channel."
@@ -1263,8 +1215,8 @@ def test_leave_broadcast(acf, all_devices_online):
check_account(bob2, bob2.create_contact(alice), inviter_side=False) check_account(bob2, bob2.create_contact(alice), inviter_side=False)
def test_leave_and_delete_group(acf, log): def test_leave_and_delete_group(acfactory, log):
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
log.section("Alice creates a group") log.section("Alice creates a group")
alice_chat = alice.create_group("Group") alice_chat = alice.create_group("Group")
@@ -1287,12 +1239,12 @@ def test_leave_and_delete_group(acf, log):
alice.wait_for_event(EventType.CHAT_MODIFIED) alice.wait_for_event(EventType.CHAT_MODIFIED)
def test_immediate_autodelete(acf, direct_imap, log): def test_immediate_autodelete(acfactory, direct_imap, log):
""" """
`bcc_self` is off by default, `bcc_self` is off by default,
so that messages are supposed to be immediately autodeleted so that messages are supposed to be immediately autodeleted
""" """
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
assert ac1.get_config("bcc_self") == "0" assert ac1.get_config("bcc_self") == "0"
log.section("ac1: create chat with ac2") log.section("ac1: create chat with ac2")
@@ -1323,8 +1275,8 @@ def test_immediate_autodelete(acf, direct_imap, log):
assert ev.msg_id == sent_msg.id assert ev.msg_id == sent_msg.id
def test_background_fetch(acf, dc): def test_background_fetch(acfactory, dc):
ac1, ac2 = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
ac1.stop_io() ac1.stop_io()
ac1_chat = ac1.create_chat(ac2) ac1_chat = ac1.create_chat(ac2)
@@ -1360,24 +1312,8 @@ def test_background_fetch(acf, dc):
break break
def test_background_fetch_does_not_wait_for_sending(dc, acf): def test_message_exists(acfactory):
alice, bob = acf.get_online_accounts(2) ac1, ac2 = acfactory.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob)
alice.stop_io()
text = "x" * 200_000
for _ in range(50):
alice_chat_bob.send_text(text)
assert not dc.is_sending_finished()
alice.start_io()
dc.background_fetch(50)
dc.wait_for_event(EventType.ACCOUNTS_BACKGROUND_FETCH_DONE)
assert not dc.is_sending_finished()
def test_message_exists(acf):
ac1, ac2 = acf.get_online_accounts(2)
chat = ac1.create_chat(ac2) chat = ac1.create_chat(ac2)
message1 = chat.send_text("Hello!") message1 = chat.send_text("Hello!")
message2 = chat.send_text("Hello again!") message2 = chat.send_text("Hello again!")
@@ -1395,7 +1331,7 @@ def test_message_exists(acf):
assert not message2.exists() assert not message2.exists()
def test_synchronize_member_list_on_group_rejoin(acf, log): def test_synchronize_member_list_on_group_rejoin(acfactory, log):
""" """
Test that user recreates group member list when it joins the group again. Test that user recreates group member list when it joins the group again.
ac1 creates a group with two other accounts: ac2 and ac3 ac1 creates a group with two other accounts: ac2 and ac3
@@ -1403,7 +1339,7 @@ def test_synchronize_member_list_on_group_rejoin(acf, log):
ac2 did not see that ac3 is removed, so it should rebuild member list from scratch. ac2 did not see that ac3 is removed, so it should rebuild member list from scratch.
""" """
log.section("setting up accounts, accepted with each other") log.section("setting up accounts, accepted with each other")
ac1, ac2, ac3 = accounts = acf.get_online_accounts(3) ac1, ac2, ac3 = accounts = acfactory.get_online_accounts(3)
log.section("ac1: creating group chat with 2 other members") log.section("ac1: creating group chat with 2 other members")
chat = ac1.create_group("title1") chat = ac1.create_group("title1")
@@ -1439,17 +1375,17 @@ def test_synchronize_member_list_on_group_rejoin(acf, log):
assert msg.get_snapshot().chat.num_contacts() == 2 assert msg.get_snapshot().chat.num_contacts() == 2
def test_large_message(acf, rpcdata) -> None: def test_large_message(acfactory) -> None:
""" """
Test sending large message without download limit set, Test sending large message without download limit set,
so it is sent with pre-message but downloaded without user interaction. so it is sent with pre-message but downloaded without user interaction.
""" """
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob) alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.send_message( alice_chat_bob.send_message(
"Hello World, this message is bigger than 5 bytes", "Hello World, this message is bigger than 5 bytes",
file=rpcdata.get_path("image/screenshot.jpg"), file="../test-data/image/screenshot.jpg",
) )
msg = bob.wait_for_incoming_msg() msg = bob.wait_for_incoming_msg()
@@ -1457,35 +1393,3 @@ def test_large_message(acf, rpcdata) -> None:
assert msg.id == msgs_changed_event.msg_id assert msg.id == msgs_changed_event.msg_id
snapshot = msg.get_snapshot() snapshot = msg.get_snapshot()
assert snapshot.text == "Hello World, this message is bigger than 5 bytes" assert snapshot.text == "Hello World, this message is bigger than 5 bytes"
def test_is_sending_finished(dc, acf) -> None:
alice, bob = acf.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob)
bob_chat_alice = bob.create_chat(alice)
assert dc.is_sending_finished()
alice_chat_bob.send_text("Hello!")
alice.wait_for_event(EventType.SMTP_MESSAGE_SENT)
assert dc.is_sending_finished()
alice.stop_io()
bob.stop_io()
bob_chat_alice.send_text("Hello back!")
alice_chat_bob.send_text("Hello again!")
assert not dc.is_sending_finished()
alice.start_io()
alice.wait_for_event(EventType.SMTP_MESSAGE_SENT)
assert not dc.is_sending_finished()
bob.start_io()
bob.wait_for_event(EventType.SMTP_MESSAGE_SENT)
assert dc.is_sending_finished()
@@ -1,132 +0,0 @@
"""Test that docs/schema.sql matches the actual database schema."""
import re
import sqlite3
from pathlib import Path
DOC_PATH = Path(__file__).resolve().parents[2] / "docs" / "schema.sql"
def strip_comments(sql):
return re.sub(r"--[^\n]*", "", sql)
def normalize(stmt):
stmt = re.sub(r"\s+", " ", stmt).strip()
stmt = stmt.replace("CREATE TABLE IF NOT EXISTS ", "CREATE TABLE ")
return re.sub(r'"(\w+)"', r"\1", stmt)
def split_table(body):
items = []
depth = 0
current = ""
for char in body:
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if char == "," and depth == 0:
items.append(current.strip())
current = ""
else:
current += char
if current.strip():
items.append(current.strip())
return items
def parse_schema(sql):
objects = {}
for raw_stmt in strip_comments(sql).split(";"):
stmt = normalize(raw_stmt)
if not stmt:
continue
match = re.match(r"CREATE TABLE (\w+) ?\((.*)\)( STRICT)?$", stmt)
if match:
name, body, strict = match.groups()
objects[f"table {name}"] = {
"items": sorted(split_table(body)),
"strict": bool(strict),
}
continue
match = re.match(r"CREATE (?:UNIQUE )?INDEX (\w+)", stmt)
if match:
objects[f"index {match.group(1)}"] = {"sql": stmt}
continue
objects[stmt[:60]] = {"sql": stmt}
return objects
def format_diff(documented, real):
if "items" in documented and "items" in real:
lines = []
# disregards order
for item in sorted(set(documented["items"]) - set(real["items"])):
lines.append(f" documented but not in the database: {item}")
for item in sorted(set(real["items"]) - set(documented["items"])):
lines.append(f" in the database but not documented: {item}")
if documented["strict"] != real["strict"]:
lines.append(f" STRICT: documented={documented['strict']} actual={real['strict']}")
return "\n".join(lines)
return f" documented: {documented}\n actual: {real}"
def read_database_schema(dbfile):
with sqlite3.connect(f"file:{dbfile}?mode=ro", uri=True) as conn:
rows = conn.execute(
"SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'",
).fetchall()
return ";\n".join(row[0] for row in rows)
# Tables where every column carries a comment in docs/schema.sql.
# Opt-in: document a table's columns, then add it here to lock it in.
FULLY_DOCUMENTED_TABLES = {
"contacts",
"imap_markseen",
"multi_device_sync",
"transports",
}
def undocumented_columns(sql, tables):
result = []
table = None
documented = False
for line in sql.splitlines():
code, _, comment = line.strip().partition("--")
code = code.strip()
if code.startswith("CREATE TABLE "):
name = code.removeprefix("CREATE TABLE ").partition("(")[0].strip()
table = name if name in tables else None
assert not table or code.endswith("("), f"{code}: want one column per line"
elif code.startswith(")"):
table = None
elif table and code and not re.match(r"(UNIQUE|PRIMARY|FOREIGN|CHECK)\b", code):
column = re.match(r"(\w+) (?!INTEGER PRIMARY KEY)", code)
if column and not documented and not comment:
result.append(f"{table}.{column.group(1)}")
documented = bool(comment) and not code
return result
def test_documented_tables_stay_documented():
missing = undocumented_columns(DOC_PATH.read_text(), FULLY_DOCUMENTED_TABLES)
assert not missing, "columns without a comment in docs/schema.sql:\n" + "\n".join(missing)
def test_documented_schema_matches_database(acf):
account = acf.get_unconfigured_account()
real = parse_schema(read_database_schema(account.get_info()["database_dir"]))
documented = parse_schema(DOC_PATH.read_text())
problems = []
for name in sorted(real.keys() - documented.keys()):
problems.append(f"{name} exists in the database but is not documented")
for name in sorted(documented.keys() - real.keys()):
problems.append(f"{name} is documented but does not exist in the database")
for name in sorted(documented.keys() & real.keys()):
if documented[name] != real[name]:
problems.append(f"{name} differs:\n{format_diff(documented[name], real[name])}")
assert not problems, "documented schema deviates from the database:\n" + "\n".join(problems)
+2 -2
View File
@@ -1,5 +1,5 @@
def test_vcard(acf) -> None: def test_vcard(acfactory) -> None:
alice, bob, fiona = acf.get_online_accounts(3) alice, bob, fiona = acfactory.get_online_accounts(3)
bob.create_chat(alice) bob.create_chat(alice)
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
+7 -7
View File
@@ -1,9 +1,9 @@
def test_webxdc(acf, rpcdata) -> None: def test_webxdc(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_message(text="Let's play chess!", file=rpcdata.get_path("webxdc/chess.xdc")) alice_chat_bob.send_message(text="Let's play chess!", file="../test-data/webxdc/chess.xdc")
event = bob.wait_for_incoming_msg_event() event = bob.wait_for_incoming_msg_event()
bob_chat_alice = bob.get_chat_by_id(event.chat_id) bob_chat_alice = bob.get_chat_by_id(event.chat_id)
@@ -21,7 +21,7 @@ def test_webxdc(acf, rpcdata) -> None:
"isAppSender": False, "isAppSender": False,
"isBroadcast": False, "isBroadcast": False,
"sendUpdateInterval": 1000, "sendUpdateInterval": 1000,
"sendUpdateMaxSize": 2**20 * (30 - 1) * 3 // 4, "sendUpdateMaxSize": 18874368,
} }
status_updates = message.get_webxdc_status_updates() status_updates = message.get_webxdc_status_updates()
@@ -43,12 +43,12 @@ def test_webxdc(acf, rpcdata) -> None:
] ]
def test_webxdc_insert_lots_of_updates(acf, rpcdata) -> None: def test_webxdc_insert_lots_of_updates(acfactory) -> None:
alice, bob = acf.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob = alice_contact_bob.create_chat()
message = alice_chat_bob.send_message(text="Let's play chess!", file=rpcdata.get_path("webxdc/chess.xdc")) message = alice_chat_bob.send_message(text="Let's play chess!", file="../test-data/webxdc/chess.xdc")
for i in range(2000): for i in range(2000):
message.send_webxdc_status_update({"payload": str(i)}, "description") message.send_webxdc_status_update({"payload": str(i)}, "description")
+3 -3
View File
@@ -1,8 +1,9 @@
[package] [package]
name = "deltachat-rpc-server" name = "deltachat-rpc-server"
version = "2.64.0-dev" version = "2.51.0-dev"
description = "DeltaChat JSON-RPC server" description = "DeltaChat JSON-RPC server"
edition = "2024" edition = "2021"
readme = "README.md"
license = "MPL-2.0" license = "MPL-2.0"
keywords = ["deltachat", "chat", "openpgp", "email", "encryption"] keywords = ["deltachat", "chat", "openpgp", "email", "encryption"]
@@ -14,7 +15,6 @@ deltachat = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
futures-lite = { workspace = true } futures-lite = { workspace = true }
libc = { workspace = true }
log = { workspace = true } log = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
+1 -1
View File
@@ -11,7 +11,7 @@ Rename the downloaded binary to `deltachat-rpc-server` and add it to your `PATH`
To install from source run: To install from source run:
```sh ```sh
cargo install --locked --git https://github.com/chatmail/core/ deltachat-rpc-server cargo install --git https://github.com/chatmail/core/ deltachat-rpc-server
``` ```
The `deltachat-rpc-server` executable will be installed into `$HOME/.cargo/bin` that should be available The `deltachat-rpc-server` executable will be installed into `$HOME/.cargo/bin` that should be available
@@ -15,5 +15,5 @@
}, },
"type": "module", "type": "module",
"types": "index.d.ts", "types": "index.d.ts",
"version": "2.64.0-dev" "version": "2.51.0-dev"
} }
@@ -56,7 +56,7 @@ for (const { folder_name, package_name } of platform_package_names) {
if (is_local) { if (is_local) {
package_json.peerDependencies["@deltachat/jsonrpc-client"] = package_json.peerDependencies["@deltachat/jsonrpc-client"] =
`file:${join(expected_cwd, "/../../deltachat-jsonrpc-bindings/typescript")}`; `file:${join(expected_cwd, "/../../deltachat-jsonrpc/typescript")}`;
} else { } else {
package_json.peerDependencies["@deltachat/jsonrpc-client"] = "*"; package_json.peerDependencies["@deltachat/jsonrpc-client"] = "*";
} }
@@ -2,7 +2,7 @@
import { ENV_VAR_NAME } from "./const.js"; import { ENV_VAR_NAME } from "./const.js";
const cargoInstallCommand = const cargoInstallCommand =
"cargo install --locked --git https://github.com/chatmail/core deltachat-rpc-server"; "cargo install --git https://github.com/chatmail/core deltachat-rpc-server";
export function NPM_NOT_FOUND_SUPPORTED_PLATFORM_ERROR(package_name) { export function NPM_NOT_FOUND_SUPPORTED_PLATFORM_ERROR(package_name) {
return `deltachat-rpc-server not found: return `deltachat-rpc-server not found:

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