Compare commits

..

13 Commits

Author SHA1 Message Date
Simon Laux
7ba45134ea fix fmt and add missing commas 2026-01-14 21:48:30 +01:00
Simon Laux
5d512a726d reword transport list, since there is now only one official transport
and that is stdio. also mention the other (embedded into desktop code)
transport implementations and link to their code
2026-01-14 21:44:42 +01:00
Simon Laux
bfa8da092c add docs link to rpc-server npm package 2026-01-14 21:43:38 +01:00
Simon Laux
72eef59f25 clarify 2026-01-14 21:15:35 +01:00
Simon Laux
5f3dbe0037 fix formatting in readme 2026-01-14 21:14:00 +01:00
Simon Laux
a74edb2f86 minor improvement to npm package readme for stdio server 2026-01-14 21:11:33 +01:00
Simon Laux
963335e660 adapt Getting Started tutorial from cffi documentation 2026-01-14 20:59:08 +01:00
Simon Laux
b1ace43b28 add instructions on how to use on an unsupported platform 2026-01-14 20:59:08 +01:00
Simon Laux
1ac520439b run prettier formatter on some readme files 2026-01-14 20:59:06 +01:00
Simon Laux
c4d849a502 update deltachat-jsonrpc readme 2026-01-14 20:56:03 +01:00
Simon Laux
78bac6021f update link to echo bot in deltachat-rpc-server 2026-01-14 20:49:14 +01:00
Simon Laux
4602b661d9 dedicated readme for @deltachat/jsonrpc-client. so there is something more useful displayed on npm and on https://js.jsonrpc.delta.chat 2026-01-14 20:49:14 +01:00
Simon Laux
c65ce1b673 export Event and ContextEvents type to include them in the documentation. 2026-01-14 20:49:14 +01:00
644 changed files with 27422 additions and 29936 deletions

View File

@@ -15,6 +15,6 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
interval: "weekly"
cooldown:
default-days: 7

View File

@@ -20,10 +20,10 @@ permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.98.1
RUST_VERSION: 1.92.0
# Minimum Supported Rust Version
MSRV: 1.89.0
MSRV: 1.88.0
jobs:
lint_rust:
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -40,10 +40,7 @@ jobs:
- run: rustup override set $RUST_VERSION
shell: bash
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Run rustfmt
run: cargo fmt --all -- --check
- name: Run clippy
@@ -58,16 +55,30 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25
- uses: EmbarkStudios/cargo-deny-action@76cd80eb775d7bbbd2d80292136d74d39e1b4918
with:
arguments: --workspace --all-features --locked
arguments: --all-features --workspace
command: check
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:
name: Rust doc comments
runs-on: ubuntu-latest
@@ -75,15 +86,12 @@ jobs:
env:
RUSTDOCFLAGS: -Dwarnings
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Rustdoc
run: cargo doc --document-private-items --no-deps
@@ -114,7 +122,7 @@ jobs:
shell: bash
if: matrix.rust == 'latest'
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -126,13 +134,10 @@ jobs:
shell: bash
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Install nextest
uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc
uses: taiki-e/install-action@69e777b377e4ec209ddad9426ae3e0c1008b0ef3
with:
tool: nextest
@@ -157,22 +162,19 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Build C library
run: cargo build -p deltachat_ffi --locked
run: cargo build -p deltachat_ffi
- name: Upload C library
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.os }}-libdeltachat.a
path: target/debug/libdeltachat.a
@@ -186,22 +188,19 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: Build deltachat-rpc-server
run: cargo build -p deltachat-rpc-server --locked
run: cargo build -p deltachat-rpc-server
- name: Upload deltachat-rpc-server
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.os }}-deltachat-rpc-server
path: ${{ matrix.os == 'windows-latest' && 'target/debug/deltachat-rpc-server.exe' || 'target/debug/deltachat-rpc-server' }}
@@ -212,7 +211,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -238,7 +237,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -288,7 +287,7 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -300,7 +299,7 @@ jobs:
path: target/debug
- name: Install python
uses: actions/setup-python@v7.0.0
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
@@ -342,13 +341,13 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Install python
uses: actions/setup-python@v7.0.0
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}

View File

@@ -30,17 +30,17 @@ jobs:
arch: [aarch64, armv7l, armv6l, i686, x86_64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
- name: Upload binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}-linux
path: result/bin/deltachat-rpc-server
@@ -54,17 +54,17 @@ jobs:
arch: [aarch64, armv7l, armv6l, i686, x86_64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
- name: Upload wheel
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
path: result/*.whl
@@ -78,17 +78,17 @@ jobs:
arch: [win32, win64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
- name: Upload binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}
path: result/bin/deltachat-rpc-server.exe
@@ -102,17 +102,17 @@ jobs:
arch: [win32, win64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel
- name: Upload wheel
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}-wheel
path: result/*.whl
@@ -127,7 +127,7 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -139,7 +139,7 @@ jobs:
run: cargo build --release --package deltachat-rpc-server --target ${{ matrix.arch }}-apple-darwin --features vendored
- name: Upload binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}-macos
path: target/${{ matrix.arch }}-apple-darwin/release/deltachat-rpc-server
@@ -153,17 +153,17 @@ jobs:
arch: [arm64-v8a, armeabi-v7a]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
- name: Upload binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}-android
path: result/bin/deltachat-rpc-server
@@ -177,17 +177,17 @@ jobs:
arch: [arm64-v8a, armeabi-v7a]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel
- name: Upload wheel
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-${{ matrix.arch }}-android-wheel
path: result/*.whl
@@ -204,11 +204,11 @@ jobs:
contents: write
runs-on: "ubuntu-latest"
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v7
@@ -370,14 +370,6 @@ jobs:
- name: List artifacts
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
if: github.event_name == 'release'
env:
@@ -390,7 +382,7 @@ jobs:
- name: Publish deltachat-rpc-server to PyPI
if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e
publish_npm_package:
name: Build & Publish npm prebuilds and deltachat-rpc-server
@@ -405,11 +397,11 @@ jobs:
# Needed to publish the binaries to the release.
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: actions/setup-python@v7.0.0
- uses: actions/setup-python@v6
with:
python-version: "3.11"
@@ -504,7 +496,7 @@ jobs:
ls -lah
- name: Upload to artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: deltachat-rpc-server-npm-package
path: deltachat-rpc-server/npm-package/*.tgz
@@ -521,12 +513,15 @@ jobs:
deltachat-rpc-server/npm-package/*.tgz
# Configure Node.js for publishing.
# Check <https://docs.npmjs.com/trusted-publishers> for the version requirements.
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: 24
node-version: 20
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`
if: github.event_name == 'release'

View File

@@ -10,11 +10,11 @@ permissions:
jobs:
dependabot:
runs-on: ubuntu-latest
if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == github.event.pull_request.head.repo.full_name
if: ${{ github.actor == 'dependabot[bot]' }}
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v3.0.0
uses: dependabot/fetch-metadata@v2.4.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Approve a PR

View File

@@ -1,23 +0,0 @@
# Check that PRs are made against the -dev version.
#
# If this fails, push commit to update the version to -dev to main.
name: Check for -dev version
on:
pull_request:
permissions: {}
jobs:
check_dev_version:
name: Check that current version ends with -dev
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
show-progress: false
persist-credentials: false
- name: Run version-checking script
run: scripts/check-dev-version.py

View File

@@ -17,29 +17,31 @@ jobs:
id-token: write
contents: read
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
# Configure Node.js for publishing.
# Check <https://docs.npmjs.com/trusted-publishers> for the version requirements.
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: 24
node-version: 20
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
working-directory: deltachat-jsonrpc-bindings/typescript
working-directory: deltachat-jsonrpc/typescript
run: npm install --ignore-scripts
- name: Package
working-directory: deltachat-jsonrpc-bindings/typescript
working-directory: deltachat-jsonrpc/typescript
run: |
npm run build
npm pack .
- name: Publish
working-directory: deltachat-jsonrpc-bindings/typescript
working-directory: deltachat-jsonrpc/typescript
run: npm publish --provenance deltachat-jsonrpc-client-* --access public

View File

@@ -16,30 +16,27 @@ jobs:
build_and_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Use Node.js 24
uses: actions/setup-node@v7
- name: Use Node.js 18.x
uses: actions/setup-node@v6
with:
node-version: 24
node-version: 18.x
- name: Add Rust cache
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-bin: false
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5
- name: npm install
working-directory: deltachat-jsonrpc-bindings/typescript
working-directory: deltachat-jsonrpc/typescript
run: npm install
- name: Build TypeScript, run Rust tests, generate bindings
working-directory: deltachat-jsonrpc-bindings/typescript
working-directory: deltachat-jsonrpc/typescript
run: npm run build
- name: Run integration tests
working-directory: deltachat-jsonrpc-bindings/typescript
working-directory: deltachat-jsonrpc/typescript
run: npm run test
env:
CHATMAIL_DOMAIN: ${{ vars.CHATMAIL_DOMAIN }}
- name: Run linter
working-directory: deltachat-jsonrpc-bindings/typescript
working-directory: deltachat-jsonrpc/typescript
run: npm run prettier:check

View File

@@ -5,13 +5,11 @@ on:
paths:
- flake.nix
- flake.lock
- nix/**
- .github/workflows/nix.yml
push:
paths:
- flake.nix
- flake.lock
- nix/**
- .github/workflows/nix.yml
branches:
- main
@@ -23,12 +21,12 @@ jobs:
name: check flake formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- run: nix fmt flake.nix nix/ -- --check
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- run: nix fmt flake.nix -- --check
build:
name: nix build
@@ -65,6 +63,7 @@ jobs:
- deltachat-rpc-server-armv7l-linux-wheel
- deltachat-rpc-server-i686-linux
- deltachat-rpc-server-i686-linux-wheel
- deltachat-rpc-server-source
- deltachat-rpc-server-win32
- deltachat-rpc-server-win32-wheel
- deltachat-rpc-server-win64
@@ -81,11 +80,11 @@ jobs:
#- deltachat-rpc-server-x86_64-android
#- deltachat-rpc-server-x86-android
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- run: nix build .#${{ matrix.installable }}
build-macos:
@@ -102,9 +101,9 @@ jobs:
# because of <https://github.com/NixOS/nixpkgs/issues/413910>.
# - deltachat-rpc-server-aarch64-darwin
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- run: nix build .#${{ matrix.installable }}

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
@@ -23,7 +23,7 @@ jobs:
working-directory: deltachat-rpc-client
run: python3 -m build
- name: Store the distribution packages
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: python-package-distributions
path: deltachat-rpc-client/dist/
@@ -47,4 +47,4 @@ jobs:
name: python-package-distributions
path: dist/
- name: Publish deltachat-rpc-client to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e

View File

@@ -14,15 +14,15 @@ jobs:
name: Build REPL example
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31
- name: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: repl.exe
path: "result/bin/deltachat-repl.exe"

View File

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

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

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

View File

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

6
.github/zizmor.yml vendored
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -2,59 +2,45 @@ cmake_minimum_required(VERSION 3.16)
project(deltachat LANGUAGES C)
include(GNUInstallDirs)
option(WITH_JSONRPC_BINDINGS "Generate jsonrpc bindings" OFF)
find_program(CARGO cargo)
if(APPLE)
set(DYNAMIC_EXT "dylib")
set(DYNAMIC_EXT "dylib")
elseif(UNIX)
set(DYNAMIC_EXT "so")
set(DYNAMIC_EXT "so")
else()
set(DYNAMIC_EXT "dll")
set(DYNAMIC_EXT "dll")
endif()
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()
set(CARGO_OUT_DIR "${CMAKE_BINARY_DIR}/target/release")
set(ARCH_DIR "./")
endif()
if(WITH_JSONRPC_BINDINGS)
set(JSONRPC_ARGS --package deltachat-jsonrpc-bindings)
endif()
add_custom_target(
lib_deltachat
ALL
COMMAND
${CMAKE_COMMAND} -E env
CARGO_TARGET_DIR="${CMAKE_BINARY_DIR}/target"
PREFIX="${CMAKE_INSTALL_PREFIX}"
LIBDIR="${CMAKE_INSTALL_FULL_LIBDIR}"
INCLUDEDIR="${CMAKE_INSTALL_FULL_INCLUDEDIR}"
${CARGO} build --release --package deltachat_ffi ${JSONRPC_ARGS}
WORKING_DIRECTORY
"${CMAKE_CURRENT_SOURCE_DIR}"
add_custom_command(
OUTPUT
"${CMAKE_BINARY_DIR}/target/release/libdeltachat.a"
"${CMAKE_BINARY_DIR}/target/release/libdeltachat.${DYNAMIC_EXT}"
"${CMAKE_BINARY_DIR}/target/release/pkgconfig/deltachat.pc"
COMMAND
PREFIX=${CMAKE_INSTALL_PREFIX}
LIBDIR=${CMAKE_INSTALL_FULL_LIBDIR}
INCLUDEDIR=${CMAKE_INSTALL_FULL_INCLUDEDIR}
${CARGO} build --target-dir=${CMAKE_BINARY_DIR}/target --release
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/deltachat-ffi
)
install(FILES "deltachat-ffi/deltachat.h" DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}")
install(FILES "${CARGO_OUT_DIR}/libdeltachat.a" DESTINATION "${CMAKE_INSTALL_LIBDIR}")
install(FILES "${CARGO_OUT_DIR}/libdeltachat.${DYNAMIC_EXT}" DESTINATION "${CMAKE_INSTALL_LIBDIR}")
install(FILES "${CARGO_OUT_DIR}/pkgconfig/deltachat.pc" DESTINATION "${CMAKE_INSTALL_LIBDIR}/pkgconfig")
add_custom_target(
lib_deltachat
ALL
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
"${CMAKE_CURRENT_SOURCE_DIR}/deltachat-jsonrpc-bindings/qt/generated/types.hpp"
"${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()
install(FILES "deltachat-ffi/deltachat.h" DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(FILES "${CMAKE_BINARY_DIR}/target/${ARCH_DIR}/release/libdeltachat.a" DESTINATION ${CMAKE_INSTALL_LIBDIR})
install(FILES "${CMAKE_BINARY_DIR}/target/${ARCH_DIR}/release/libdeltachat.${DYNAMIC_EXT}" DESTINATION ${CMAKE_INSTALL_LIBDIR})
install(FILES "${CMAKE_BINARY_DIR}/target/${ARCH_DIR}/release/pkgconfig/deltachat.pc" DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig)

987
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
[package]
name = "deltachat"
version = "2.61.0-dev"
version = "2.37.0"
edition = "2024"
license = "MPL-2.0"
rust-version = "1.89"
rust-version = "1.88"
repository = "https://github.com/chatmail/core"
[profile.dev]
@@ -44,8 +44,8 @@ ratelimit = { path = "./deltachat-ratelimit" }
anyhow = { workspace = true }
async-broadcast = "0.7.2"
async-channel = { workspace = true }
async-imap = { version = "0.11.3", default-features = false, features = ["runtime-tokio", "compress"] }
async-native-tls = { version = "0.6", default-features = false, features = ["runtime-tokio"] }
async-imap = { version = "0.11.1", default-features = false, features = ["runtime-tokio", "compress"] }
async-native-tls = { version = "0.5", default-features = false, features = ["runtime-tokio"] }
async-smtp = { version = "0.10.2", default-features = false, features = ["runtime-tokio"] }
async_zip = { version = "0.0.18", default-features = false, features = ["deflate", "tokio-fs"] }
base64 = { workspace = true }
@@ -53,10 +53,10 @@ blake3 = "1.8.2"
brotli = { version = "8", default-features=false, features = ["std"] }
bytes = "1"
chrono = { workspace = true, features = ["alloc", "clock", "std"] }
colorutils-rs = { version = "0.8.0", default-features = false }
colorutils-rs = { version = "0.7.5", default-features = false }
data-encoding = "2.9.0"
escaper = "0.1"
fast-socks5 = "1"
fast-socks5 = "0.10"
fd-lock = "4"
futures-lite = { workspace = true }
futures = { workspace = true }
@@ -70,7 +70,7 @@ iroh-gossip = { version = "0.35", default-features = false, features = ["net"] }
iroh = { version = "0.35", default-features = false }
kamadak-exif = "0.6.1"
libc = { workspace = true }
mail-builder = { version = "0.5.0", default-features = false }
mail-builder = { version = "0.4.4", default-features = false }
mailparse = { workspace = true }
mime = "0.3.17"
num_cpus = "1.17"
@@ -78,31 +78,33 @@ num-derive = "0.4"
num-traits = { workspace = true }
parking_lot = "0.12.4"
percent-encoding = "2.3"
pgp = { version = "0.20.0", features = ["draft-pqc"], default-features = false }
pgp = { version = "0.18.0", default-features = false }
pin-project = "1"
qrcodegen = "1.7.0"
quick-xml = { version = "0.41", features = ["escape-html"] }
quick-xml = { version = "0.38", features = ["escape-html"] }
rand-old = { package = "rand", version = "0.8" }
rand = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rustls-pki-types = "1.12.0"
sanitize-filename = { workspace = true }
sdp = "0.17.1"
sdp = "0.10.0"
serde_json = { workspace = true }
serde_urlencoded = "0.7.1"
serde = { workspace = true, features = ["derive"] }
sha-1 = "0.10"
sha2 = "0.10"
shadowsocks = { version = "1.23.1", default-features = false, features = ["aead-cipher", "aead-cipher-2022"] }
smallvec = "1.15.1"
strum = "0.28"
strum_macros = "0.28"
strum = "0.27"
strum_macros = "0.27"
tagger = "4.3.4"
textwrap = "0.16.2"
thiserror = { workspace = true }
tokio-io-timeout = "1.2.1"
tokio-rustls = { version = "0.26.2", default-features = false, features = ["tls12", "brotli"] }
tokio-rustls = { version = "0.26.2", default-features = false }
tokio-stream = { version = "0.1.17", features = ["fs"] }
astral-tokio-tar = { version = "0.6.3", default-features = false }
astral-tokio-tar = { version = "0.5.6", default-features = false }
tokio-util = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.9"
@@ -129,7 +131,6 @@ members = [
"deltachat-ffi",
"deltachat_derive",
"deltachat-jsonrpc",
"deltachat-jsonrpc-bindings",
"deltachat-rpc-server",
"deltachat-ratelimit",
"deltachat-repl",
@@ -181,12 +182,11 @@ harness = false
anyhow = "1"
async-channel = "2.5.0"
base64 = "0.22"
chrono = { version = "0.4.44", default-features = false }
chrono = { version = "0.4.42", default-features = false }
deltachat-contact-tools = { path = "deltachat-contact-tools" }
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
deltachat-jsonrpc-bindings = { path = "deltachat-jsonrpc-bindings", default-features = false }
deltachat = { path = ".", default-features = false }
futures = "0.3.32"
futures = "0.3.31"
futures-lite = "2.6.1"
libc = "0.2"
log = "0.4"
@@ -194,17 +194,17 @@ mailparse = "0.16.1"
nu-ansi-term = "0.50"
num-traits = "0.2"
rand = "0.9"
regex = "1.12"
regex = "1.10"
rusqlite = "0.37"
sanitize-filename = "0.6"
serde = "1.0"
serde_json = "1"
tempfile = "3.27.0"
tempfile = "3.24.0"
thiserror = "2"
tokio = "1"
tokio-util = "0.7.18"
tokio-util = "0.7.17"
tracing-subscriber = "0.3"
yerpc = "0.7"
yerpc = "0.6.4"
[features]
default = ["vendored"]

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
- robust [SMTP](https://github.com/chatmail/async-smtp)
and [IMAP](https://github.com/chatmail/async-imap) handling
- robust [SMTP](https://github.com/chatmail/async-imap)
and [IMAP](https://github.com/chatmail/async-smtp) handling
- safe and interoperable [MIME parsing](https://github.com/staktrace/mailparse)
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
```sh
$ cargo install --locked cargo-bolero
$ cargo install cargo-bolero
```
Run fuzzing tests with

View File

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

View File

@@ -21,7 +21,7 @@ text TEXT DEFAULT '' NOT NULL -- message text
```
Do not use macros like [`concat!`](https://doc.rust-lang.org/std/macro.concat.html)
or [`indoc!`](https://docs.rs/indoc).
or [`indoc!](https://docs.rs/indoc).
Do not escape newlines like this:
```
sql.execute(
@@ -59,13 +59,6 @@ If column is already declared without `NOT NULL`, use `IFNULL` function to provi
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).
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
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
@@ -75,12 +68,6 @@ keyword doesn't help here.
Consider adding context to `anyhow` errors for SQL statements using `.context()` so that it's
possible to understand from logs which statement failed. See [Errors](#errors) for more info.
When changing complex SQL queries, test them on a new database with `EXPLAIN QUERY PLAN`
to make sure that indexes are used and large tables are not going to be scanned.
Never run `ANALYZE` on the databases,
this makes query planner unpredictable
and may make performance significantly worse: <https://github.com/chatmail/core/issues/6585>
## Errors
Delta Chat core mostly uses [`anyhow`](https://docs.rs/anyhow/) errors.
@@ -168,23 +155,3 @@ are documented.
Follow Rust guidelines for the documentation comments:
<https://rust-lang.github.io/rfcs/1574-more-api-documentation-conventions.html#summary-sentence>
## Do not use `into()`, `try_into()` or `parse()`
For internal types, implementing `From`, `TryFrom` or `FromStr` is discouraged.
Instead, a `new()` function is recommended.
For external types, prefer using `Type::from()`, `Type::try_from()` or `Type::from_str()`
over `into()`, `try_into()` or `parse()`.
Calling `into()`, `try_into()` or `parse()`
creates an indirection,
which is hard to follow for people who are not familiar with Rust,
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.

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
[package]
name = "deltachat-contact-tools"
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."
license = "MPL-2.0"

View File

@@ -29,16 +29,17 @@
use std::fmt;
use std::ops::Deref;
use std::sync::LazyLock;
use anyhow::Result;
use anyhow::bail;
use regex::regex;
use anyhow::Result;
use regex::Regex;
mod vcard;
pub use vcard::{VcardContact, make_vcard, parse_vcard};
pub use vcard::{make_vcard, parse_vcard, VcardContact};
/// Valid contact address.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone)]
pub struct ContactAddress(String);
impl Deref for ContactAddress {
@@ -87,7 +88,9 @@ impl rusqlite::types::ToSql for ContactAddress {
/// - Removes special characters from the name, see [`sanitize_name()`]
/// - 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) {
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() {
captures.get(1).map_or("", |m| m.as_str())

View File

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

View File

@@ -91,7 +91,7 @@ fn test_make_and_parse_vcard() {
authname: "Alice Wonderland".to_string(),
key: Some("[base64-data]".to_string()),
profile_image: Some("image in Base64".to_string()),
biography: Some("Hi,\nI'm Alice; and this is a backslash: \\".to_string()),
biography: Some("Hi, I'm Alice".to_string()),
timestamp: Ok(1713465762),
},
VcardContact {
@@ -110,7 +110,7 @@ fn test_make_and_parse_vcard() {
FN:Alice Wonderland\r\n\
KEY:data:application/pgp-keys;base64\\,[base64-data]\r\n\
PHOTO:data:image/jpeg;base64\\,image in Base64\r\n\
NOTE:Hi\\,\\nI'm Alice\\; and this is a backslash: \\\\\r\n\
NOTE:Hi\\, I'm Alice\r\n\
REV:20240418T184242Z\r\n\
END:VCARD\r\n",
"BEGIN:VCARD\r\n\
@@ -220,10 +220,7 @@ END:VCARD
assert_eq!(contacts[0].addr, "bob@example.org".to_string());
assert_eq!(contacts[0].authname, "Bob".to_string());
assert_eq!(contacts[0].key, None);
assert_eq!(
contacts[0].profile_image.as_deref().unwrap(),
"/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEUAAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAAL8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q=="
);
assert_eq!(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[0].addr, "alice@example.org");
assert_eq!(&contacts[0].authname, "Alice Wonderland");
assert_eq!(
contacts[0].key.as_ref().unwrap(),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
assert_eq!(contacts[0].key.as_ref().unwrap(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
assert!(contacts[0].timestamp.is_err());
assert_eq!(contacts[0].profile_image, None);
}
@@ -278,24 +272,7 @@ END:VCARD",
assert_eq!(contacts.len(), 1);
assert_eq!(&contacts[0].addr, "alice@example.org");
assert_eq!(&contacts[0].authname, "Alice");
assert_eq!(
contacts[0].key.as_ref().unwrap(),
"xsaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=="
);
assert_eq!(contacts[0].key.as_ref().unwrap(), "xsaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa==");
assert!(contacts[0].timestamp.is_err());
assert_eq!(
contacts[0].profile_image.as_ref().unwrap(),
"/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z"
);
}
#[test]
fn test_vcard_value_escape_unescape() {
let original = "Text, with; chars and a \\ and a newline\nand a literal newline \\n";
let expected_escaped = r"Text\, with\; chars and a \\ and a newline\nand a literal newline \\n";
let escaped = escape(original);
assert_eq!(escaped, expected_escaped);
let unescaped = unescape(&escaped);
assert_eq!(original, unescaped);
assert_eq!(contacts[0].profile_image.as_ref().unwrap(), "/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z");
}

View File

@@ -1,8 +1,8 @@
[package]
name = "deltachat_ffi"
version = "2.61.0-dev"
version = "2.37.0"
description = "Deltachat FFI"
edition = "2024"
edition = "2018"
readme = "README.md"
license = "MPL-2.0"

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
use crate::chat::ChatItem;
use crate::constants::DC_MSG_ID_DAYMARKER;
use crate::contact::ContactId;
use crate::location::Location;
use crate::message::MsgId;
@@ -20,7 +21,7 @@ impl dc_array_t {
Self::ContactIds(array) => array[index].to_u32(),
Self::Chat(array) => match array[index] {
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::Uint(array) => array[index],

File diff suppressed because it is too large Load Diff

View File

@@ -230,6 +230,7 @@ pub enum LotState {
MsgInFresh = 10,
MsgInNoticed = 13,
MsgInSeen = 16,
MsgOutPreparing = 18,
MsgOutDraft = 19,
MsgOutPending = 20,
MsgOutFailed = 24,
@@ -245,6 +246,7 @@ impl From<MessageState> for LotState {
InFresh => LotState::MsgInFresh,
InNoticed => LotState::MsgInNoticed,
InSeen => LotState::MsgInSeen,
OutPreparing => LotState::MsgOutPreparing,
OutDraft => LotState::MsgOutDraft,
OutPending => LotState::MsgOutPending,
OutFailed => LotState::MsgOutFailed,

View File

@@ -17,15 +17,13 @@ use std::ptr;
/// }
/// ```
unsafe fn dc_strdup(s: *const libc::c_char) -> *mut libc::c_char {
unsafe {
let ret: *mut libc::c_char = if !s.is_null() {
libc::strdup(s)
} else {
libc::calloc(1, 1) as *mut libc::c_char
};
assert!(!ret.is_null());
ret
}
let ret: *mut libc::c_char = if !s.is_null() {
libc::strdup(s)
} else {
libc::calloc(1, 1) as *mut libc::c_char
};
assert!(!ret.is_null());
ret
}
/// Error type for the [OsStrExt] trait
@@ -166,40 +164,34 @@ pub(crate) trait Strdup {
/// This function will panic when the original string contains an
/// interior null byte as this can not be represented in raw C
/// strings.
fn strdup(&self) -> *mut libc::c_char;
unsafe fn strdup(&self) -> *mut libc::c_char;
}
impl Strdup for str {
fn strdup(&self) -> *mut libc::c_char {
unsafe {
let tmp = CString::new_lossy(self);
dc_strdup(tmp.as_ptr())
}
unsafe fn strdup(&self) -> *mut libc::c_char {
let tmp = CString::new_lossy(self);
dc_strdup(tmp.as_ptr())
}
}
impl Strdup for String {
fn strdup(&self) -> *mut libc::c_char {
unsafe fn strdup(&self) -> *mut libc::c_char {
let s: &str = self;
s.strdup()
}
}
impl Strdup for std::path::Path {
fn strdup(&self) -> *mut libc::c_char {
unsafe {
let tmp = self.to_c_string().unwrap_or_else(|_| CString::default());
dc_strdup(tmp.as_ptr())
}
unsafe fn strdup(&self) -> *mut libc::c_char {
let tmp = self.to_c_string().unwrap_or_else(|_| CString::default());
dc_strdup(tmp.as_ptr())
}
}
impl Strdup for [u8] {
fn strdup(&self) -> *mut libc::c_char {
unsafe {
let tmp = CString::new_lossy(self);
dc_strdup(tmp.as_ptr())
}
unsafe fn strdup(&self) -> *mut libc::c_char {
let tmp = CString::new_lossy(self);
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.
///
/// 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> {
fn strdup(&self) -> *mut libc::c_char {
unsafe fn strdup(&self) -> *mut libc::c_char {
match self {
Some(s) => {
let tmp = CString::new_lossy(s.as_ref());
unsafe { dc_strdup(tmp.as_ptr()) }
dc_strdup(tmp.as_ptr())
}
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
#[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");
use std::os::unix::ffi::OsStrExt;
let c_str = unsafe { std::ffi::CStr::from_ptr(s) }.to_bytes();
let os_str = std::ffi::OsStr::from_bytes(c_str);
std::path::Path::new(os_str)
unsafe {
let c_str = std::ffi::CStr::from_ptr(s).to_bytes();
let os_str = std::ffi::OsStr::from_bytes(c_str);
std::path::Path::new(os_str)
}
}
// as_path() implementation for windows, documented above.
#[cfg(target_os = "windows")]
pub(crate) unsafe fn as_path<'a>(s: *const libc::c_char) -> &'a std::path::Path {
unsafe { as_path_unicode(s) }
pub(crate) fn as_path<'a>(s: *const libc::c_char) -> &'a std::path::Path {
as_path_unicode(s)
}
// 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
// too.
#[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");
let cstr = unsafe { CStr::from_ptr(s) };
@@ -370,20 +364,14 @@ mod tests {
fn test_as_path() {
let some_path = CString::new("/some/path").unwrap();
let ptr = some_path.as_ptr();
assert_eq!(
unsafe { as_path(ptr) },
std::ffi::OsString::from("/some/path")
)
assert_eq!(as_path(ptr), std::ffi::OsString::from("/some/path"))
}
#[test]
fn test_as_path_unicode_fn() {
let some_path = CString::new("/some/path").unwrap();
let ptr = some_path.as_ptr();
assert_eq!(
unsafe { as_path_unicode(ptr) },
std::ffi::OsString::from("/some/path")
);
assert_eq!(as_path_unicode(ptr), std::ffi::OsString::from("/some/path"));
}
#[test]

View File

@@ -1,16 +0,0 @@
[package]
name = "deltachat-jsonrpc-bindings"
version = "2.61.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"]

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");
}

View File

@@ -1 +0,0 @@
generated

View File

@@ -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 *)

View File

@@ -1 +0,0 @@

View File

@@ -1,8 +1,8 @@
[package]
name = "deltachat-jsonrpc"
version = "2.61.0-dev"
version = "2.37.0"
description = "DeltaChat JSON-RPC API"
edition = "2024"
edition = "2021"
license = "MPL-2.0"
repository = "https://github.com/chatmail/core"

View File

@@ -16,6 +16,7 @@ We also include a JavaScript and TypeScript client for the JSON-RPC API. The sou
The package includes a JavaScript/TypeScript client which is partially auto-generated through the JSON-RPC library used by this crate ([yerpc](https://github.com/chatmail/yerpc)). Find the source in the [`typescript`](typescript) folder.
To use it locally, first install the dependencies and compile the TypeScript code to JavaScript:
```sh
cd typescript
npm install
@@ -24,11 +25,28 @@ npm run build
The JavaScript client is [published on NPM](https://www.npmjs.com/package/@deltachat/jsonrpc-client).
###### Usage
```typescript
import { startDeltaChat } from "@deltachat/stdio-rpc-server";
import { C } from "@deltachat/jsonrpc-client";
const dc = await startDeltaChat("deltachat-data");
console.log(await dc.rpc.getSystemInfo());
const accounts = await dc.rpc.getAllAccounts();
console.log("accounts", accounts);
dc.close();
```
##### Generate TypeScript/JavaScript documentation
A script is included to build autogenerated documentation, which includes all RPC methods:
```sh
cd typescript
npm run docs
```
Then open the [`typescript/docs`](typescript/docs) folder in a web browser.
## Development

View File

@@ -5,40 +5,42 @@ use std::sync::Arc;
use std::time::Duration;
use std::{collections::HashMap, str::FromStr};
use anyhow::{Context, Result, anyhow, bail, ensure};
use deltachat::EventEmitter;
use anyhow::{anyhow, bail, ensure, Context, Result};
pub use deltachat::accounts::Accounts;
use deltachat::blob::BlobObject;
use deltachat::calls::ice_servers;
use deltachat::chat::{
self, Chat, ChatId, ChatItem, MessageListOptions, add_contact_to_chat, forward_msgs,
forward_msgs_2ctx, get_chat_media, get_chat_msgs, get_chat_msgs_ext, markfresh_chat,
marknoticed_all_chats, marknoticed_chat, remove_contact_from_chat,
self, add_contact_to_chat, forward_msgs, forward_msgs_2ctx, get_chat_media, get_chat_msgs,
get_chat_msgs_ex, marknoticed_chat, remove_contact_from_chat, Chat, ChatId, ChatItem,
MessageListOptions,
};
use deltachat::chatlist::Chatlist;
use deltachat::config::{Config, get_all_ui_config_keys};
use deltachat::contact::{Contact, ContactId, Origin, may_be_valid_addr};
use deltachat::config::{get_all_ui_config_keys, Config};
use deltachat::constants::DC_MSG_ID_DAYMARKER;
use deltachat::contact::{may_be_valid_addr, Contact, ContactId, Origin};
use deltachat::context::get_info;
use deltachat::ephemeral::Timer;
use deltachat::imex;
use deltachat::location;
use deltachat::message::{
self, Message, MessageState, MsgId, Viewtype, delete_msgs_ext, get_existing_msg_ids,
get_msg_read_receipt_count, get_msg_read_receipts, markseen_msgs,
self, delete_msgs_ex, get_existing_msg_ids, get_msg_read_receipts, markseen_msgs, Message,
MessageState, MsgId, Viewtype,
};
use deltachat::peer_channels::{
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_code_generator::{create_qr_svg, generate_backup_qr, get_securejoin_qr_svg};
use deltachat::qr_code_generator::{generate_backup_qr, get_securejoin_qr_svg};
use deltachat::reaction::{get_msg_reactions, send_reaction};
use deltachat::securejoin;
use deltachat::stock_str::StockMessage;
use deltachat::storage_usage::{get_blobdir_storage_usage, get_storage_usage};
use deltachat::webxdc::StatusUpdateSerial;
use deltachat::EventEmitter;
use sanitize_filename::is_sanitized;
use tokio::fs;
use tokio::sync::{Mutex, RwLock, watch};
use tokio::sync::{watch, Mutex, RwLock};
use types::login_param::EnteredLoginParam;
use yerpc::rpc;
@@ -52,6 +54,8 @@ use types::contact::{ContactObject, VcardContact};
use types::events::Event;
use types::http::HttpResponse;
use types::message::{MessageData, MessageObject, MessageReadReceipt};
use types::notify_state::JsonrpcNotifyState;
use types::provider_info::ProviderInfo;
use types::reactions::JsonrpcReactions;
use types::webxdc::WebxdcMessageInfo;
@@ -63,8 +67,7 @@ use self::types::{
JsonrpcMessageListItem, MessageNotificationInfo, MessageSearchResult, MessageViewtype,
},
};
use crate::api::types::appversions::JsonrpcAppSource;
use crate::api::types::chat_list::{ChatListItemFetchResult, get_chat_list_item_by_id};
use crate::api::types::chat_list::{get_chat_list_item_by_id, ChatListItemFetchResult};
use crate::api::types::qr::{QrObject, SecurejoinSource, SecurejoinUiPath};
#[derive(Debug)]
@@ -153,7 +156,7 @@ impl CommandApi {
}
}
#[rpc(all_positional)]
#[rpc(all_positional, ts_outdir = "typescript/generated")]
impl CommandApi {
/// Test function.
async fn sleep(&self, delay: f64) {
@@ -191,16 +194,6 @@ impl CommandApi {
.context("event channel is closed")
}
/// Waits for at least one event and return a batch of events.
async fn get_next_event_batch(&self) -> Vec<Event> {
self.event_emitter
.recv_batch()
.await
.into_iter()
.map(|event| event.into())
.collect()
}
// ---------------------------------------------
// Account Management
// ---------------------------------------------
@@ -278,8 +271,7 @@ impl CommandApi {
/// Performs a background fetch for all accounts in parallel with a timeout.
///
/// The `AccountsBackgroundFetchDone` event is emitted at the end even in case of timeout,
/// and immediately if another background fetch is already running.
/// The `AccountsBackgroundFetchDone` event is emitted at the end even in case of timeout.
/// Process all events until you get this one and you can safely return to the background
/// without forgetting to create notifications caused by timing race conditions.
async fn background_fetch(&self, timeout_in_seconds: f64) -> Result<()> {
@@ -327,6 +319,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
async fn get_account_file_size(&self, account_id: u32) -> Result<u64> {
let ctx = self.get_context(account_id).await?;
@@ -336,6 +334,21 @@ impl CommandApi {
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.
async fn is_configured(&self, account_id: u32) -> Result<bool> {
let ctx = self.get_context(account_id).await?;
@@ -449,7 +462,9 @@ impl CommandApi {
let accounts = self.accounts.read().await;
for (stock_id, stock_message) in strings {
if let Some(stock_id) = StockMessage::from_u32(stock_id) {
accounts.set_stock_translation(stock_id, stock_message)?;
accounts
.set_stock_translation(stock_id, stock_message)
.await?;
}
}
Ok(())
@@ -527,7 +542,7 @@ impl CommandApi {
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to remove a transport.
/// and [Self::delete_transport()] to delete a transport.
async fn list_transports(&self, account_id: u32) -> Result<Vec<EnteredLoginParam>> {
let ctx = self.get_context(account_id).await?;
let res = ctx
@@ -539,12 +554,8 @@ impl CommandApi {
Ok(res)
}
/// Removes a transport.
/// UIs should call this function when the user removes a relay.
///
/// The last transport cannot be removed.
/// If the removed transport was the one used for sending,
/// another one is chosen automatically.
/// Removes the transport with the specified email address
/// (i.e. [EnteredLoginParam::addr]).
async fn delete_transport(&self, account_id: u32, addr: String) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.delete_transport(&addr).await
@@ -621,7 +632,7 @@ impl CommandApi {
ChatId::new(chat_id).get_fresh_msg_cnt(&ctx).await
}
/// (deprecated) Gets messages to be processed by the bot and returns their IDs.
/// Gets messages to be processed by the bot and returns their IDs.
///
/// Only messages with database ID higher than `last_msg_id` config value
/// are returned. After processing the messages, the bot should
@@ -629,13 +640,6 @@ impl CommandApi {
/// or manually updating the value to avoid getting already
/// processed messages.
///
/// Deprecated 2026-04: This returns the message's id as soon as the first part arrives,
/// even if it is not fully downloaded yet.
/// The bot needs to wait for the message to be fully downloaded.
/// Since this is usually not the desired behavior,
/// bots should instead use the #DC_EVENT_INCOMING_MSG / [`types::events::EventType::IncomingMsg`]
/// event for getting notified about new messages.
///
/// [`markseen_msgs`]: Self::markseen_msgs
async fn get_next_msgs(&self, account_id: u32) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?;
@@ -648,7 +652,7 @@ impl CommandApi {
Ok(msg_ids)
}
/// (deprecated) Waits for messages to be processed by the bot and returns their IDs.
/// Waits for messages to be processed by the bot and returns their IDs.
///
/// This function is similar to [`get_next_msgs`],
/// but waits for internal new message notification before returning.
@@ -659,13 +663,6 @@ impl CommandApi {
/// To shutdown the bot, stopping I/O can be used to interrupt
/// pending or next `wait_next_msgs` call.
///
/// Deprecated 2026-04: This returns the message's id as soon as the first part arrives,
/// even if it is not fully downloaded yet.
/// The bot needs to wait for the message to be fully downloaded.
/// Since this is usually not the desired behavior,
/// bots should instead use the #DC_EVENT_INCOMING_MSG / [`types::events::EventType::IncomingMsg`]
/// event for getting notified about new messages.
///
/// [`get_next_msgs`]: Self::get_next_msgs
async fn wait_next_msgs(&self, account_id: u32) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?;
@@ -678,19 +675,10 @@ impl CommandApi {
Ok(msg_ids)
}
/// Estimates the number of messages that will be deleted
/// by the `set_config()`-option `delete_device_after`.
///
/// Estimate the number of messages that will be deleted
/// by the set_config()-options `delete_device_after` or `delete_server_after`.
/// This is typically used to show the estimated impact to the user
/// before actually enabling deletion of old messages.
///
/// Messages in the "Saved Messages" chat are not counted as they will not be deleted automatically.
///
/// Parameters:
/// - `from_server`: Deprecated, pass `false` here
/// - `seconds`: Count messages older than the given number of seconds.
///
/// Returns the number of messages that are older than the given number of seconds.
async fn estimate_auto_deletion_count(
&self,
account_id: u32,
@@ -701,6 +689,25 @@ impl CommandApi {
message::estimate_deletion_cnt(&ctx, from_server, seconds).await
}
// ---------------------------------------------
// autocrypt
// ---------------------------------------------
async fn initiate_autocrypt_key_transfer(&self, account_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
deltachat::imex::initiate_key_transfer(&ctx).await
}
async fn continue_autocrypt_key_transfer(
&self,
account_id: u32,
message_id: u32,
setup_code: String,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
deltachat::imex::continue_key_transfer(&ctx, MsgId::new(message_id), &setup_code).await
}
// ---------------------------------------------
// chat list
// ---------------------------------------------
@@ -802,7 +809,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
/// and the user may create the chat again.
/// - **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
/// all group members - especially for groups not used for a longer time, this is
/// really unexpected when deletion results in contacting all members again,
@@ -847,8 +854,6 @@ impl CommandApi {
/// if `checkQr()` returns `askVerifyContact` or `askVerifyGroup`
/// an out-of-band-verification can be joined using `secure_join()`
///
/// @deprecated as of 2026-03; use create_qr_svg(get_chat_securejoin_qr_code()) instead.
///
/// chat_id: If set to a group-chat-id,
/// the Verified-Group-Invite protocol is offered in the QR code;
/// works for protected groups as well as for normal groups.
@@ -886,6 +891,7 @@ impl CommandApi {
/// to `check_qr()`.
///
/// **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> {
let ctx = self.get_context(account_id).await?;
@@ -966,7 +972,7 @@ impl CommandApi {
/// 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.
///
/// - for group chats all members are returned, DC_CONTACT_ID_SELF is returned
@@ -1023,9 +1029,7 @@ impl CommandApi {
/// Create a new unencrypted group chat.
///
/// 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
/// 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.
/// address-contacts.
async fn create_group_chat_unencrypted(&self, account_id: u32, name: String) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
chat::create_group_unencrypted(&ctx, &name)
@@ -1050,6 +1054,9 @@ impl CommandApi {
/// because the word "channel" already appears a lot in the code,
/// which would make it hard to grep for it.
///
/// After creation, the chat contains no recipients and is in _unpromoted_ state;
/// see [`CommandApi::create_group_chat`] for more information on the unpromoted state.
///
/// Returns the created chat's id.
async fn create_broadcast(&self, account_id: u32, chat_name: String) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
@@ -1061,8 +1068,7 @@ impl CommandApi {
/// Set group name.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
/// all group members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
async fn set_chat_name(&self, account_id: u32, chat_id: u32, new_name: String) -> Result<()> {
@@ -1070,39 +1076,10 @@ impl CommandApi {
chat::set_chat_name(&ctx, ChatId::new(chat_id), &new_name).await
}
/// Set group or broadcast channel description.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
///
/// See also [`Self::get_chat_description`] / `getChatDescription()`.
async fn set_chat_description(
&self,
account_id: u32,
chat_id: u32,
description: String,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
chat::set_chat_description(&ctx, ChatId::new(chat_id), &description).await
}
/// Load the chat description from the database.
///
/// UIs show this in the profile page of the chat,
/// it is settable by [`Self::set_chat_description`] / `setChatDescription()`.
async fn get_chat_description(&self, account_id: u32, chat_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
chat::get_chat_description(&ctx, ChatId::new(chat_id)).await
}
/// Set group profile image.
///
/// If the group is already _promoted_ (any message was sent to the group),
/// or if this is a brodacast channel,
/// all members are informed by a special status message that is sent automatically by this function.
/// all group members are informed by a special status message that is sent automatically by this function.
///
/// Sends out #DC_EVENT_CHAT_MODIFIED and #DC_EVENT_MSGS_CHANGED if a status message was sent.
///
@@ -1187,24 +1164,10 @@ impl CommandApi {
Ok(None)
}
/// Mark all messages in all chats as _noticed_.
/// Skips messages from blocked contacts, but does not skip messages in muted chats.
///
/// _Noticed_ messages are no longer _fresh_ and do not count as being unseen
/// but are still waiting for being marked as "seen" using markseen_msgs()
/// (read receipts aren't sent for noticed messages).
///
/// Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
/// See also markseen_msgs().
pub async fn marknoticed_all_chats(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
marknoticed_all_chats(&ctx).await
}
/// Mark all messages in a chat as _noticed_.
/// _Noticed_ messages are no longer _fresh_ and do not count as being unseen
/// but are still waiting for being marked as "seen" using markseen_msgs()
/// (read receipts aren't sent for noticed messages).
/// (IMAP/MDNs is not done for noticed messages).
///
/// Calling this function usually results in the event #DC_EVENT_MSGS_NOTICED.
/// See also markseen_msgs().
@@ -1213,15 +1176,6 @@ impl CommandApi {
marknoticed_chat(&ctx, ChatId::new(chat_id)).await
}
/// Marks the last incoming message in the chat as _fresh_.
///
/// UI can use this to offer a "mark unread" option,
/// so that already noticed chats get a badge counter again.
async fn markfresh_chat(&self, account_id: u32, chat_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
markfresh_chat(&ctx, ChatId::new(chat_id)).await
}
/// Returns the message that is immediately followed by the last seen
/// message.
/// From the point of view of the user this is effectively
@@ -1294,7 +1248,7 @@ impl CommandApi {
/// 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. :)
///
/// - 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)
/// and the internal state is changed to @ref DC_STATE_IN_SEEN to reflect these actions.
///
@@ -1316,37 +1270,26 @@ impl CommandApi {
markseen_msgs(&ctx, msg_ids.into_iter().map(MsgId::new).collect()).await
}
/// Get all message IDs belonging to a chat.
/// Returns all messages of a particular chat.
///
/// The list is already sorted and starts with the oldest message.
/// Clients should not try to re-sort the list as this would be an expensive action
/// and would result in inconsistencies between clients.
/// Note that the messages are not necessarily sorted by their ID or by their displayed timestamp;
/// UIs need to handle both the case of descending message IDs
/// and of decreasing timestamps.
///
/// Optionally, 'daymarkers' added to the ID array may help to
/// implement virtual lists.
///
/// Parameters:
///
/// * chat_id The chat ID of which the messages IDs should be queried.
/// * _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
/// corresponding (following) day in the local timezone.
async fn get_message_ids(
&self,
account_id: u32,
chat_id: u32,
_info_only: bool,
info_only: bool,
add_daymarker: bool,
) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?;
let msg = get_chat_msgs_ext(
let msg = get_chat_msgs_ex(
&ctx,
ChatId::new(chat_id),
MessageListOptions { add_daymarker },
MessageListOptions {
info_only,
add_daymarker,
},
)
.await?;
Ok(msg
@@ -1354,7 +1297,7 @@ impl CommandApi {
.map(|chat_item| -> u32 {
match chat_item {
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())
@@ -1378,24 +1321,21 @@ impl CommandApi {
}
}
/// Get all messages belonging to a chat.
///
/// Similar to `get_message_ids` / `getMessageIds`,
/// see that function for details.
/// The difference is that this function here returns a list of `MessageListItem`,
/// which is an enum of a message or a daymarker.
async fn get_message_list_items(
&self,
account_id: u32,
chat_id: u32,
_info_only: bool,
info_only: bool,
add_daymarker: bool,
) -> Result<Vec<JsonrpcMessageListItem>> {
let ctx = self.get_context(account_id).await?;
let msg = get_chat_msgs_ext(
let msg = get_chat_msgs_ex(
&ctx,
ChatId::new(chat_id),
MessageListOptions { add_daymarker },
MessageListOptions {
info_only,
add_daymarker,
},
)
.await?;
Ok(msg
@@ -1458,32 +1398,12 @@ impl CommandApi {
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
/// on the IMAP server.
async fn delete_messages(&self, account_id: u32, message_ids: Vec<u32>) -> Result<()> {
let ctx = self.get_context(account_id).await?;
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,
@@ -1491,7 +1411,7 @@ impl CommandApi {
async fn delete_messages_for_all(&self, account_id: u32, message_ids: Vec<u32>) -> Result<()> {
let ctx = self.get_context(account_id).await?;
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
@@ -1514,18 +1434,6 @@ impl CommandApi {
MessageInfo::from_msg_id(&ctx, MsgId::new(message_id)).await
}
/// Returns count of read receipts on message.
///
/// This view count is meant as a feedback measure for the channel owner only.
async fn get_message_read_receipt_count(
&self,
account_id: u32,
message_id: u32,
) -> Result<usize> {
let ctx = self.get_context(account_id).await?;
get_msg_read_receipt_count(&ctx, MsgId::new(message_id)).await
}
/// Returns contacts that sent read receipts and the time of reading.
async fn get_message_read_receipts(
&self,
@@ -1852,11 +1760,25 @@ impl CommandApi {
deltachat::contact::make_vcard(&ctx, &contacts).await
}
/// Sets vCard containing the given contacts to the message draft.
async fn set_draft_vcard(
&self,
account_id: u32,
msg_id: u32,
contacts: Vec<u32>,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
let contacts: Vec<_> = contacts.iter().map(|&c| ContactId::new(c)).collect();
let mut msg = Message::load_from_db(&ctx, MsgId::new(msg_id)).await?;
msg.make_vcard(&ctx, &contacts).await?;
msg.get_chat_id().set_draft(&ctx, Some(&mut msg)).await
}
// ---------------------------------------------
// 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.
async fn get_chat_id_by_contact_id(
@@ -1992,8 +1914,6 @@ impl CommandApi {
/// even if there is no concurrent call to [`CommandApi::provide_backup`],
/// but will fail after 60 seconds to avoid deadlocks.
///
/// @deprecated as of 2026-03; use `create_qr_svg(get_backup_qr())` instead.
///
/// Returns the QR code rendered as an SVG image.
async fn get_backup_qr_svg(&self, account_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
@@ -2007,11 +1927,6 @@ impl CommandApi {
generate_backup_qr(&ctx, &qr).await
}
/// Renders the given text as a QR code SVG image.
async fn create_qr_svg(&self, text: String) -> Result<String> {
create_qr_svg(&text)
}
/// Gets a backup from a remote provider.
///
/// This retrieves the backup from a remote device over the network and imports it into
@@ -2039,20 +1954,15 @@ impl CommandApi {
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.
/// One of:
/// - DC_CONNECTIVITY_NOT_CONNECTED (1000): 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_WORKING (3000): 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_NOT_CONNECTED (1000-1999): Show e.g. the string "Not connected" or a red dot
/// - DC_CONNECTIVITY_CONNECTING (2000-2999): Show e.g. the string "Connecting…" or a yellow dot
/// - 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
///
/// 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
/// e.g. in the title of the main screen.
@@ -2081,21 +1991,6 @@ impl CommandApi {
// locations
// ---------------------------------------------
/// Sets current location.
///
/// Returns true if location streaming is currently
/// enabled and locations should be updated.
///
/// Location is represented as latitude and longitude in degrees
/// and horizontal accuracy in meters.
async fn set_location(&self, latitude: f64, longitude: f64, accuracy: f64) -> Result<bool> {
self.accounts
.read()
.await
.set_location(latitude, longitude, accuracy)
.await
}
async fn get_locations(
&self,
account_id: u32,
@@ -2118,39 +2013,6 @@ impl CommandApi {
Ok(locations.into_iter().map(|l| l.into()).collect())
}
/// Enables location streaming in chat identified by `chat_id` for `seconds` seconds.
///
/// Pass 0 as the number of seconds to disable location streaming in the chat.
async fn send_locations_to_chat(
&self,
account_id: u32,
chat_id: u32,
seconds: i64,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
let chat_id = ChatId::new(chat_id);
location::send_to_chat(&ctx, chat_id, seconds).await?;
Ok(())
}
/// Returns whether any chat is sending locations.
async fn is_sending_locations(&self, account_id: u32) -> Result<bool> {
let ctx = self.get_context(account_id).await?;
location::is_sending(&ctx).await
}
/// Returns whether `chat_id` is sending locations.
async fn is_sending_locations_to_chat(&self, account_id: u32, chat_id: u32) -> Result<bool> {
let ctx = self.get_context(account_id).await?;
let chat_id = ChatId::new(chat_id);
location::is_sending_to_chat(&ctx, chat_id).await
}
/// Stops sending locations to all chats.
async fn stop_sending_locations(&self) -> Result<()> {
self.accounts.read().await.stop_sending_locations().await
}
// ---------------------------------------------
// webxdc
// ---------------------------------------------
@@ -2236,9 +2098,6 @@ impl CommandApi {
/// Get blob encoded as base64 from a webxdc message
///
/// 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(
&self,
account_id: u32,
@@ -2249,7 +2108,7 @@ impl CommandApi {
let message = Message::load_from_db(&ctx, MsgId::new(instance_msg_id)).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))
}
@@ -2282,11 +2141,10 @@ impl CommandApi {
account_id: u32,
chat_id: u32,
place_call_info: String,
has_video: bool,
) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
let msg_id = ctx
.place_outgoing_call(ChatId::new(chat_id), place_call_info, has_video)
.place_outgoing_call(ChatId::new(chat_id), place_call_info)
.await?;
Ok(msg_id.to_u32())
}
@@ -2385,7 +2243,6 @@ impl CommandApi {
chat::resend_msgs(&ctx, &message_ids).await
}
/// @deprecated as of 2026-04; use `send_msg` with `Viewtype::Sticker` instead.
async fn send_sticker(
&self,
account_id: u32,
@@ -2397,16 +2254,19 @@ impl CommandApi {
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(&ctx, Path::new(&sticker_path), None, None)?;
// JSON-rpc does not need heuristics to turn [Viewtype::Sticker] into [Viewtype::Image]
msg.force_sticker();
let message_id = deltachat::chat::send_msg(&ctx, ChatId::new(chat_id), &mut msg).await?;
Ok(message_id.to_u32())
}
/// Sends a reaction to message.
/// Send a reaction to message.
///
/// A reaction is a string that represents an emoji.
/// You can call this function again to change the emoji;
/// the last sent reaction overrides all previously sent reactions.
/// It is possible to remove the reaction by sending an empty string.
/// Reaction is a string of emojis separated by spaces. Reaction to a
/// single message can be sent multiple times. The last reaction
/// received overrides all previously received reactions. It is
/// possible to remove all reactions by sending an empty string.
async fn send_reaction(
&self,
account_id: u32,
@@ -2419,7 +2279,6 @@ impl CommandApi {
}
/// Returns reactions to the message.
/// `None` when there are no reactions.
async fn get_message_reactions(
&self,
account_id: u32,
@@ -2580,10 +2439,7 @@ impl CommandApi {
continue;
}
let sticker_name = sticker_entry.file_name().into_string().unwrap_or_default();
if sticker_name.ends_with(".png")
|| sticker_name.ends_with(".webp")
|| sticker_name.ends_with(".gif")
{
if sticker_name.ends_with(".png") || sticker_name.ends_with(".webp") {
sticker_paths.push(
sticker_entry
.path()
@@ -2751,48 +2607,6 @@ impl CommandApi {
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)

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
use std::time::{Duration, SystemTime};
use anyhow::{Context as _, Result, bail};
use deltachat::chat::{self, ChatVisibility, get_chat_contacts, get_past_chat_contacts};
use anyhow::{bail, Context as _, Result};
use deltachat::chat::{self, get_chat_contacts, get_past_chat_contacts, ChatVisibility};
use deltachat::chat::{Chat, ChatId};
use deltachat::constants::Chattype;
use deltachat::contact::{Contact, ContactId};

View File

@@ -4,7 +4,7 @@ use deltachat::chatlist::get_last_message_for_chat;
use deltachat::constants::*;
use deltachat::contact::Contact;
use deltachat::{
chat::{ChatVisibility, get_chat_contacts},
chat::{get_chat_contacts, ChatVisibility},
chatlist::Chatlist,
};
use num_traits::cast::ToPrimitive;

View File

@@ -203,17 +203,6 @@ pub enum EventType {
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.
///
/// This event means that the message will no longer appear in the messagelist.
@@ -394,15 +383,11 @@ pub enum EventType {
msg_id: u32,
},
/// Tells that a background fetch call is done:
/// the fetch completed, timed out, was stopped or was not started.
/// Tells that the Background fetch was completed (or timed out).
/// 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:
/// 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.
/// This event is only emitted by the account manager
AccountsBackgroundFetchDone,
/// Inform that set of chats or the order of the chats in the chatlist has changed.
///
@@ -456,8 +441,6 @@ pub enum EventType {
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
/// The call was accepted from this device (process).
from_this_device: bool,
},
/// Outgoing call accepted.
@@ -482,9 +465,9 @@ pub enum EventType {
///
/// UI should update the list.
///
/// The event is emitted on the device modifying
/// the transports as well as on other devices
/// applying the synced change.
/// This event is emitted when transport
/// synchronization messages arrives,
/// but not when the UI modifies the transport list by itself.
TransportsModified,
}
@@ -561,10 +544,6 @@ impl From<CoreEventType> for EventType {
chat_id: chat_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 {
chat_id: chat_id.to_u32(),
msg_id: msg_id.to_u32(),
@@ -655,14 +634,9 @@ impl From<CoreEventType> for EventType {
place_call_info,
has_video,
},
CoreEventType::IncomingCallAccepted {
msg_id,
chat_id,
from_this_device,
} => IncomingCallAccepted {
CoreEventType::IncomingCallAccepted { msg_id, chat_id } => IncomingCallAccepted {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
from_this_device,
},
CoreEventType::OutgoingCallAccepted {
msg_id,

View File

@@ -16,7 +16,7 @@ pub struct HttpResponse {
impl From<CoreHttpResponse> for HttpResponse {
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 mimetype = response.mimetype;
let encoding = response.encoding;

View File

@@ -23,12 +23,6 @@ pub struct EnteredLoginParam {
/// Imap server port.
pub imap_port: Option<u16>,
/// IMAP server folder.
///
/// Defaults to "INBOX" if not set.
/// Should not be an empty string.
pub imap_folder: Option<String>,
/// Imap socket security.
pub imap_security: Option<Socket>,
@@ -56,6 +50,10 @@ pub struct EnteredLoginParam {
/// invalid hostnames.
/// Default: Automatic
pub certificate_checks: Option<EnteredCertificateChecks>,
/// If true, login via OAUTH2 (not recommended anymore).
/// Default: false
pub oauth2: Option<bool>,
}
impl From<dc::EnteredLoginParam> for EnteredLoginParam {
@@ -68,7 +66,6 @@ impl From<dc::EnteredLoginParam> for EnteredLoginParam {
password: param.imap.password,
imap_server: param.imap.server.into_option(),
imap_port: param.imap.port.into_option(),
imap_folder: param.imap.folder.into_option(),
imap_security: imap_security.into_option(),
imap_user: param.imap.user.into_option(),
smtp_server: param.smtp.server.into_option(),
@@ -77,6 +74,7 @@ impl From<dc::EnteredLoginParam> for EnteredLoginParam {
smtp_user: param.smtp.user.into_option(),
smtp_password: param.smtp.password.into_option(),
certificate_checks: certificate_checks.into_option(),
oauth2: param.oauth2.into_option(),
}
}
}
@@ -87,15 +85,14 @@ impl TryFrom<EnteredLoginParam> for dc::EnteredLoginParam {
fn try_from(param: EnteredLoginParam) -> Result<Self> {
Ok(Self {
addr: param.addr,
imap: dc::EnteredImapLoginParam {
imap: dc::EnteredServerLoginParam {
server: param.imap_server.unwrap_or_default(),
port: param.imap_port.unwrap_or_default(),
folder: param.imap_folder.unwrap_or_default(),
security: param.imap_security.unwrap_or_default().into(),
user: param.imap_user.unwrap_or_default(),
password: param.password,
},
smtp: dc::EnteredSmtpLoginParam {
smtp: dc::EnteredServerLoginParam {
server: param.smtp_server.unwrap_or_default(),
port: param.smtp_port.unwrap_or_default(),
security: param.smtp_security.unwrap_or_default().into(),
@@ -103,7 +100,7 @@ impl TryFrom<EnteredLoginParam> for dc::EnteredLoginParam {
password: param.smtp_password.unwrap_or_default(),
},
certificate_checks: param.certificate_checks.unwrap_or_default().into(),
oauth2: false,
oauth2: param.oauth2.unwrap_or_default(),
})
}
}
@@ -150,8 +147,9 @@ impl From<Socket> for dc::Socket {
#[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum EnteredCertificateChecks {
/// `Automatic` means strict certificate checks,
/// unless a legacy-domain override disables them.
/// `Automatic` means that provider database setting should be taken.
/// If there is no provider database setting for certificate checks,
/// check certificates strictly.
#[default]
Automatic,

View File

@@ -68,6 +68,7 @@ pub struct MessageObject {
/// if `show_padlock` is `false`,
/// and nothing if it is `true`.
show_padlock: bool,
is_setupmessage: bool,
is_info: bool,
is_forwarded: bool,
@@ -87,6 +88,8 @@ pub struct MessageObject {
override_sender_name: Option<String>,
sender: ContactObject,
setup_code_begin: Option<String>,
file: Option<String>,
file_mime: Option<String>,
@@ -103,9 +106,6 @@ pub struct MessageObject {
saved_message_id: Option<u32>,
is_pinned: bool,
/// `None` when there are no reactions.
reactions: Option<JsonrpcReactions>,
vcard_contact: Option<VcardContact>,
@@ -226,6 +226,7 @@ impl MessageObject {
subject: message.get_subject().to_owned(),
show_padlock: message.get_showpadlock(),
is_setupmessage: message.is_setupmessage(),
is_info: message.is_info(),
is_forwarded: message.is_forwarded(),
is_bot: message.is_bot(),
@@ -242,6 +243,8 @@ impl MessageObject {
override_sender_name,
sender,
setup_code_begin: message.get_setupcodebegin(context).await,
file: match message.get_file(context) {
Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()),
None => None,
@@ -266,7 +269,6 @@ impl MessageObject {
.await?
.map(|id| id.to_u32()),
is_pinned: message.is_pinned(),
reactions,
vcard_contact: vcard_contacts.first().cloned(),
@@ -291,6 +293,8 @@ pub enum MessageViewtype {
Gif,
/// Message containing a sticker, similar to image.
/// NB: When sending, the message viewtype may be changed to `Image` by some heuristics like
/// checking for transparent pixels. Use `Message::force_sticker()` to disable them.
///
/// If possible, the ui should display the image without borders in a transparent way.
/// A click on a sticker will offer to install the sticker set in some future.
@@ -384,7 +388,6 @@ impl From<download::DownloadState> for DownloadState {
pub enum SystemMessageType {
Unknown,
GroupNameChanged,
GroupDescriptionChanged,
GroupImageChanged,
MemberAddedToGroup,
MemberRemovedFromGroup,
@@ -394,11 +397,11 @@ pub enum SystemMessageType {
LocationOnly,
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.
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.
SecurejoinWaitTimeout,
@@ -429,8 +432,6 @@ pub enum SystemMessageType {
CallAccepted,
CallEnded,
MessagePinned,
MessageUnpinned,
}
impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
@@ -439,7 +440,6 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
match system_message_type {
SystemMessage::Unknown => SystemMessageType::Unknown,
SystemMessage::GroupNameChanged => SystemMessageType::GroupNameChanged,
SystemMessage::GroupDescriptionChanged => SystemMessageType::GroupDescriptionChanged,
SystemMessage::GroupImageChanged => SystemMessageType::GroupImageChanged,
SystemMessage::MemberAddedToGroup => SystemMessageType::MemberAddedToGroup,
SystemMessage::MemberRemovedFromGroup => SystemMessageType::MemberRemovedFromGroup,
@@ -460,8 +460,6 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout,
SystemMessage::CallAccepted => SystemMessageType::CallAccepted,
SystemMessage::CallEnded => SystemMessageType::CallEnded,
SystemMessage::MessagePinned => SystemMessageType::MessagePinned,
SystemMessage::MessageUnpinned => SystemMessageType::MessageUnpinned,
}
}
}
@@ -734,9 +732,9 @@ impl From<deltachat::ephemeral::Timer> for EphemeralTimer {
fn from(value: deltachat::ephemeral::Timer) -> Self {
match value {
deltachat::ephemeral::Timer::Disabled => EphemeralTimer::Disabled,
deltachat::ephemeral::Timer::Enabled { duration } => EphemeralTimer::Enabled {
duration: duration.get(),
},
deltachat::ephemeral::Timer::Enabled { duration } => {
EphemeralTimer::Enabled { duration }
}
}
}
}

View File

@@ -1,5 +1,4 @@
pub mod account;
pub mod appversions;
pub mod calls;
pub mod chat;
pub mod chat_list;
@@ -9,6 +8,8 @@ pub mod http;
pub mod location;
pub mod login_param;
pub mod message;
pub mod notify_state;
pub mod provider_info;
pub mod qr;
pub mod reactions;
pub mod webxdc;

View File

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

View File

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

View File

@@ -19,8 +19,6 @@ pub enum QrObject {
invitenumber: String,
/// Authentication code.
authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Ask the user whether to join the group.
AskVerifyGroup {
@@ -36,8 +34,6 @@ pub enum QrObject {
invitenumber: String,
/// Authentication code.
authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Ask the user whether to join the broadcast channel.
AskJoinBroadcast {
@@ -58,8 +54,6 @@ pub enum QrObject {
invitenumber: String,
/// Authentication code.
authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Contact fingerprint is verified.
///
@@ -235,17 +229,14 @@ impl From<Qr> for QrObject {
fingerprint,
invitenumber,
authcode,
is_v3,
..
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::AskVerifyContact {
contact_id,
fingerprint,
invitenumber,
authcode,
is_v3,
}
}
Qr::AskVerifyGroup {
@@ -255,11 +246,9 @@ impl From<Qr> for QrObject {
fingerprint,
invitenumber,
authcode,
is_v3,
..
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::AskVerifyGroup {
grpname,
grpid,
@@ -267,7 +256,6 @@ impl From<Qr> for QrObject {
fingerprint,
invitenumber,
authcode,
is_v3,
}
}
Qr::AskJoinBroadcast {
@@ -277,11 +265,9 @@ impl From<Qr> for QrObject {
fingerprint,
authcode,
invitenumber,
is_v3,
..
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::AskJoinBroadcast {
name,
grpid,
@@ -289,7 +275,6 @@ impl From<Qr> for QrObject {
fingerprint,
authcode,
invitenumber,
is_v3,
}
}
Qr::FprOk { contact_id } => {
@@ -324,7 +309,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::WithdrawVerifyContact {
contact_id,
fingerprint,
@@ -341,7 +326,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::WithdrawVerifyGroup {
grpname,
grpid,
@@ -360,7 +345,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::WithdrawJoinBroadcast {
name,
grpid,
@@ -377,7 +362,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::ReviveVerifyContact {
contact_id,
fingerprint,
@@ -394,7 +379,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::ReviveVerifyGroup {
grpname,
grpid,
@@ -413,7 +398,7 @@ impl From<Qr> for QrObject {
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
let fingerprint = fingerprint.to_string();
QrObject::ReviveJoinBroadcast {
name,
grpid,

View File

@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use deltachat::contact::ContactId;
use deltachat::reaction::Reactions;
use serde::Serialize;
use typescript_type_def::TypeDef;
@@ -23,11 +24,6 @@ pub struct JsonrpcReaction {
#[serde(rename = "Reactions", rename_all = "camelCase")]
pub struct JsonrpcReactions {
/// Map from a contact to it's reaction to message.
///
/// There is only a single reaction per contact,
/// 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>>,
/// Unique reactions and their count, sorted in descending order.
reactions: Vec<JsonrpcReaction>,
@@ -35,25 +31,42 @@ pub struct JsonrpcReactions {
impl From<Reactions> for JsonrpcReactions {
fn from(reactions: Reactions) -> Self {
let reactions_by_contact: BTreeMap<u32, Vec<String>> = reactions
.by_contact
.iter()
.map(|(key, value)| (key.to_u32(), vec![value.as_str().to_string()]))
.collect();
let mut reactions_by_contact: BTreeMap<u32, Vec<String>> = BTreeMap::new();
let reactions = reactions
.frequencies
.into_iter()
.map(|entry| JsonrpcReaction {
emoji: entry.reaction.as_str().to_string(),
count: entry.count,
is_from_self: entry.is_from_self,
})
.collect();
for contact_id in reactions.contacts() {
let reaction = reactions.get(contact_id);
if reaction.is_empty() {
continue;
}
let emojis: Vec<String> = reaction
.emojis()
.into_iter()
.map(|emoji| emoji.to_owned())
.collect();
reactions_by_contact.insert(contact_id.to_u32(), emojis.clone());
}
let self_reactions = reactions_by_contact.get(&ContactId::SELF.to_u32());
let mut reactions_v = Vec::new();
for (emoji, count) in reactions.emoji_sorted_by_frequency() {
let is_from_self = if let Some(self_reactions) = self_reactions {
self_reactions.contains(&emoji)
} else {
false
};
let reaction = JsonrpcReaction {
emoji,
count,
is_from_self,
};
reactions_v.push(reaction)
}
JsonrpcReactions {
reactions_by_contact,
reactions,
reactions: reactions_v,
}
}
}

View File

@@ -37,10 +37,6 @@ pub struct WebxdcMessageInfo {
internet_access: bool,
/// Address to be used for `window.webxdc.selfAddr` in JS land.
self_addr: String,
/// Define if the local user is the one who initially shared the webxdc application in the chat.
is_app_sender: bool,
/// Define if the app runs in a broadcasting context.
is_broadcast: bool,
/// Milliseconds to wait before calling `sendUpdate()` again since the last call.
/// Should be exposed to `window.sendUpdateInterval` in JS land.
send_update_interval: usize,
@@ -64,8 +60,6 @@ impl WebxdcMessageInfo {
request_integration: _,
internet_access,
self_addr,
is_app_sender,
is_broadcast,
send_update_interval,
send_update_max_size,
} = message.get_webxdc_info(context).await?;
@@ -78,8 +72,6 @@ impl WebxdcMessageInfo {
source_code_url: maybe_empty_string_to_option(source_code_url),
internet_access,
self_addr,
is_app_sender,
is_broadcast,
send_update_interval,
send_update_max_size,
})

View File

@@ -85,7 +85,7 @@ mod tests {
assert_eq!(result, response.to_owned());
}
{
let request = r#"{"jsonrpc":"2.0","method":"batch_set_config","id":2,"params":[1,{"addr":"","mail_user":"","mail_pw":"","mail_server":"","mail_port":"","mail_security":"","imap_certificate_checks":"","send_user":"","send_pw":"","send_server":"","send_port":"","send_security":""}]}"#;
let request = r#"{"jsonrpc":"2.0","method":"batch_set_config","id":2,"params":[1,{"addr":"","mail_user":"","mail_pw":"","mail_server":"","mail_port":"","mail_security":"","imap_certificate_checks":"","send_user":"","send_pw":"","send_server":"","send_port":"","send_security":"","smtp_certificate_checks":""}]}"#;
let response = r#"{"jsonrpc":"2.0","id":2,"result":null}"#;
session.handle_incoming(request).await;
let result = receiver.recv().await?;

View File

@@ -0,0 +1,206 @@
# @deltachat/jsonrpc-client
This package is a client for the jsonrpc server.
> If you are looking for the functions in the documentation, they are under [`RawClient`](https://js.jsonrpc.delta.chat/classes/RawClient.html).
### Important Terms
- [chat mail core (formerly delta chat core)](https://github.com/deltachat/deltachat-core-rust/) is heart of all Delta Chat clients. Handels all the heavy lifting (email, encryption, ...) and provides an easy api for bots and clients (`getChatlist`, `getChat`, `getContact`, ...).
- [jsonrpc](https://www.jsonrpc.org/specification) is a JSON based protocol
for applications to speak to each other by [remote procedure calls](https://en.wikipedia.org/wiki/Remote_procedure_call) (short RPC),
which basically means that the client can call methods on the server by sending a JSON messages.
- [`deltachat-rpc-server`](https://github.com/deltachat/deltachat-core-rust/tree/main/deltachat-rpc-server) provides the jsonrpc api over stdio (stdin/stdout)
- [`@deltachat/stdio-rpc-server`](https://www.npmjs.com/package/@deltachat/stdio-rpc-server) is an easy way to install `deltachat-rpc-server` from npm and use it from nodejs.
#### Transport
You need to connect this client to an instance of chatmail-core via a transport.
For this you can use the `StdioTransport`, which you can use by importing `StdioDeltaChat`.
You can also make your own transport, for example deltachat desktop uses a custom transport that sends the json messages over electron ipc.
Just implement your transport based on the `Transport` interface - look at how the [stdio transport is implemented](https://github.com/deltachat/deltachat-core-rust/blob/7121675d226e69fd85d0194d4b9c4442e4dd8299/deltachat-jsonrpc/typescript/src/client.ts#L113) for an example, it's not hard.
The other transports that exist (but note that those are not standalone, they list here only serves as reference on how to implement those.):
- Electron IPC (from Delta Chat Desktop)
- [transport](https://github.com/deltachat/deltachat-desktop/blob/0a4fdb2065c7b14fa097769181afd05e3f552f54/packages/target-electron/runtime-electron/runtime.ts#L49-L123), [request handling](https://github.com/deltachat/deltachat-desktop/blob/0a4fdb2065c7b14fa097769181afd05e3f552f54/packages/target-electron/src/deltachat/controller.ts#L199-L201), [responses](https://github.com/deltachat/deltachat-desktop/blob/0a4fdb2065c7b14fa097769181afd05e3f552f54/packages/target-electron/src/deltachat/controller.ts#L93-L113)
- Tauri IPC (from DC Desktop Tauri edition)
- [transport](https://github.com/deltachat/deltachat-desktop/blob/0a4fdb2065c7b14fa097769181afd05e3f552f54/packages/target-tauri/runtime-tauri/runtime.ts#L86-L118), backend: [request handling](https://github.com/deltachat/deltachat-desktop/blob/0a4fdb2065c7b14fa097769181afd05e3f552f54/packages/target-tauri/src-tauri/src/lib.rs#L85-L94), [responses](https://github.com/deltachat/deltachat-desktop/blob/0a4fdb2065c7b14fa097769181afd05e3f552f54/packages/target-tauri/src-tauri/src/state/deltachat.rs#L43-L95)
- Authenticated Websocket (from DC Desktop Browser edition)
- [transport](https://github.com/deltachat/deltachat-desktop/blob/0a4fdb2065c7b14fa097769181afd05e3f552f54/packages/target-browser/runtime-browser/runtime.ts#L37-L80), backend: [authentication](https://github.com/deltachat/deltachat-desktop/blob/0a4fdb2065c7b14fa097769181afd05e3f552f54/packages/target-browser/src/index.ts#L258-L275), [web socket server](https://github.com/deltachat/deltachat-desktop/blob/0a4fdb2065c7b14fa097769181afd05e3f552f54/packages/target-browser/src/deltachat-rpc.ts#L93) (this also contains some unrelated code, it's easier than it looks at first glance)
## Usage
> The **minimum** nodejs version for `@deltachat/stdio-rpc-server` is `16`
```
npm i @deltachat/stdio-rpc-server @deltachat/jsonrpc-client
```
```js
import { startDeltaChat } from "@deltachat/stdio-rpc-server";
// Import constants you might need later
import { C } from "@deltachat/jsonrpc-client";
async function main() {
const dc = await startDeltaChat("deltachat-data");
console.log(await dc.rpc.getSystemInfo());
dc.close();
}
main();
```
For a more complete example refer to <https://github.com/deltachat-bot/echo/tree/master/nodejs_stdio_jsonrpc>.
### Listening for events
```ts
dc.on("Info", (accountId, { msg }) =>
console.info(accountId, "[core:info]", msg),
);
// Or get an event emitter for only one account
const emitter = dc.getContextEvents(accountId);
emitter.on("IncomingMsg", async ({ chatId, msgId }) => {
const message = await dc.rpc.getMessage(accountId, msgId);
console.log("got message in chat " + chatId + " : ", message.text);
});
```
### Getting Started
This section describes how to handle the Delta Chat core library over the jsonrpc bindings.
For general information about Delta Chat itself,
see <https://delta.chat> and <https://github.com/deltachat>.
Let's start.
First of all, you have to start the deltachat-rpc-server process.
```js
import { startDeltaChat } from "@deltachat/stdio-rpc-server";
const dc = await startDeltaChat("deltachat-data");
```
Then we have to create an Account (also called Context or profile) that is bound to a database.
The database is a normal SQLite file with a "blob directory" beside it.
But these details are handled by deltachat's account manager.
So you just have to tell the account manager to create a new account:
```js
const accountId = await dc.rpc.addAccount();
```
After that, register event listeners so you can see what core is doing:
Intenally `@deltachat/jsonrpc-client` implments a loop that waits for new events and then emits them to javascript land.
```js
dc.on("Info", (accountId, { msg }) =>
console.info(accountId, "[core:info]", msg),
);
```
Now you can **configure the account:**
```js
// use some real test credentials here
await dc.rpc.setConfig(accountId, "addr", "alice@example.org");
await dc.rpc.setConfig(accountId, "mail_pw", "***");
// you can also set multiple config options in one call
await dc.rpc.batchSetConfig(accountId, {
addr: "alice@example.org",
mail_pw: "***",
});
// after setting the credentials attempt to login
await dc.rpc.configure(accountId);
```
`configure()` returns a promise that is rejected on error (with await is is thrown).
The configuration itself may take a while. You can monitor it's progress like this:
```js
dc.on("ConfigureProgress", (accountId, { progress, comment }) => {
console.log(accountId, "ConfigureProgress", progress, comment);
});
// make sure to register this event handler before calling `dc.rpc.configure()`
```
The configuration result is saved in the database.
On subsequent starts it is not needed to call `dc.rpc.configure(accountId)`
(you can check this using `dc.rpc.isConfigured(accountId)`).
On a successfully configuration delta chat core automatically connects to the server, however subsequent starts you **need to do that manually** by calling `dc.rpc.startIo(accountId)` or `dc.rpc.startIoForAllAccounts()`.
```js
if (!(await dc.rpc.isConfigured(accountId))) {
// use some real test credentials here
await dc.rpc.batchSetConfig(accountId, {
addr: "alice@example.org",
mail_pw: "***",
});
await dc.rpc.configure(accountId);
} else {
await dc.rpc.startIo(accountId);
}
```
Now you can **send the first message:**
```js
const contactId = await dc.rpc.createContact(
accountId,
"bob@example.org",
null /* optional name */,
);
const chatId = await dc.rpc.createChatByContactId(accountId, contactId);
await dc.rpc.miscSendTextMessage(
accountId,
chatId,
"Hi, here is my first message!",
);
```
`dc.rpc.miscSendTextMessage()` returns immediately;
the sending itself is done in the background.
If you check the testing address (bob),
you should receive a normal e-mail.
Answer this e-mail in any e-mail program with "Got it!",
and the IO you started above will **receive the message**.
You can then **list all messages** of a chat as follows:
```js
let i = 0;
for (const msgId of await exp.rpc.getMessageIds(120, 12, false, false)) {
i++;
console.log(`Message: ${i}`, (await dc.rpc.getMessage(120, msgId)).text);
}
```
This will output the following two lines:
```
Message 1: Hi, here is my first message!
Message 2: Got it!
```
<!-- TODO: ### Clean shutdown? - seems to be more advanced to call async functions on exit, also is this needed in this usecase? -->
## Further information
- `@deltachat/stdio-rpc-server`
- [package on npm](https://www.npmjs.com/package/@deltachat/stdio-rpc-server)
- [source code on github](https://github.com/deltachat/deltachat-core-rust/tree/main/deltachat-rpc-server/npm-package)
- [use `@deltachat/stdio-rpc-server` on an usuported platform](https://github.com/deltachat/deltachat-core-rust/tree/main/deltachat-rpc-server/npm-package#how-to-use-on-an-unsupported-platform)
- The issue-tracker for the core library is here: <https://github.com/deltachat/deltachat-core-rust/issues>
If you need further assistance,
please do not hesitate to contact us
through the channels shown at https://delta.chat/en/contribute
Please keep in mind, that your derived work
must respect the Mozilla Public License 2.0 of deltachat-rpc-server
and the respective licenses of the libraries deltachat-rpc-server links with.

View File

@@ -54,5 +54,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.61.0-dev"
"version": "2.37.0"
}

View File

@@ -44,6 +44,7 @@ const constants = data
key.startsWith("DC_CERTCK_") ||
key.startsWith("DC_SOCKET_") ||
key.startsWith("DC_LP_AUTH_") ||
key.startsWith("DC_PUSH_") ||
key.startsWith("DC_TEXT1_") ||
key.startsWith("DC_CHAT_TYPE")
);

View File

@@ -5,14 +5,14 @@ import { RawClient } from "../generated/client.js";
import { BaseTransport, Request } from "yerpc";
import { TinyEmitter } from "@deltachat/tiny-emitter";
type Events = { ALL: (accountId: number, event: EventType) => void } & {
export type Events = { ALL: (accountId: number, event: EventType) => void } & {
[Property in EventType["kind"]]: (
accountId: number,
event: Extract<EventType, { kind: Property }>,
) => void;
};
type ContextEvents = { ALL: (event: EventType) => void } & {
export type ContextEvents = { ALL: (event: EventType) => void } & {
[Property in EventType["kind"]]: (
event: Extract<EventType, { kind: Property }>,
) => void;
@@ -53,19 +53,18 @@ export class BaseDeltaChat<
*/
async eventLoop(): Promise<void> {
while (true) {
for (const event of await this.rpc.getNextEventBatch()) {
//@ts-ignore
this.emit(event.event.kind, event.contextId, event.event);
this.emit("ALL", event.contextId, event.event);
const event = await this.rpc.getNextEvent();
//@ts-ignore
this.emit(event.event.kind, event.contextId, event.event);
this.emit("ALL", event.contextId, event.event);
if (this.contextEmitters[event.contextId]) {
this.contextEmitters[event.contextId].emit(
event.event.kind,
//@ts-ignore
event.event as any,
);
this.contextEmitters[event.contextId].emit("ALL", event.event as any);
}
if (this.contextEmitters[event.contextId]) {
this.contextEmitters[event.contextId].emit(
event.event.kind,
//@ts-ignore
event.event as any,
);
this.contextEmitters[event.contextId].emit("ALL", event.event as any);
}
}
}

View File

@@ -148,6 +148,23 @@ describe("online tests", function () {
expect(message2.text).equal("super secret message");
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"]>(

View File

@@ -2,7 +2,7 @@
name = "ratelimit"
version = "1.0.0"
description = "Token bucket implementation"
edition = "2024"
edition = "2021"
license = "MPL-2.0"
[dependencies]

View File

@@ -1,8 +1,8 @@
[package]
name = "deltachat-repl"
version = "2.61.0-dev"
version = "2.37.0"
license = "MPL-2.0"
edition = "2024"
edition = "2021"
repository = "https://github.com/chatmail/core"
[dependencies]

View File

@@ -5,10 +5,9 @@ use std::path::Path;
use std::str::FromStr;
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::chatlist::*;
use deltachat::config;
use deltachat::constants::*;
use deltachat::contact::*;
use deltachat::context::*;
@@ -25,6 +24,7 @@ use deltachat::reaction::send_reaction;
use deltachat::receive_imf::*;
use deltachat::sql;
use deltachat::tools::*;
use deltachat::{config, provider};
use tokio::fs;
/// Reset database tables.
@@ -122,7 +122,7 @@ async fn poke_spec(context: &Context, spec: Option<&str>) -> bool {
let name_f = entry.file_name();
let name = name_f.to_string_lossy();
if name.ends_with(".eml") {
let path_plus_name = format!("{real_spec}/{name}");
let path_plus_name = format!("{}/{}", &real_spec, name);
println!("Import: {path_plus_name}");
if poke_eml_file(context, Path::new(&path_plus_name))
.await
@@ -133,11 +133,11 @@ async fn poke_spec(context: &Context, spec: Option<&str>) -> bool {
}
}
} else {
eprintln!("Import: Cannot open directory {real_spec:?}.");
eprintln!("Import: Cannot open directory \"{}\".", &real_spec);
return false;
}
}
println!("Import: {read_cnt} items read from {real_spec:?}.");
println!("Import: {} items read from \"{}\".", read_cnt, &real_spec);
if read_cnt > 0 {
context.emit_msgs_changed_without_ids();
}
@@ -179,7 +179,7 @@ async fn log_msg(context: &Context, prefix: impl AsRef<str>, msg: &Message) {
msg.get_id(),
if msg.get_showpadlock() { "🔒" } else { "" },
if msg.has_location() { "📍" } else { "" },
contact_name,
&contact_name,
contact_id,
msgtext,
if msg.has_html() { "[HAS-HTML]" } else { "" },
@@ -221,14 +221,14 @@ async fn log_msg(context: &Context, prefix: impl AsRef<str>, msg: &Message) {
},
statestr,
downloadstate,
temp2,
&temp2,
);
}
async fn log_msglist(context: &Context, msglist: &[MsgId]) -> Result<()> {
let mut lines_out = 0;
for &msg_id in msglist {
if msg_id == MsgId::DAYMARKER {
if msg_id == MsgId::new(DC_MSG_ID_DAYMARKER) {
println!(
"--------------------------------------------------------------------------------"
);
@@ -302,6 +302,9 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
// TODO: reuse commands definition in main.rs.
"imex" => println!(
"====================Import/Export commands==\n\
initiate-key-transfer\n\
get-setupcodebegin <msg-id>\n\
continue-key-transfer <msg-id> <setup-code>\n\
has-backup\n\
export-backup\n\
import-backup <backup-file>\n\
@@ -319,6 +322,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
info\n\
set <configuration-key> [<value>]\n\
get <configuration-key>\n\
oauth2\n\
configure\n\
connect\n\
disconnect\n\
@@ -339,11 +343,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
addmember <contact-id>\n\
removemember <contact-id>\n\
groupname <name>\n\
groupdescription <description>\n\
groupimage <image>\n\
chatinfo\n\
sendlocations <seconds>\n\
setlocation <lat> <lng>\n\
dellocations\n\
getlocations [<contact-id>]\n\
send <text>\n\
send-sync <text>\n\
@@ -395,6 +399,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
joinqr <qr-content>\n\
setqr <qr-content>\n\
createqrsvg <qr-content>\n\
providerinfo <addr>\n\
fileinfo <file>\n\
estimatedeletion <seconds>\n\
clear -- clear screen\n\
@@ -402,6 +407,34 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
============================================="
),
},
"initiate-key-transfer" => match initiate_key_transfer(&context).await {
Ok(setup_code) => {
println!("Setup code for the transferred setup message: {setup_code}",)
}
Err(err) => bail!("Failed to generate setup code: {err}"),
},
"get-setupcodebegin" => {
ensure!(!arg1.is_empty(), "Argument <msg-id> missing.");
let msg_id: MsgId = MsgId::new(arg1.parse()?);
let msg = Message::load_from_db(&context, msg_id).await?;
if msg.is_setupmessage() {
let setupcodebegin = msg.get_setupcodebegin(&context).await;
println!(
"The setup code for setup message {} starts with: {}",
msg_id,
setupcodebegin.unwrap_or_default(),
);
} else {
bail!("{msg_id} is no setup message.",);
}
}
"continue-key-transfer" => {
ensure!(
!arg1.is_empty() && !arg2.is_empty(),
"Arguments <msg-id> <setup-code> expected"
);
continue_key_transfer(&context, MsgId::new(arg1.parse()?), arg2).await?;
}
"has-backup" => {
has_backup(&context, blobdir).await?;
}
@@ -559,7 +592,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
.map_or_else(String::new, |prefix| format!("{prefix}: ")),
summary.text,
statestr,
timestr,
&timestr,
if chat.is_sending_locations() {
"📍"
} else {
@@ -571,7 +604,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
);
}
}
if location::is_sending(&context).await? {
if location::is_sending_locations_to_chat(&context, None).await? {
println!("Location streaming enabled.");
}
println!("{cnt} chats");
@@ -616,10 +649,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
let sel_chat = sel_chat.as_ref().unwrap();
let time_start = std::time::SystemTime::now();
let msglist = chat::get_chat_msgs_ext(
let msglist = chat::get_chat_msgs_ex(
&context,
sel_chat.get_id(),
chat::MessageListOptions {
info_only: false,
add_daymarker: true,
},
)
@@ -630,7 +664,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
.into_iter()
.map(|x| match x {
ChatItem::Message { msg_id } => msg_id,
ChatItem::DayMarker { .. } => MsgId::DAYMARKER,
ChatItem::DayMarker { .. } => MsgId::new(DC_MSG_ID_DAYMARKER),
})
.collect();
@@ -736,13 +770,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Chat name set");
}
"groupdescription" => {
ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "Argument <description> missing.");
chat::set_chat_description(&context, sel_chat.as_ref().unwrap().get_id(), arg1).await?;
println!("Chat description set");
}
"groupimage" => {
ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "Argument <image> missing.");
@@ -778,7 +805,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!(
"Location streaming: {}",
location::is_sending_to_chat(&context, sel_chat.as_ref().unwrap().get_id()).await?,
location::is_sending_locations_to_chat(
&context,
Some(sel_chat.as_ref().unwrap().get_id())
)
.await?,
);
}
"getlocations" => {
@@ -818,7 +849,12 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
ensure!(!arg1.is_empty(), "No timeout given.");
let seconds = arg1.parse()?;
location::send_to_chat(&context, sel_chat.as_ref().unwrap().get_id(), seconds).await?;
location::send_locations_to_chat(
&context,
sel_chat.as_ref().unwrap().get_id(),
seconds,
)
.await?;
println!(
"Locations will be sent to Chat#{} for {} seconds. Use 'setlocation <lat> <lng>' to play around.",
sel_chat.as_ref().unwrap().get_id(),
@@ -840,6 +876,9 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Success, streaming can be stopped.");
}
}
"dellocations" => {
location::delete_all(&context).await?;
}
"send" => {
ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "No message text given.");
@@ -1203,21 +1242,42 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
fs::write(&file, svg).await?;
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" => {
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.");
};
let (width, height) = get_filemeta(&buf)?;
println!("width={width}, height={height}");
}
}
"estimatedeletion" => {
ensure!(!arg1.is_empty(), "Argument <seconds> missing");
let seconds = arg1.parse()?;
let from_server = false;
let device_cnt = message::estimate_deletion_cnt(&context, from_server, seconds).await?;
println!("estimated count of messages older than {seconds} seconds: {device_cnt}");
let device_cnt = message::estimate_deletion_cnt(&context, false, seconds).await?;
let server_cnt = message::estimate_deletion_cnt(&context, true, seconds).await?;
println!(
"estimated count of messages older than {seconds} seconds:\non device: {device_cnt}\non server: {server_cnt}"
);
}
"" => (),
_ => bail!("Unknown command: \"{arg0}\" type ? for help."),

View File

@@ -9,12 +9,14 @@ extern crate deltachat;
use std::borrow::Cow::{self, Borrowed, Owned};
use anyhow::{Error, bail};
use deltachat::EventType;
use anyhow::{bail, Error};
use deltachat::chat::ChatId;
use deltachat::config;
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 nu_ansi_term::Color;
use rustyline::completion::{Completer, FilenameCompleter, Pair};
@@ -147,7 +149,10 @@ impl Completer for DcHelper {
}
}
const IMEX_COMMANDS: [&str; 10] = [
const IMEX_COMMANDS: [&str; 13] = [
"initiate-key-transfer",
"get-setupcodebegin",
"continue-key-transfer",
"has-backup",
"export-backup",
"import-backup",
@@ -160,10 +165,11 @@ const IMEX_COMMANDS: [&str; 10] = [
"stop",
];
const DB_COMMANDS: [&str; 10] = [
const DB_COMMANDS: [&str; 11] = [
"info",
"set",
"get",
"oauth2",
"configure",
"connect",
"disconnect",
@@ -186,11 +192,11 @@ const CHAT_COMMANDS: [&str; 39] = [
"addmember",
"removemember",
"groupname",
"groupdescription",
"groupimage",
"chatinfo",
"sendlocations",
"setlocation",
"dellocations",
"getlocations",
"send",
"send-sync",
@@ -237,7 +243,7 @@ const CONTACT_COMMANDS: [&str; 9] = [
"import-vcard",
"make-vcard",
];
const MISC_COMMANDS: [&str; 13] = [
const MISC_COMMANDS: [&str; 14] = [
"getqr",
"getqrsvg",
"getbadqr",
@@ -245,6 +251,7 @@ const MISC_COMMANDS: [&str; 13] = [
"joinqr",
"setqr",
"createqrsvg",
"providerinfo",
"fileinfo",
"estimatedeletion",
"clear",
@@ -266,11 +273,10 @@ impl Hinter for DcHelper {
&CONTACT_COMMANDS[..],
&MISC_COMMANDS[..],
] {
if let Some(entry) = cmds.iter().find(|el| el.starts_with(&line[..pos]))
&& *entry != line
&& *entry != &line[..pos]
{
return Some(entry[pos..].to_owned());
if let Some(entry) = cmds.iter().find(|el| el.starts_with(&line[..pos])) {
if *entry != line && *entry != &line[..pos] {
return Some(entry[pos..].to_owned());
}
}
}
}
@@ -422,6 +428,19 @@ async fn handle_cmd(
"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");

View File

@@ -2,9 +2,6 @@
RPC client connects to standalone Delta Chat RPC server `deltachat-rpc-server`
and provides asynchronous interface to it.
`rpc.start()` performs a health-check RPC call to verify the server
started successfully and will raise an error if startup fails
(e.g. if the accounts directory could not be used).
## Getting started
@@ -29,7 +26,7 @@ $ pip install .
1. Build `deltachat-rpc-server` with `cargo build -p deltachat-rpc-server`.
2. Install tox `pip install -U tox`
3. Run `CHATMAIL_DOMAIN=ci-chatmail.testrun.org PATH="../target/debug:$PATH" tox`.
3. Run `CHATMAIL_DOMAIN=nine.testrun.org PATH="../target/debug:$PATH" tox`.
Additional arguments to `tox` are passed to pytest, e.g. `tox -- -s` does not capture test output.

View File

@@ -13,7 +13,7 @@ def main():
with Rpc() as rpc:
deltachat = DeltaChat(rpc)
system_info = deltachat.get_system_info()
logging.info(f"Running deltachat core {system_info['deltachat_core_version']}")
logging.info("Running deltachat core %s", system_info["deltachat_core_version"])
accounts = deltachat.get_all_accounts()
account = accounts[0] if accounts else deltachat.add_account()
@@ -21,30 +21,36 @@ def main():
account.set_config("bot", "1")
if not account.is_configured():
logging.info("Account is not configured, configuring")
account.add_or_update_transport({"addr": sys.argv[1], "password": sys.argv[2]})
account.set_config("addr", sys.argv[1])
account.set_config("mail_pw", sys.argv[2])
account.configure()
logging.info("Configured")
else:
logging.info("Account is already configured")
deltachat.start_io()
qr = account.get_qr_code()
logging.info(f"Invite link: {qr}")
while True:
event = account.wait_for_event()
if event.kind == EventType.INFO:
logging.info(event["msg"])
elif event.kind == EventType.WARNING:
logging.warning(event["msg"])
elif event.kind == EventType.ERROR:
logging.error(event["msg"])
elif event.kind == EventType.INCOMING_MSG:
logging.info("Got an incoming message")
message = account.get_message_by_id(event.msg_id)
def process_messages():
for message in account.get_next_messages():
snapshot = message.get_snapshot()
if snapshot.from_id != SpecialContactId.SELF and not snapshot.is_bot and not snapshot.is_info:
snapshot.chat.send_text(snapshot.text)
snapshot.message.mark_seen()
# Process old messages.
process_messages()
while True:
event = account.wait_for_event()
if event["kind"] == EventType.INFO:
logging.info("%s", event["msg"])
elif event["kind"] == EventType.WARNING:
logging.warning("%s", event["msg"])
elif event["kind"] == EventType.ERROR:
logging.error("%s", event["msg"])
elif event["kind"] == EventType.INCOMING_MSG:
logging.info("Got an incoming message")
process_messages()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import argparse
import functools
import os
import re
import sys
@@ -45,13 +44,8 @@ class AttrDict(dict):
super().__setattr__(attr, val)
def _forever(_event: AttrDict) -> bool:
return False
def run_client_cli(
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
until: Callable[[AttrDict], bool] = _forever,
argv: Optional[list] = None,
**kwargs,
) -> None:
@@ -61,11 +55,10 @@ def run_client_cli(
"""
from .client import Client
_run_cli(Client, until, hooks, argv, **kwargs)
_run_cli(Client, hooks, argv, **kwargs)
def run_bot_cli(
until: Callable[[AttrDict], bool] = _forever,
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
argv: Optional[list] = None,
**kwargs,
@@ -76,12 +69,11 @@ def run_bot_cli(
"""
from .client import Bot
_run_cli(Bot, until, hooks, argv, **kwargs)
_run_cli(Bot, hooks, argv, **kwargs)
def _run_cli(
client_type: Type["Client"],
until: Callable[[AttrDict], bool] = _forever,
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
argv: Optional[list] = None,
**kwargs,
@@ -119,7 +111,7 @@ def _run_cli(
kwargs={"email": args.email, "password": args.password},
)
configure_thread.start()
client.run_until(until)
client.run_forever()
def extract_addr(text: str) -> str:
@@ -157,7 +149,7 @@ def parse_system_add_remove(text: str) -> Optional[Tuple[str, str, str]]:
"""
# You removed 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 With space (tmp1@x.org) removed by tmp2@x.org.
# Member With space (tmp1@x.org) removed by Another member (tmp2@x.org).",
@@ -190,6 +182,9 @@ class futuremethod: # noqa: N801
self._func = func
def __get__(self, instance, owner=None):
if instance is None:
return self
def future(*args):
generator = self._func(instance, *args)
res = next(generator)
@@ -202,7 +197,6 @@ class futuremethod: # noqa: N801
return f
@functools.wraps(self._func)
def wrapper(*args):
f = future(*args)
return f()

View File

@@ -3,9 +3,9 @@
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, Union
from warnings import warn
from ._utils import AttrDict, futuremethod
from .chat import Chat
@@ -36,15 +36,6 @@ class Account:
if event_type is None or next_event.kind == event_type:
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):
"""Remove all queued-up events for a given account.
@@ -150,10 +141,9 @@ class Account:
return transports
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._rpc.wait_for_all_work_done(self.id)
self.clear_all_events()
self.wait_for_event(EventType.IMAP_INBOX_IDLE)
def create_contact(self, obj: Union[int, str, Contact, "Account"], name: Optional[str] = None) -> Contact:
"""Create a new Contact or return an existing one.
@@ -193,7 +183,7 @@ class Account:
return [Contact(self, contact_id) for contact_id in contact_ids]
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()
def get_device_chat(self) -> Chat:
@@ -229,7 +219,7 @@ class Account:
return [AttrDict(contact=Contact(self, contact["id"]), **contact) for contact in contacts]
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):
assert contact.account == self
contact_id = contact.id
@@ -351,6 +341,9 @@ class Account:
because the word "channel" already appears a lot in the code,
which would make it hard to grep for it.
After creation, the chat contains no recipients and is in _unpromoted_ state;
see `create_group()` for more information on the unpromoted state.
Returns the created chat.
"""
return Chat(self, self._rpc.create_broadcast(self.id, name))
@@ -399,7 +392,8 @@ class Account:
"""Return the list of fresh messages, newest messages first.
This call is intended for displaying notifications.
If you are writing a bot, process "incoming message" events instead.
If you are writing a bot, use `get_fresh_messages_in_arrival_order()` instead,
to process oldest messages first.
"""
fresh_msg_ids = self._rpc.get_fresh_msgs(self.id)
return [Message(self, msg_id) for msg_id in fresh_msg_ids]
@@ -411,15 +405,7 @@ class Account:
@futuremethod
def wait_next_messages(self) -> list[Message]:
"""(deprecated) Wait for new messages and return a list of them. Meant for bots.
Deprecated 2026-04: This returns the message's id as soon as the first part arrives,
even if it is not fully downloaded yet.
The bot needs to wait for the message to be fully downloaded.
Since this is usually not the desired behavior,
bots should instead use the `EventType.INCOMING_MSG`
event for getting notified about new messages.
"""
"""Wait for new messages and return a list of them."""
next_msg_ids = yield self._rpc.wait_next_msgs.future(self.id)
return [Message(self, msg_id) for msg_id in next_msg_ids]
@@ -469,6 +455,16 @@ class Account:
"""Wait for reaction change event."""
return self.wait_for_event(EventType.REACTIONS_CHANGED)
def get_fresh_messages_in_arrival_order(self) -> list[Message]:
"""Return fresh messages list sorted in the order of their arrival, with ascending IDs."""
warn(
"get_fresh_messages_in_arrival_order is deprecated, use get_next_messages instead.",
DeprecationWarning,
stacklevel=2,
)
fresh_msg_ids = sorted(self._rpc.get_fresh_msgs(self.id))
return [Message(self, msg_id) for msg_id in fresh_msg_ids]
def export_backup(self, path, passphrase: str = "") -> None:
"""Export backup."""
self._rpc.export_backup(self.id, str(path), passphrase)
@@ -487,11 +483,11 @@ class Account:
passphrase = "" # Importing passphrase-protected keys is currently not supported.
self._rpc.import_self_keys(self.id, str(path), passphrase)
def initiate_autocrypt_key_transfer(self) -> None:
"""Send Autocrypt Setup Message."""
return self._rpc.initiate_autocrypt_key_transfer(self.id)
def ice_servers(self) -> list:
"""Return ICE servers for WebRTC configuration."""
ice_servers_json = self._rpc.ice_servers(self.id)
return json.loads(ice_servers_json)
def is_sending_locations(self) -> bool:
"""Return True if sending locations to any chat."""
return self._rpc.is_sending_locations(self.id)

View File

@@ -164,7 +164,7 @@ class Chat:
return Message(self.account, msg_id)
def send_sticker(self, path: str) -> Message:
"""Deprecated as of 2026-04; use `send_message` with `Viewtype.STICKER` instead."""
"""Send an sticker and return the resulting Message instance."""
msg_id = self._rpc.send_sticker(self.account.id, self.id, path)
return Message(self.account, msg_id)
@@ -206,9 +206,9 @@ class Chat:
snapshot["message"] = Message(self.account, snapshot.id)
return snapshot
def get_messages(self, add_daymarker: bool = False) -> list[Message]:
def get_messages(self, info_only: bool = False, add_daymarker: bool = False) -> list[Message]:
"""Get the list of messages in this chat."""
msgs = self._rpc.get_message_ids(self.account.id, self.id, False, add_daymarker)
msgs = self._rpc.get_message_ids(self.account.id, self.id, info_only, add_daymarker)
return [Message(self.account, msg_id) for msg_id in msgs]
def get_fresh_message_count(self) -> int:
@@ -219,10 +219,6 @@ class Chat:
"""Mark all messages in this chat as noticed."""
self._rpc.marknoticed_chat(self.account.id, self.id)
def mark_fresh(self) -> None:
"""Mark the last incoming message in the chat as fresh."""
self._rpc.markfresh_chat(self.account.id, self.id)
def add_contact(self, *contact: Union[int, str, Contact, "Account"]) -> None:
"""Add contacts to this group."""
from .account import Account
@@ -252,7 +248,7 @@ class Chat:
def get_contacts(self) -> list[Contact]:
"""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)
return [Contact(self.account, contact_id) for contact_id in contacts]
@@ -277,16 +273,6 @@ class Chat:
"""Remove profile image of this chat."""
self._rpc.set_chat_profile_image(self.account.id, self.id, None)
def send_locations(self, seconds) -> None:
"""Enable location streaming in the chat for the given number of seconds.
Pass 0 to disable location streaming."""
self._rpc.send_locations_to_chat(self.account.id, self.id, seconds)
def is_sending_locations(self) -> bool:
"""Return True if sending locations to this chat."""
return self._rpc.is_sending_locations_to_chat(self.account.id, self.id)
def get_locations(
self,
contact: Optional[Contact] = None,
@@ -317,7 +303,7 @@ class Chat:
f.flush()
self._rpc.send_msg(self.account.id, self.id, {"viewtype": ViewType.VCARD, "file": f.name})
def place_outgoing_call(self, place_call_info: str, has_video_initially: bool) -> Message:
def place_outgoing_call(self, place_call_info: str) -> Message:
"""Starts an outgoing call."""
msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info, has_video_initially)
msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info)
return Message(self.account, msg_id)

View File

@@ -14,7 +14,6 @@ from typing import (
from ._utils import (
AttrDict,
_forever,
parse_system_add_remove,
parse_system_image_changed,
parse_system_title_changed,
@@ -92,28 +91,19 @@ class Client:
def run_forever(self) -> None:
"""Process events forever."""
self.run_until(_forever)
self.run_until(lambda _: False)
def run_until(self, func: Callable[[AttrDict], bool]) -> AttrDict:
"""Start the event processing loop."""
"""Process events until the given callable evaluates to True.
The callable should accept an AttrDict object representing the
last processed event. The event is returned when the callable
evaluates to True.
"""
self.logger.debug("Listening to incoming events...")
if self.is_configured():
self.account.start_io()
self._process_messages() # Process old messages.
return self._process_events(until_func=func) # Loop over incoming events
def _process_events(
self,
until_func: Callable[[AttrDict], bool] = _forever,
until_event: EventType = False,
) -> AttrDict:
"""Process events until the given callable evaluates to True,
or until a certain event happens.
The until_func callable should accept an AttrDict object representing
the last processed event. The event is returned when the callable
evaluates to True.
"""
while True:
event = self.account.wait_for_event()
event["kind"] = EventType(event.kind)
@@ -122,13 +112,10 @@ class Client:
if event.kind == EventType.INCOMING_MSG:
self._process_messages()
stop = until_func(event)
stop = func(event)
if stop:
return event
if event.kind == until_event:
return event
def _on_event(self, event: AttrDict, filter_type: Type[EventFilter] = RawEvent) -> None:
for hook, evfilter in self._hooks.get(filter_type, []):
if evfilter.filter(event):

View File

@@ -54,7 +54,6 @@ class EventType(str, Enum):
MSG_DELIVERED = "MsgDelivered"
MSG_FAILED = "MsgFailed"
MSG_READ = "MsgRead"
MSG_READ_COUNT_CHANGED = "MsgReadCountChanged"
MSG_DELETED = "MsgDeleted"
CHAT_MODIFIED = "ChatModified"
CHAT_DELETED = "ChatDeleted"
@@ -97,7 +96,7 @@ class ChatType(str, Enum):
"""Chat type."""
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"
@@ -191,6 +190,7 @@ class MessageState(IntEnum):
IN_FRESH = 10
IN_NOTICED = 13
IN_SEEN = 16
OUT_PREPARING = 18
OUT_DRAFT = 19
OUT_PENDING = 20
OUT_FAILED = 24
@@ -231,6 +231,14 @@ class KeyGenType(IntEnum):
RSA4096 = 3
# "Lp" means "login parameters"
class LpAuthFlag(IntEnum):
"""Authorization flags."""
OAUTH2 = 0x2
NORMAL = 0x4
class MediaQuality(IntEnum):
"""Media quality setting."""
@@ -246,6 +254,14 @@ class ProviderStatus(IntEnum):
BROKEN = 3
class PushNotifyState(IntEnum):
"""Push notifications state."""
NOT_CONNECTED = 0
HEARTBEAT = 1
CONNECTED = 2
class ShowEmails(IntEnum):
"""Show emails mode."""

View File

@@ -55,7 +55,7 @@ class Contact:
return snapshot
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
return Chat(

View File

@@ -59,15 +59,3 @@ class DeltaChat:
def set_translations(self, translations: dict[str, str]) -> None:
"""Set stock translation strings."""
self.rpc.set_stock_strings(translations)
def set_location(self, latitude, longitude, accuracy) -> bool:
"""Set location, return True if location streaming should continue."""
return self.rpc.set_location(latitude, longitude, accuracy)
def stop_sending_locations(self) -> None:
"""Stop sending locations to all chats."""
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()

View File

@@ -25,14 +25,7 @@ class Message:
return self.account._rpc
def send_reaction(self, *reaction: str) -> "Message":
"""
Sends a reaction to message.
A reaction is a string that represents an emoji.
You can call this function again to change the emoji;
the last sent reaction overrides all previously sent reactions.
It is possible to remove the reaction by sending an empty string.
"""
"""Send a reaction to this message."""
msg_id = self._rpc.send_reaction(self.account.id, self.id, reaction)
return Message(self.account, msg_id)
@@ -51,14 +44,6 @@ class Message:
read_receipts = self._rpc.get_message_read_receipts(self.account.id, self.id)
return [AttrDict(read_receipt) for read_receipt in read_receipts]
def get_read_receipt_count(self) -> int:
"""
Returns count of read receipts on message.
This view count is meant as a feedback measure for the channel owner only.
"""
return self._rpc.get_message_read_receipt_count(self.account.id, self.id)
def get_reactions(self) -> Optional[AttrDict]:
"""Get message reactions."""
reactions = self._rpc.get_message_reactions(self.account.id, self.id)
@@ -79,6 +64,14 @@ class Message:
"""Return True if the message exists."""
return bool(self._rpc.get_existing_msg_ids(self.account.id, [self.id]))
def continue_autocrypt_key_transfer(self, setup_code: str) -> None:
"""Continue the Autocrypt Setup Message key transfer.
This function can be called on received Autocrypt Setup Message
to import the key encrypted with the provided setup code.
"""
self._rpc.continue_autocrypt_key_transfer(self.account.id, self.id, setup_code)
def send_webxdc_status_update(self, update: Union[dict, str], description: str) -> None:
"""Send a webxdc status update. This message must be a webxdc."""
if not isinstance(update, str):

View File

@@ -7,13 +7,12 @@ import os
import pathlib
import platform
import random
import socket
import subprocess
import sys
import time
import urllib.parse
from typing import Iterator, Optional
from typing import AsyncGenerator, Optional
import execnet
import py
import pytest
from . import Account, AttrDict, Bot, Chat, Client, DeltaChat, EventType, Message
@@ -23,46 +22,23 @@ from .rpc import Rpc
E2EE_INFO_MSGS = 1
"""
The number of info messages added to new e2ee chats.
Currently this is "Messages are end-to-end encrypted."
Currently this is "End-to-end encryption available".
"""
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():
headers = [f"CHATMAIL_DOMAIN: {os.environ.get('CHATMAIL_DOMAIN')}"]
for base in os.get_exec_path():
fn = pathlib.Path(base).joinpath(base, "deltachat-rpc-server")
if fn.exists():
proc = subprocess.Popen([str(fn), "--version"], stderr=subprocess.PIPE)
proc.wait()
version = proc.stderr.read().decode().strip()
headers.append(f"RPC-SERVER: {fn} [{version}]")
break
return f"deltachat-rpc-server: {fn} [{version}]"
return headers
return None
class RPCAccountFactory:
class ACFactory:
"""Test account factory."""
def __init__(self, deltachat: DeltaChat) -> None:
@@ -78,13 +54,13 @@ class RPCAccountFactory:
def get_credentials(self) -> (str, str):
"""Generate new credentials for chatmail account."""
domain = os.environ["CHATMAIL_DOMAIN"]
domain = os.getenv("CHATMAIL_DOMAIN")
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
return f"{username}@{domain}", f"{username}${username}"
def get_account_qr(self):
"""Return "dcaccount:" QR code for testing chatmail relay."""
domain = os.environ["CHATMAIL_DOMAIN"]
domain = os.getenv("CHATMAIL_DOMAIN")
return f"dcaccount:{domain}"
@futuremethod
@@ -128,7 +104,7 @@ class RPCAccountFactory:
return ac_clone
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.
"""
@@ -175,7 +151,7 @@ class RPCAccountFactory:
@pytest.fixture
def rpc(tmp_path) -> Iterator[Rpc]:
def rpc(tmp_path) -> AsyncGenerator:
"""RPC client fixture."""
rpc_server = Rpc(accounts_dir=str(tmp_path / "accounts"))
with rpc_server:
@@ -189,20 +165,20 @@ def dc(rpc) -> DeltaChat:
@pytest.fixture
def acf(dc) -> RPCAccountFactory:
def acfactory(dc) -> AsyncGenerator:
"""Return account factory fixture."""
return RPCAccountFactory(dc)
return ACFactory(dc)
@pytest.fixture
def rpcdata():
def data():
"""Test data."""
class Data:
def __init__(self) -> None:
for path in pathlib.Path(__file__).parents:
datadir = path / "test-data"
if datadir.is_dir():
for path in reversed(py.path.local(__file__).parts()):
datadir = path.join("test-data")
if datadir.isdir():
self.path = datadir
return
raise Exception("Data path cannot be found")
@@ -292,33 +268,26 @@ def get_core_python_env(tmp_path_factory):
@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.
The 'eval' function allows to remote-execute arbitrary expressions
that can use the `bob` online account, and the `bob_contact_alice`.
"""
from execnet import makegateway
def factory(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"))
channel = gw.remote_exec(remote_bob_loop)
# 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"
cm = os.environ.get("CHATMAIL_DOMAIN")
# 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
alice = acf.get_online_account()
alice = acfactory.get_online_account()
channel.send(alice.self_contact.make_vcard())
# wait for bob to have started
@@ -347,8 +316,10 @@ def remote_bob_loop(channel):
import os
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
# so we can't just pass `rpc_server_path` argument to Rpc constructor
@@ -359,13 +330,8 @@ def remote_bob_loop(channel):
with rpc:
dc = DeltaChat(rpc)
channel.send(dc.rpc.get_system_info()["deltachat_core_version"])
# RPCAccountFactory would configure from a "dcaccount" QR,
# which old cores cannot use on underscore domains
bob = dc.add_account()
bob.add_transport_from_qr(dclogin_qr)
bob.bring_online()
acfactory = ACFactory(dc)
bob = acfactory.get_online_account()
alice_vcard = channel.receive()
[alice_contact] = bob.import_vcard(alice_vcard)
ns = {"bob": bob, "bob_contact_alice": alice_contact}

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import contextlib
import itertools
import json
import logging
@@ -39,15 +38,8 @@ class RpcMethod:
"params": args,
"id": request_id,
}
queue: Queue = Queue()
# Register before testing for shutdown, so that either the reader loop
# 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)
self.rpc.request_results[request_id] = queue = Queue()
self.rpc.request_queue.put(request)
def rpc_future():
"""Wait for the request to receive a result."""
@@ -62,12 +54,7 @@ class RpcMethod:
class Rpc:
"""RPC client."""
def __init__(
self,
accounts_dir: Optional[str] = None,
rpc_server_path="deltachat-rpc-server",
**kwargs,
):
def __init__(self, accounts_dir: Optional[str] = None, rpc_server_path="deltachat-rpc-server", **kwargs):
"""Initialize RPC client.
The 'kwargs' arguments will be passed to subprocess.Popen().
@@ -86,25 +73,14 @@ class Rpc:
# Map from request ID to a Queue which provides a single result
self.request_results: dict[int, Queue]
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.reader_thread: Thread
self.writer_thread: Thread
self.events_thread: Thread
def start(self) -> None:
"""Start RPC server subprocess and wait for successful initialization.
This method blocks until the RPC server responds to an initial
health-check RPC call (get_system_info).
If the server fails to start
(e.g., due to an invalid accounts directory),
a JsonRpcError is raised.
"""
popen_kwargs = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE}
"""Start RPC server subprocess."""
popen_kwargs = {"stdin": subprocess.PIPE, "stdout": subprocess.PIPE}
if sys.version_info >= (3, 11):
# Prevent subprocess from capturing SIGINT.
popen_kwargs["process_group"] = 0
@@ -114,12 +90,10 @@ class Rpc:
popen_kwargs.update(self._kwargs)
self.process = subprocess.Popen(self.rpc_server_path, **popen_kwargs)
self.id_iterator = itertools.count(start=1)
self.event_queues = {}
self.request_results = {}
self.request_queue = Queue()
self.request_queue_closed = False
self.closing = False
self.reader_thread = Thread(target=self.reader_loop)
self.reader_thread.start()
@@ -128,53 +102,15 @@ class Rpc:
self.events_thread = Thread(target=self.events_loop)
self.events_thread.start()
# Perform a health-check RPC call to ensure the server started
# successfully and the accounts directory is usable.
try:
system_info = self.get_system_info()
except (JsonRpcError, Exception) as e:
# The reader_loop already saw EOF on stdout, so the process
# has exited and stderr is available.
stderr = self.process.stderr.read().decode(errors="replace").strip()
self.closing = True
self._shutdown_loops()
if stderr:
raise JsonRpcError(f"RPC server failed to start: {stderr}") from e
raise JsonRpcError(f"RPC server startup check failed: {e}") from e
logging.info(
"RPC server ready. Core version: %s",
system_info.get("deltachat_core_version", "unknown"),
)
def close(self) -> None:
"""Terminate RPC server process and wait until the reader loop finishes."""
self.closing = True
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._shutdown_loops()
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.process.stdin.close()
self.reader_thread.join()
self.request_queue.put(None)
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):
self.start()
@@ -196,12 +132,6 @@ class Rpc:
except Exception:
# Log an exception if the reader loop dies.
logging.exception("Exception in the reader loop")
finally:
# Shut the request queue first, so that requests registered from now
# on are failed by their caller, then answer the pending ones here.
self.request_queue_closed = True
for request_id in list(self.request_results):
self._fail_request(request_id)
def writer_loop(self) -> None:
"""Writer loop ensuring only a single thread writes requests."""
@@ -210,6 +140,7 @@ class Rpc:
data = (json.dumps(request) + "\n").encode()
self.process.stdin.write(data)
self.process.stdin.flush()
except Exception:
# Log an exception if the writer loop dies.
logging.exception("Exception in the writer loop")
@@ -223,15 +154,15 @@ class Rpc:
def events_loop(self) -> None:
"""Request new events and distributes them between queues."""
try:
while events := self.get_next_event_batch():
for event in events:
account_id = event["contextId"]
queue = self.get_queue(account_id)
payload = event["event"]
logging.debug("account_id=%d got an event %s", account_id, payload)
queue.put(payload)
while True:
if self.closing:
return
event = self.get_next_event()
account_id = event["contextId"]
queue = self.get_queue(account_id)
event = event["event"]
logging.debug("account_id=%d got an event %s", account_id, event)
queue.put(event)
except Exception:
# Log an exception if the event loop dies.
logging.exception("Exception in the event loop")

View File

@@ -22,10 +22,8 @@ ALL = "1:*"
class DirectImap:
"""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.addr = addr or account.get_config("addr")
self.password = password or account.get_config("mail_pw")
self.logid = account.get_config("displayname") or id(account)
self._idling = False
self.connect()
@@ -35,15 +33,11 @@ class DirectImap:
host = self.account.get_config("configured_mail_server")
port = 993
user = self.addr
user = self.account.get_config("addr")
host = user.rsplit("@")[-1]
pw = self.password
pw = self.account.get_config("mail_pw")
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 = MailBox(host, port, ssl_context=ssl.create_default_context())
self.conn.login(user, pw)
self.select_folder("INBOX")

View File

@@ -5,16 +5,16 @@ from typing import TYPE_CHECKING
from deltachat_rpc_client import EventType
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
"""
addr, password = acf.get_credentials()
account = acf.get_unconfigured_account()
addr, password = acfactory.get_credentials()
account = acfactory.get_unconfigured_account()
account.clear_all_events()
assert not account.is_configured()
future = account.add_or_update_transport.future({"addr": addr, "password": password})

View File

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

View File

@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
from deltachat_rpc_client import Account, EventType, const
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):
@@ -40,11 +40,11 @@ def wait_for_chatlist(account):
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)
"""
alice, bob = acf.get_online_accounts(2)
alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
@@ -82,12 +82,11 @@ def test_delivery_status(acf: RPCAccountFactory) -> None:
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
"""
(alice,) = acf.get_online_accounts(1)
alice.set_config("force_encryption", "0")
(alice,) = acfactory.get_online_accounts(1)
invalid_contact = alice.create_contact("example@example.com", "invalid address")
invalid_chat = alice.get_chat_by_id(alice._rpc.create_chat_by_contact_id(alice.id, invalid_contact.id))
@@ -110,12 +109,12 @@ def test_delivery_status_failed(acf: RPCAccountFactory) -> None:
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.
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_chat_bob = alice_contact_bob.create_chat()
@@ -128,7 +127,7 @@ def test_download_on_demand(acf: RPCAccountFactory, rpcdata) -> None:
msg.get_snapshot().chat.accept()
bob.get_chat_by_id(chat_id).send_message(
"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()
@@ -144,8 +143,8 @@ def test_download_on_demand(acf: RPCAccountFactory, rpcdata) -> None:
wait_for_chatlist_specific_item(alice, chat_id)
def get_multi_account_test_setup(acf: RPCAccountFactory) -> [Account, Account, Account]:
alice, bob = acf.get_online_accounts(2)
def get_multi_account_test_setup(acfactory: ACFactory) -> [Account, Account, Account]:
alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
@@ -161,12 +160,12 @@ def get_multi_account_test_setup(acf: RPCAccountFactory) -> [Account, Account, A
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
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)
@@ -191,11 +190,11 @@ def test_imap_sync_seen_msgs(acf: RPCAccountFactory) -> None:
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
"""
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()
wait_for_chatlist_specific_item(alice_second_device, alice_chat_bob.id)

View File

@@ -1,5 +1,4 @@
import subprocess
import time
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"])
def test_qr_setup_contact(acf, alice_and_remote_bob, version) -> None:
def test_qr_setup_contact(alice_and_remote_bob, version) -> None:
"""Test other-core Bob profile can do securejoin with Alice on current core."""
alice, alice_contact_bob, remote_eval = alice_and_remote_bob(version)
@@ -34,41 +33,10 @@ def test_qr_setup_contact(acf, alice_and_remote_bob, version) -> None:
# Test that Bob verified Alice's profile.
assert remote_eval("bob_contact_alice.get_snapshot().is_verified")
# Test that Bob can also scan a QR code
# of Alice for which the key is not known yet.
# For the test above Bob already knew the key from a vCard.
alice2 = acf.get_online_account()
qr_code = alice2.get_qr_code()
remote_eval(f"bob.secure_join({qr_code!r})")
remote_eval("bob.wait_for_securejoin_joiner_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()
# Test that Alice verified Bob's profile.
alice_contact_bob_snapshot = alice_contact_bob.get_snapshot()
assert alice_contact_bob_snapshot.is_verified
remote_eval("bob.wait_for_securejoin_joiner_success()")
# Test that Bob verified Alice's profile.
assert remote_eval("bob_contact_alice.get_snapshot().is_verified")
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."""
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')")
@@ -76,64 +44,14 @@ def test_send_and_receive_message(alice_and_remote_bob) -> None:
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."""
_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))")
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)
remote_eval("locals()['future']()")
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"

View File

@@ -1,29 +1,322 @@
import logging
import re
import time
import pytest
from imap_tools import AND, U
from deltachat_rpc_client import EventType
from deltachat_rpc_client import Contact, EventType, Message
def test_moved_markseen(acf, direct_imap, log):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1 = acf.get_online_account()
addr, password = acf.get_credentials()
ac2 = acf.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password})
def test_move_works(acfactory):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.set_config("mvbox_move", "1")
ac2.bring_online()
# Make sure that messages are not immediately auto-deleted on the server:
ac1.set_config("bcc_self", "1")
ac2.set_config("bcc_self", "1")
chat = ac1.create_chat(ac2)
chat.send_text("message1")
log.section("ac2: creating DeltaChat folder")
# Message is moved to the movebox
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
# Message is downloaded
msg = ac2.wait_for_incoming_msg().get_snapshot()
assert msg.text == "message1"
def test_move_avoids_loop(acfactory, direct_imap):
"""Test that the message is only moved from INBOX to DeltaChat.
This is to avoid busy loop if moved message reappears in the Inbox
or some scanned folder later.
For example, this happens on servers that alias `INBOX.DeltaChat` to `DeltaChat` folder,
so the message moved to `DeltaChat` appears as a new message in the `INBOX.DeltaChat` folder.
We do not want to move this message from `INBOX.DeltaChat` to `DeltaChat` again.
"""
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.set_config("mvbox_move", "1")
ac2.set_config("delete_server_after", "0")
ac2.bring_online()
# Create INBOX.DeltaChat folder and make sure
# it is detected by full folder scan.
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("DeltaChat")
ac2.set_config("sync_msgs", "0") # Do not send a sync message when accepting a contact request.
ac2_direct_imap.create_folder("INBOX.DeltaChat")
ac2.stop_io()
ac2.start_io()
ac2.add_or_update_transport({"addr": addr, "password": password, "imapFolder": "DeltaChat"})
while True:
event = ac2.wait_for_event()
# Wait until the end of folder scan.
if event.kind == EventType.INFO and "Found folders:" in event.msg:
break
ac1_chat = acfactory.get_accepted_chat(ac1, ac2)
ac1_chat.send_text("Message 1")
# Message is moved to the DeltaChat folder and downloaded.
ac2_msg1 = ac2.wait_for_incoming_msg().get_snapshot()
assert ac2_msg1.text == "Message 1"
# Move the message to the INBOX.DeltaChat again.
# We assume that test server uses "." as the delimiter.
ac2_direct_imap.select_folder("DeltaChat")
ac2_direct_imap.conn.move(["*"], "INBOX.DeltaChat")
ac1_chat.send_text("Message 2")
ac2_msg2 = ac2.wait_for_incoming_msg().get_snapshot()
assert ac2_msg2.text == "Message 2"
# Stop and start I/O to trigger folder scan.
ac2.stop_io()
ac2.start_io()
while True:
event = ac2.wait_for_event()
# Wait until the end of folder scan.
if event.kind == EventType.INFO and "Found folders:" in event.msg:
break
# Check that Message 1 is still in the INBOX.DeltaChat folder
# and Message 2 is in the DeltaChat folder.
ac2_direct_imap.select_folder("INBOX")
assert len(ac2_direct_imap.get_all_messages()) == 0
ac2_direct_imap.select_folder("DeltaChat")
assert len(ac2_direct_imap.get_all_messages()) == 1
ac2_direct_imap.select_folder("INBOX.DeltaChat")
assert len(ac2_direct_imap.get_all_messages()) == 1
def test_reactions_for_a_reordering_move(acfactory, direct_imap):
"""When a batch of messages is moved from Inbox to DeltaChat folder with a single MOVE command,
their UIDs may be reordered (e.g. Gmail is known for that) which led to that messages were
processed by receive_imf in the wrong order, and, particularly, reactions were processed before
messages they refer to and thus dropped.
"""
(ac1,) = acfactory.get_online_accounts(1)
addr, password = acfactory.get_credentials()
ac2 = acfactory.get_unconfigured_account()
ac2.add_or_update_transport({"addr": addr, "password": password})
ac2.set_config("mvbox_move", "1")
assert ac2.is_configured()
ac2.bring_online()
chat1 = acfactory.get_accepted_chat(ac1, ac2)
ac2.stop_io()
logging.info("sending message + reaction from ac1 to ac2")
msg1 = chat1.send_text("hi")
msg1.wait_until_delivered()
# It's is sad, but messages must differ in their INTERNALDATEs to be processed in the correct
# order by DC, and most (if not all) mail servers provide only seconds precision.
time.sleep(1.1)
react_str = "\N{THUMBS UP SIGN}"
msg1.send_reaction(react_str).wait_until_delivered()
logging.info("moving messages to ac2's DeltaChat folder in the reverse order")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.connect()
for uid in sorted([m.uid for m in ac2_direct_imap.get_all_messages()], reverse=True):
ac2_direct_imap.conn.move(uid, "DeltaChat")
logging.info("receiving messages by ac2")
ac2.start_io()
msg2 = Message(ac2, ac2.wait_for_reactions_changed().msg_id)
assert msg2.get_snapshot().text == msg1.get_snapshot().text
reactions = msg2.get_reactions()
contacts = [Contact(ac2, int(i)) for i in reactions.reactions_by_contact]
assert len(contacts) == 1
assert contacts[0].get_snapshot().address == ac1.get_config("addr")
assert list(reactions.reactions_by_contact.values())[0] == [react_str]
def test_delete_deltachat_folder(acfactory, direct_imap):
"""Test that DeltaChat folder is recreated if user deletes it manually."""
ac1 = acfactory.new_configured_account()
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.conn.folder.delete("DeltaChat")
assert "DeltaChat" not in ac1_direct_imap.list_folders()
# Wait until new folder is created and UIDVALIDITY is updated.
while True:
event = ac1.wait_for_event()
if event.kind == EventType.INFO and "transport 1: UID validity for folder DeltaChat changed from " in event.msg:
break
ac2 = acfactory.get_online_account()
ac2.create_chat(ac1).send_text("hello")
msg = ac1.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
assert "DeltaChat" in ac1_direct_imap.list_folders()
def test_dont_show_emails(acfactory, direct_imap, log):
"""Most mailboxes have a "Drafts" folder where constantly new emails appear but we don't actually want to show them.
So: If it's outgoing AND there is no Received header, then ignore the email.
If the draft email is sent out and received later (i.e. it's in "Inbox"), it must be shown.
Also, test that unknown emails in the Spam folder are not shown."""
ac1 = acfactory.new_configured_account()
ac1.stop_io()
ac1.set_config("show_emails", "2")
ac1.create_contact("alice@example.org").create_chat()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder("Drafts")
ac1_direct_imap.create_folder("Spam")
ac1_direct_imap.create_folder("Junk")
# Learn UID validity for all folders.
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.start_io()
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
ac1.stop_io()
ac1_direct_imap.append(
"Drafts",
"""
From: ac1 <{}>
Subject: subj
To: alice@example.org
Message-ID: <aepiors@example.org>
Content-Type: text/plain; charset=utf-8
message in Drafts received later
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: unknown.address@junk.org, unkwnown.add@junk.org
Subject: subj
To: {}
Message-ID: <spam.message2@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: delta<address: inbox@nhroy.com>
Subject: subj
To: {}
Message-ID: <spam.message99@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown & malformed message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Spam",
"""
From: alice@example.org
Subject: subj
To: {}
Message-ID: <spam.message3@junk.org>
Content-Type: text/plain; charset=utf-8
Actually interesting message in Spam
""".format(
ac1.get_config("configured_addr"),
),
)
ac1_direct_imap.append(
"Junk",
"""
From: unknown.address@junk.org
Subject: subj
To: {}
Message-ID: <spam.message@junk.org>
Content-Type: text/plain; charset=utf-8
Unknown message in Junk
""".format(
ac1.get_config("configured_addr"),
),
)
ac1.set_config("scan_all_folders_debounce_secs", "0")
log.section("All prepared, now let DC find the message")
ac1.start_io()
# Wait until each folder was scanned, this is necessary for this test to test what it should test:
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
fresh_msgs = list(ac1.get_fresh_messages())
msg = fresh_msgs[0].get_snapshot()
chat_msgs = msg.chat.get_messages()
assert len(chat_msgs) == 1
assert msg.text == "subj Actually interesting message in Spam"
assert not any("unknown.address" in c.get_full_snapshot().name for c in ac1.get_chatlist())
ac1_direct_imap.select_folder("Spam")
assert ac1_direct_imap.get_uid_by_message_id("spam.message@junk.org")
ac1.stop_io()
log.section("'Send out' the draft by moving it to Inbox, and wait for DC to display it this time")
ac1_direct_imap.select_folder("Drafts")
uid = ac1_direct_imap.get_uid_by_message_id("aepiors@example.org")
ac1_direct_imap.conn.move(uid, "Inbox")
ac1.start_io()
event = ac1.wait_for_event(EventType.MSGS_CHANGED)
msg2 = Message(ac1, event.msg_id).get_snapshot()
assert msg2.text == "subj message in Drafts received later"
assert len(msg.chat.get_messages()) == 2
def test_move_works_on_self_sent(acfactory):
ac1, ac2 = acfactory.get_online_accounts(2)
# Enable movebox and wait until it is created.
ac1.set_config("mvbox_move", "1")
ac1.set_config("bcc_self", "1")
ac1.bring_online()
chat = ac1.create_chat(ac2)
chat.send_text("message1")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
chat.send_text("message2")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
chat.send_text("message3")
ac1.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
def test_moved_markseen(acfactory, direct_imap):
"""Test that message already moved to DeltaChat folder is marked as seen."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.set_config("mvbox_move", "1")
ac2.set_config("delete_server_after", "0")
ac2.set_config("sync_msgs", "0") # Do not send a sync message when accepting a contact request.
ac2.bring_online()
ac2.stop_io()
@@ -33,7 +326,6 @@ def test_moved_markseen(acf, direct_imap, log):
idle2.wait_for_new_message()
# Emulate moving of the message to DeltaChat folder by Sieve rule.
log.section("ac2: moving message into DeltaChat folder")
ac2_direct_imap.conn.move(["*"], "DeltaChat")
ac2_direct_imap.select_folder("DeltaChat")
assert len(list(ac2_direct_imap.conn.fetch("*", mark_seen=False))) == 1
@@ -57,51 +349,164 @@ 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
def test_markseen_message_and_mdn(acf, direct_imap):
ac1, ac2 = acf.get_online_accounts(2)
@pytest.mark.parametrize("mvbox_move", [True, False])
def test_markseen_message_and_mdn(acfactory, direct_imap, mvbox_move):
ac1, ac2 = acfactory.get_online_accounts(2)
# Make sure that messages are not immediately auto-deleted on the server:
ac1.set_config("bcc_self", "1")
ac2.set_config("bcc_self", "1")
for ac in ac1, ac2:
ac.set_config("delete_server_after", "0")
if mvbox_move:
ac.set_config("mvbox_move", "1")
ac.bring_online()
acf.get_accepted_chat(ac1, ac2).send_text("hi")
# Do not send BCC to self, we only want to test MDN on ac1.
ac1.set_config("bcc_self", "0")
acfactory.get_accepted_chat(ac1, ac2).send_text("hi")
msg = ac2.wait_for_incoming_msg()
msg.mark_seen()
rex = re.compile("Marked messages ([0-9,:]+) in folder INBOX as seen.")
if mvbox_move:
rex = re.compile("Marked messages [0-9]+ in folder DeltaChat as seen.")
else:
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:
uids = set()
while len(uids) < 2:
while True:
event = ac.wait_for_event()
if event.kind == EventType.INFO and (match := rex.search(event.msg)):
uids.update(re.split("[,:]", match.group(1)))
if event.kind == EventType.INFO and rex.search(event.msg):
break
folder = "mvbox" if mvbox_move else "inbox"
ac1_direct_imap = direct_imap(ac1)
ac2_direct_imap = direct_imap(ac2)
ac1_direct_imap.select_folder("INBOX")
ac2_direct_imap.select_folder("INBOX")
ac1_direct_imap.select_config_folder(folder)
ac2_direct_imap.select_config_folder(folder)
# Check that the mdn and original message is marked as seen
assert len(list(ac1_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
# Check that the mdn is marked as seen
assert len(list(ac1_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
# Check original message is marked as seen
assert len(list(ac2_direct_imap.conn.fetch(AND(seen=True), mark_seen=False))) == 1
def test_trash_multiple_messages(acf, direct_imap, log):
ac1, ac2 = acf.get_online_accounts(2)
def test_mvbox_and_trash(acfactory, direct_imap, log):
log.section("ac1: start with mvbox")
ac1 = acfactory.get_online_account()
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
log.section("ac2: start without a mvbox")
ac2 = acfactory.get_online_account()
log.section("ac1: create trash")
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder("Trash")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.stop_io()
ac1.start_io()
log.section("ac1: send message and wait for ac2 to receive it")
acfactory.get_accepted_chat(ac1, ac2).send_text("message1")
assert ac2.wait_for_incoming_msg().get_snapshot().text == "message1"
assert ac1.get_config("configured_mvbox_folder") == "DeltaChat"
while ac1.get_config("configured_trash_folder") != "Trash":
ac1.wait_for_event(EventType.CONNECTIVITY_CHANGED)
@pytest.mark.parametrize(
("folder", "move", "expected_destination"),
[
(
"xyz",
False,
"xyz",
), # Test that emails aren't found in a random folder
(
"xyz",
True,
"xyz",
), # ...emails are found in a random folder and downloaded without moving
(
"Spam",
False,
"INBOX",
), # ...emails are moved from the spam folder to the Inbox
],
)
# Testrun.org does not support the CREATE-SPECIAL-USE capability, which means that we can't create a folder with
# the "\Junk" flag (see https://tools.ietf.org/html/rfc6154). So, we can't test spam folder detection by flag.
def test_scan_folders(acfactory, log, direct_imap, folder, move, expected_destination):
"""Delta Chat periodically scans all folders for new messages to make sure we don't miss any."""
variant = folder + "-" + str(move) + "-" + expected_destination
log.section("Testing variant " + variant)
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("delete_server_after", "0")
if move:
ac1.set_config("mvbox_move", "1")
ac1.bring_online()
ac1.stop_io()
ac1_direct_imap = direct_imap(ac1)
ac1_direct_imap.create_folder(folder)
# Wait until each folder was selected once and we are IDLEing:
ac1.start_io()
ac1.bring_online()
ac1.stop_io()
assert folder in ac1_direct_imap.list_folders()
log.section("Send a message from ac2 to ac1 and manually move it to `folder`")
ac1_direct_imap.select_config_folder("inbox")
with ac1_direct_imap.idle() as idle1:
acfactory.get_accepted_chat(ac2, ac1).send_text("hello")
idle1.wait_for_new_message()
ac1_direct_imap.conn.move(["*"], folder) # "*" means "biggest UID in mailbox"
log.section("start_io() and see if DeltaChat finds the message (" + variant + ")")
ac1.set_config("scan_all_folders_debounce_secs", "0")
ac1.start_io()
chat = ac1.create_chat(ac2)
n_msgs = 1 # "Messages are end-to-end encrypted."
if folder == "Spam":
msg = ac1.wait_for_incoming_msg().get_snapshot()
assert msg.text == "hello"
n_msgs += 1
else:
ac1.wait_for_event(EventType.IMAP_INBOX_IDLE)
assert len(chat.get_messages()) == n_msgs
# The message has reached its destination.
ac1_direct_imap.select_folder(expected_destination)
assert len(ac1_direct_imap.get_all_messages()) == 1
if folder != expected_destination:
ac1_direct_imap.select_folder(folder)
assert len(ac1_direct_imap.get_all_messages()) == 0
def test_trash_multiple_messages(acfactory, direct_imap, log):
ac1, ac2 = acfactory.get_online_accounts(2)
ac2.stop_io()
# Make sure that messages are not immediately auto-deleted on the server:
ac2.set_config("bcc_self", "1")
# Create the Trash folder on IMAP server and configure deletion to it. There was a bug that if
# Trash wasn't configured initially, it can't be configured later, let's check this.
log.section("Creating trash folder")
ac2_direct_imap = direct_imap(ac2)
ac2_direct_imap.create_folder("Trash")
ac2.set_config("delete_server_after", "0")
ac2.set_config("sync_msgs", "0")
ac2.set_config("delete_to_trash", "1")
log.section("Check that Trash can be configured initially as well")
ac3 = ac2.clone()
ac3.bring_online()
assert ac3.get_config("configured_trash_folder")
ac3.stop_io()
ac2.start_io()
chat12 = acf.get_accepted_chat(ac1, ac2)
chat12 = acfactory.get_accepted_chat(ac1, ac2)
log.section("ac1: sending 3 messages")
texts = ["first", "second", "third"]
@@ -115,15 +520,17 @@ def test_trash_multiple_messages(acf, direct_imap, log):
assert msg.text in texts
if text != "second":
to_delete.append(msg)
# ac2 has received some messages, this is impossible w/o the trash folder configured, let's
# check the configuration.
assert ac2.get_config("configured_trash_folder") == "Trash"
log.section("ac2: deleting all messages except second")
assert len(to_delete) == len(texts) - 1
ac2.delete_messages(to_delete)
log.section("ac2: test that only one message is left")
ac2_direct_imap = direct_imap(ac2)
while 1:
ac2.wait_for_event(EventType.IMAP_MESSAGE_DELETED)
ac2.wait_for_event(EventType.IMAP_MESSAGE_MOVED)
ac2_direct_imap.select_config_folder("inbox")
nr_msgs = len(ac2_direct_imap.get_all_messages())
assert nr_msgs > 0

View File

@@ -7,25 +7,15 @@ If you want to debug iroh at rust-trace/log level set
RUST_LOG=iroh_net=trace,iroh_gossip=trace
"""
import itertools
import logging
import os
import threading
from contextlib import contextmanager
import time
import pytest
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
def path_to_webxdc(request):
@@ -34,22 +24,11 @@ def path_to_webxdc(request):
return str(p)
@pytest.fixture
def path_to_large_webxdc(request):
p = request.path.parent.parent.parent.joinpath("test-data/webxdc/realtime-check.xdc")
assert p.exists()
return str(p)
def log(msg):
logging.info(msg)
# payload used to probe/establish realtime connectivity, filtered out by tests
SETUP_DATA = b"realtime-setup"
def setup_realtime_webxdc(ac1, ac2, path_to_webxdc, wait=True):
def setup_realtime_webxdc(ac1, ac2, path_to_webxdc):
assert ac1.get_config("webxdc_realtime_enabled") == "1"
assert ac2.get_config("webxdc_realtime_enabled") == "1"
ac1_ac2_chat = ac1.create_chat(ac2)
@@ -66,44 +45,45 @@ def setup_realtime_webxdc(ac1, ac2, path_to_webxdc, wait=True):
log("sending ac2 -> ac1 realtime advertisement and additional message")
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
@contextmanager
def send_realtime_data_forever(msgs, data=None):
stop = threading.Event()
data = data or [SETUP_DATA] * len(msgs)
def setup_thread_send_realtime_data(msg, data):
def thread_run():
for _i in range(10):
msg.send_webxdc_realtime_data(data)
time.sleep(1)
def thread_run(msg, payload):
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()
threading.Thread(target=thread_run, daemon=True).start()
def wait_realtime_connected(msg_pairs):
with send_realtime_data_forever([sender for sender, _ in msg_pairs]):
for _, receiver in msg_pairs:
receiver.account.wait_for_realtime_data(receiver.id)
def wait_receive_realtime_data(msg_data_list):
account = msg_data_list[0][0].account
msg_data_list = msg_data_list[:]
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."""
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)
ac2.create_chat(ac1)
# 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()
snapshot = ac2_webxdc_msg.get_snapshot()
assert snapshot.text == "play"
@@ -111,7 +91,7 @@ def test_realtime_sequentially(acf, path_to_webxdc):
# send iroh announcements sequentially
log("sending ac1 -> ac2 realtime advertisement and additional message")
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")
snapshot = ac2.wait_for_incoming_msg().get_snapshot()
@@ -119,7 +99,7 @@ def test_realtime_sequentially(acf, path_to_webxdc):
log("sending ac2 -> ac1 realtime advertisement and additional message")
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")
snapshot = ac1.wait_for_incoming_msg().get_snapshot()
@@ -130,27 +110,50 @@ def test_realtime_sequentially(acf, path_to_webxdc):
data = os.urandom(128000)
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."""
ac1, ac2 = acf.get_online_accounts(2)
setup_realtime_webxdc(ac1, ac2, path_to_webxdc)
ac1, ac2 = acfactory.get_online_accounts(2)
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."""
ac1, ac2 = acf.get_online_accounts(2)
ac1_webxdc_msg, ac2_webxdc_msg = setup_realtime_webxdc(ac1, ac2, path_to_webxdc, wait=False)
ac1_webxdc_msg2, ac2_webxdc_msg2 = setup_realtime_webxdc(ac1, ac2, path_to_webxdc, wait=False)
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, ac2 = acfactory.get_online_accounts(2)
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)
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."""
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_webxdc_msg = ac1_ac2_chat.send_message(text="webxdc", file=path_to_webxdc)
@@ -164,29 +167,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_advertisement()
with send_realtime_data_forever([ac1_webxdc_msg], data=[lambda i: str(i).encode()]):
n = int(ac2.wait_for_realtime_data(ac2_webxdc_msg.id).decode())
assert int(ac2.wait_for_realtime_data(ac2_webxdc_msg.id).decode()) > n
def thread_run():
for i in range(10):
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."""
ac1, ac2 = acf.get_online_accounts(2)
ac1_webxdc_msg, ac2_webxdc_msg = setup_realtime_webxdc(ac1, ac2, path_to_webxdc, wait=True)
ac1, ac2 = acfactory.get_online_accounts(2)
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):
ac1_webxdc_msg.send_webxdc_realtime_data([i])
for i in range(200):
# lingering SETUP_DATA payloads from the wait_realtime_connected() barrier may still arrive
while (data := ac2.wait_for_realtime_data(ac2_webxdc_msg.id)) == SETUP_DATA:
pass
assert data == bytes([i]), "Reordering detected"
while 1:
event = ac2.wait_for_event()
if event.kind == EventType.WEBXDC_REALTIME_DATA and bytes(event.data) != b"hello":
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."""
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_webxdc_msg = ac1_ac2_chat.send_message(text="WebXDC", file=path_to_webxdc)
ac2_webxdc_msg = ac2.wait_for_incoming_msg()
@@ -203,26 +227,3 @@ def test_advertisement_after_chatting(acf, path_to_webxdc):
ac2_webxdc_msg.send_webxdc_realtime_advertisement()
event = ac1.wait_for_event(EventType.WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED)
assert event.msg_id == ac1_webxdc_msg.id
def test_realtime_large_webxdc(acf, path_to_large_webxdc):
"""Tests initializing realtime channel on a large webxdc.
This is a regression test for a bug that existed in version 2.42.0.
Large webxdc is split into pre- and post- message,
and this previously resulted in failure to initialize realtime.
"""
ac1, ac2 = acf.get_online_accounts(2)
ac2.create_chat(ac1)
ac1_ac2_chat = ac1.create_chat(ac2)
ac1_webxdc_msg = ac1_ac2_chat.send_message(text="realtime check", file=path_to_large_webxdc)
# Receive pre-message.
ac2_webxdc_msg = ac2.wait_for_incoming_msg()
# Receive post-message.
ac2_webxdc_msg = ac2.wait_for_msg(EventType.MSGS_CHANGED)
ac2_webxdc_msg.send_webxdc_realtime_advertisement()
event = ac1.wait_for_event(EventType.WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED)
assert event.msg_id == ac1_webxdc_msg.id

View File

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

View File

@@ -1,32 +0,0 @@
def test_set_location(dc, acf) -> None:
# Try setting location without any accounts.
assert not dc.set_location(1.0, 2.0, 0.1)
# Create one account that does not stream,
# set location.
acf.new_configured_account()
assert not dc.set_location(3.0, 4.0, 0.1)
def test_send_locations_to_chat(dc, acf):
alice, bob = acf.get_online_accounts(2)
assert not alice.is_sending_locations()
alice_chat_bob = alice.create_chat(bob)
assert not alice_chat_bob.is_sending_locations()
# Test starting and stopping location streaming in a chat.
alice_chat_bob.send_locations(3600)
assert alice.is_sending_locations()
assert alice_chat_bob.is_sending_locations()
alice_chat_bob.send_locations(0)
assert not alice.is_sending_locations()
assert not alice_chat_bob.is_sending_locations()
# Test stop_sending_locations() for all accounts and chats.
alice_chat_bob.send_locations(3600)
assert alice.is_sending_locations()
assert alice_chat_bob.is_sending_locations()
dc.stop_sending_locations()
assert not alice.is_sending_locations()
assert not alice_chat_bob.is_sending_locations()

View File

@@ -4,47 +4,62 @@ from deltachat_rpc_client import EventType
from deltachat_rpc_client.const import MessageState
def test_bcc_self_is_enabled_when_setting_up_second_device(acf):
ac = acf.get_online_account()
def test_bcc_self_delete_server_after_defaults(acfactory):
"""Test default values for bcc_self and delete_server_after."""
ac = acfactory.get_online_account()
# Initially after getting online
# the setting bcc_self is set to 0 because there is only one device
# and delete_server_after is "1", meaning immediate deletion.
assert ac.get_config("bcc_self") == "0"
assert ac.get_config("delete_server_after") == "1"
# Setup a second device.
ac_clone = ac.clone()
ac_clone.bring_online()
# Second device setup enables bcc_self.
# Second device setup
# enables bcc_self and changes default delete_server_after.
assert ac.get_config("bcc_self") == "1"
assert ac_clone.get_config("bcc_self") == "1"
assert ac.get_config("delete_server_after") == "0"
# Test manually disabling bcc_self
assert ac_clone.get_config("bcc_self") == "1"
assert ac_clone.get_config("delete_server_after") == "0"
# Manually disabling bcc_self
# also restores the default for delete_server_after.
ac.set_config("bcc_self", "0")
assert ac.get_config("bcc_self") == "0"
assert ac.get_config("delete_server_after") == "1"
# Cloning the account again enables bcc_self again
# Cloning the account again enables bcc_self
# even though it was manually disabled.
ac_clone = ac.clone()
assert ac.get_config("bcc_self") == "1"
assert ac.get_config("delete_server_after") == "0"
def test_one_account_send_bcc_setting(acf, log, direct_imap):
ac1, ac2 = acf.get_online_accounts(2)
def test_one_account_send_bcc_setting(acfactory, log, direct_imap):
ac1, ac2 = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone()
ac1_clone.bring_online()
log.section("send out message without bcc to ourselves")
ac1.set_config("bcc_self", "0")
chat = ac1.create_chat(ac2)
self_addr = ac1.get_config("addr")
other_addr = ac2.get_config("addr")
msg_out = chat.send_text("message1")
assert not msg_out.get_snapshot().is_forwarded
# 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 self_addr not in ev.msg
assert other_addr in ev.msg
log.section("ac1: setting bcc_self=1")
ac1.set_config("bcc_self", "1")
@@ -52,16 +67,20 @@ def test_one_account_send_bcc_setting(acf, log, direct_imap):
msg_out = chat.send_text("message2")
# 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"
# 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)
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)
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.
while True:
event = ac1.wait_for_event()
@@ -75,9 +94,9 @@ def test_one_account_send_bcc_setting(acf, log, direct_imap):
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."""
ac1, ac2 = acf.get_online_accounts(2)
ac1, ac2 = acfactory.get_online_accounts(2)
ac1_clone = ac1.clone()
ac1_clone.bring_online()
@@ -127,34 +146,3 @@ def test_multidevice_sync_seen(acf, log):
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.
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

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