Compare commits

..

6 Commits

Author SHA1 Message Date
dignifiedquire
170007e645 happy deny 2024-12-17 10:29:41 +01:00
dignifiedquire
bdea8551d1 update to released version of gossip 2024-12-17 10:26:18 +01:00
dignifiedquire
478ca73c6f update deny.toml 2024-12-16 22:32:51 +01:00
dignifiedquire
b65949d643 Merge remote-tracking branch 'origin/main' into iroh-v0-29-0 2024-12-16 22:32:00 +01:00
dignifiedquire
a908f376a2 update to iroh@0.30 2024-12-16 22:29:10 +01:00
dignifiedquire
34439085fd feat: upgrade to iroh@0.29.0
- iroh-net -> iroh
- iroh-gossip uses hex by default, use base32 manually to keep backwards compat
- use the new `iroh::protocol::Router` to manage the gossip integration
2024-12-09 17:47:42 +00:00
719 changed files with 64626 additions and 67749 deletions

View File

@@ -1,35 +0,0 @@
---
name: Bug report
about: Report something that isn't working.
title: ''
assignees: ''
labels: bug
---
<!--
This is the chatmail core's bug report tracker.
For Delta Chat feature requests and support, please go to the forum: https://support.delta.chat
Please fill out as much of this form as you can (leaving out stuff that is not applicable is ok).
-->
- Operating System (Linux/Mac/Windows/iOS/Android):
- Core Version:
- Client Version:
## Expected behavior
*What did you try to achieve?*
## Actual behavior
*What happened instead?*
### Steps to reproduce the problem
1.
2.
### Screenshots
### Logs

View File

@@ -7,8 +7,6 @@ updates:
commit-message: commit-message:
prefix: "chore(cargo)" prefix: "chore(cargo)"
open-pull-requests-limit: 50 open-pull-requests-limit: 50
cooldown:
default-days: 7
# Keep GitHub Actions up to date. # Keep GitHub Actions up to date.
# <https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot> # <https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot>
@@ -16,5 +14,3 @@ updates:
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
cooldown:
default-days: 7

View File

@@ -16,34 +16,23 @@ on:
branches: branches:
- main - main
permissions: {}
env: env:
RUSTFLAGS: -Dwarnings RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.95.0
# Minimum Supported Rust Version
MSRV: 1.89.0
jobs: jobs:
lint_rust: lint_rust:
name: Lint Rust name: Lint Rust
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 env:
RUSTUP_TOOLCHAIN: 1.83.0
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
- name: Install rustfmt and clippy - name: Install rustfmt and clippy
run: rustup toolchain install $RUST_VERSION --profile minimal --component rustfmt --component clippy run: rustup toolchain install $RUSTUP_TOOLCHAIN --profile minimal --component rustfmt --component clippy
- run: rustup override set $RUST_VERSION
shell: bash
- name: Cache rust cargo artifacts - name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 uses: swatinem/rust-cache@v2
with:
save-if: false
add-rust-environment-hash-key: false
- name: Run rustfmt - name: Run rustfmt
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
- name: Run clippy - name: Run clippy
@@ -53,55 +42,52 @@ jobs:
- name: Check with only default features - name: Check with only default features
run: cargo check --all-targets run: cargo check --all-targets
npm_constants:
name: Check if node constants are up to date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- name: Rebuild constants
run: npm run build:core:constants
- name: Check that constants are not changed
run: git diff --exit-code
cargo_deny: cargo_deny:
name: cargo deny name: cargo deny
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false - uses: EmbarkStudios/cargo-deny-action@v2
- uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb
with: with:
arguments: --workspace --all-features --locked arguments: --all-features --workspace
command: check command: check
command-arguments: "-Dwarnings" command-arguments: "-Dwarnings"
provider_database: provider_database:
name: Check provider database name: Check provider database
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false 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 - name: Check provider database
run: scripts/update-provider-database.sh run: scripts/update-provider-database.sh
docs: docs:
name: Rust doc comments name: Rust doc comments
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
env: env:
RUSTDOCFLAGS: -Dwarnings RUSTDOCFLAGS: -Dwarnings
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
- run: rustup override set $RUST_VERSION
shell: bash
- name: Cache rust cargo artifacts - name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 uses: swatinem/rust-cache@v2
with:
save-if: false
add-rust-environment-hash-key: false
- name: Rustdoc - name: Rustdoc
run: cargo doc --document-private-items --no-deps run: cargo doc --document-private-items --no-deps
@@ -111,64 +97,42 @@ jobs:
matrix: matrix:
include: include:
- os: ubuntu-latest - os: ubuntu-latest
rust: latest rust: 1.83.0
- os: windows-latest - os: windows-latest
rust: latest rust: 1.83.0
- os: macos-latest - os: macos-latest
rust: latest rust: 1.83.0
# Minimum Supported Rust Version # Minimum Supported Rust Version = 1.81.0
- os: ubuntu-latest - os: ubuntu-latest
rust: minimum rust: 1.81.0
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps: steps:
- run: - uses: actions/checkout@v4
echo "RUSTUP_TOOLCHAIN=$MSRV" >> $GITHUB_ENV
shell: bash
if: matrix.rust == 'minimum'
- run:
echo "RUSTUP_TOOLCHAIN=$RUST_VERSION" >> $GITHUB_ENV
shell: bash
if: matrix.rust == 'latest'
- uses: actions/checkout@v6
with: with:
show-progress: false show-progress: false
persist-credentials: false
- name: Install Rust ${{ matrix.rust }} - name: Install Rust ${{ matrix.rust }}
run: rustup toolchain install --profile minimal $RUSTUP_TOOLCHAIN run: rustup toolchain install --profile minimal ${{ matrix.rust }}
shell: bash - run: rustup override set ${{ matrix.rust }}
- run: rustup override set $RUSTUP_TOOLCHAIN
shell: bash
- name: Cache rust cargo artifacts - name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 uses: swatinem/rust-cache@v2
with:
# Only save the cache from the main branch runs.
# No need for PRs to write to the cache.
##save-if: ${{ github.ref == 'refs/heads/main' }}
# Do not hash Cargo.lock.
# We want the cache to be used later in PRs
# even if it updates some dependency.
add-rust-environment-hash-key: false
- name: Install nextest - name: Install nextest
uses: taiki-e/install-action@5f57d6cb7cd20b14a8a27f522884c4bc8a187458 uses: taiki-e/install-action@v2
with: with:
tool: nextest tool: nextest
- name: Tests - name: Tests
env: env:
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
run: cargo nextest run --workspace --locked run: cargo nextest run --workspace
- name: Doc-Tests - name: Doc-Tests
env: env:
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
run: cargo test --workspace --locked --doc run: cargo test --workspace --doc
- name: Test cargo vendor - name: Test cargo vendor
run: cargo vendor run: cargo vendor
@@ -179,27 +143,19 @@ jobs:
matrix: matrix:
os: [ubuntu-latest, macos-latest] os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
- run: rustup override set $RUST_VERSION
shell: bash
- name: Cache rust cargo artifacts - name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 uses: swatinem/rust-cache@v2
with:
save-if: false
add-rust-environment-hash-key: false
- name: Build C library - name: Build C library
run: cargo build -p deltachat_ffi run: cargo build -p deltachat_ffi --features jsonrpc
- name: Upload C library - name: Upload C library
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.os }}-libdeltachat.a name: ${{ matrix.os }}-libdeltachat.a
path: target/debug/libdeltachat.a path: target/debug/libdeltachat.a
@@ -211,27 +167,19 @@ jobs:
matrix: matrix:
os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
- run: rustup override set $RUST_VERSION
shell: bash
- name: Cache rust cargo artifacts - name: Cache rust cargo artifacts
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 uses: swatinem/rust-cache@v2
with:
save-if: false
add-rust-environment-hash-key: false
- name: Build deltachat-rpc-server - name: Build deltachat-rpc-server
run: cargo build -p deltachat-rpc-server run: cargo build -p deltachat-rpc-server
- name: Upload deltachat-rpc-server - name: Upload deltachat-rpc-server
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.os }}-deltachat-rpc-server name: ${{ matrix.os }}-deltachat-rpc-server
path: ${{ matrix.os == 'windows-latest' && 'target/debug/deltachat-rpc-server.exe' || 'target/debug/deltachat-rpc-server' }} path: ${{ matrix.os == 'windows-latest' && 'target/debug/deltachat-rpc-server.exe' || 'target/debug/deltachat-rpc-server' }}
@@ -240,12 +188,10 @@ jobs:
python_lint: python_lint:
name: Python lint name: Python lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
- name: Install tox - name: Install tox
run: pip install tox run: pip install tox
@@ -258,38 +204,6 @@ jobs:
working-directory: deltachat-rpc-client working-directory: deltachat-rpc-client
run: tox -e lint run: tox -e lint
# mypy does not work with PyPy since mypy 1.19
# as it introduced native `librt` dependency
# that uses CPython internals.
# We only run mypy with CPython because of this.
cffi_python_mypy:
name: CFFI Python mypy
needs: ["c_library", "python_lint"]
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- name: Download libdeltachat.a
uses: actions/download-artifact@v7
with:
name: ubuntu-latest-libdeltachat.a
path: target/debug
- name: Install tox
run: pip install tox
- name: Run mypy
env:
DCC_RS_TARGET: debug
DCC_RS_DEV: ${{ github.workspace }}
working-directory: python
run: tox -e mypy
cffi_python_tests: cffi_python_tests:
name: CFFI Python tests name: CFFI Python tests
needs: ["c_library", "python_lint"] needs: ["c_library", "python_lint"]
@@ -299,9 +213,9 @@ jobs:
include: include:
# Currently used Rust version. # Currently used Rust version.
- os: ubuntu-latest - os: ubuntu-latest
python: 3.14 python: 3.13
- os: macos-latest - os: macos-latest
python: 3.14 python: 3.13
# PyPy tests # PyPy tests
- os: ubuntu-latest - os: ubuntu-latest
@@ -309,28 +223,26 @@ jobs:
- os: macos-latest - os: macos-latest
python: pypy3.10 python: pypy3.10
# Minimum Supported Python Version = 3.10 # Minimum Supported Python Version = 3.7
# This is the minimum version for which manylinux Python wheels are # This is the minimum version for which manylinux Python wheels are
# built. Test it with minimum supported Rust version. # built. Test it with minimum supported Rust version.
- os: ubuntu-latest - os: ubuntu-latest
python: "3.10" python: 3.7
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
- name: Download libdeltachat.a - name: Download libdeltachat.a
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: ${{ matrix.os }}-libdeltachat.a name: ${{ matrix.os }}-libdeltachat.a
path: target/debug path: target/debug
- name: Install python - name: Install python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python }} python-version: ${{ matrix.python }}
@@ -343,7 +255,7 @@ jobs:
DCC_RS_TARGET: debug DCC_RS_TARGET: debug
DCC_RS_DEV: ${{ github.workspace }} DCC_RS_DEV: ${{ github.workspace }}
working-directory: python working-directory: python
run: tox -e doc,py run: tox -e mypy,doc,py
rpc_python_tests: rpc_python_tests:
name: JSON-RPC Python tests name: JSON-RPC Python tests
@@ -353,11 +265,11 @@ jobs:
matrix: matrix:
include: include:
- os: ubuntu-latest - os: ubuntu-latest
python: 3.14 python: 3.13
- os: macos-latest - os: macos-latest
python: 3.14 python: 3.13
- os: windows-latest - os: windows-latest
python: 3.14 python: 3.13
# PyPy tests # PyPy tests
- os: ubuntu-latest - os: ubuntu-latest
@@ -365,20 +277,18 @@ jobs:
- os: macos-latest - os: macos-latest
python: pypy3.10 python: pypy3.10
# Minimum Supported Python Version = 3.10 # Minimum Supported Python Version = 3.7
- os: ubuntu-latest - os: ubuntu-latest
python: "3.10" python: 3.7
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
- name: Install python - name: Install python
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python }} python-version: ${{ matrix.python }}
@@ -386,7 +296,7 @@ jobs:
run: pip install tox run: pip install tox
- name: Download deltachat-rpc-server - name: Download deltachat-rpc-server
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: ${{ matrix.os }}-deltachat-rpc-server name: ${{ matrix.os }}-deltachat-rpc-server
path: target/debug path: target/debug

View File

@@ -17,8 +17,6 @@ on:
release: release:
types: [published] types: [published]
permissions: {}
jobs: jobs:
# Build a version statically linked against musl libc # Build a version statically linked against musl libc
# to avoid problems with glibc version incompatibility. # to avoid problems with glibc version incompatibility.
@@ -30,46 +28,22 @@ jobs:
arch: [aarch64, armv7l, armv6l, i686, x86_64] arch: [aarch64, armv7l, armv6l, i686, x86_64]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false - uses: DeterminateSystems/nix-installer-action@main
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5 - uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build deltachat-rpc-server binaries - name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
- name: Upload binary - name: Upload binary
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: deltachat-rpc-server-${{ matrix.arch }}-linux name: deltachat-rpc-server-${{ matrix.arch }}-linux
path: result/bin/deltachat-rpc-server path: result/bin/deltachat-rpc-server
if-no-files-found: error if-no-files-found: error
build_linux_wheel:
name: Linux wheel
strategy:
fail-fast: false
matrix:
arch: [aarch64, armv7l, armv6l, i686, x86_64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
- name: Upload wheel
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-linux-wheel
path: result/*.whl
if-no-files-found: error
build_windows: build_windows:
name: Windows name: Windows
strategy: strategy:
@@ -78,46 +52,22 @@ jobs:
arch: [win32, win64] arch: [win32, win64]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false - uses: DeterminateSystems/nix-installer-action@main
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5 - uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build deltachat-rpc-server binaries - name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }} run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
- name: Upload binary - name: Upload binary
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: deltachat-rpc-server-${{ matrix.arch }} name: deltachat-rpc-server-${{ matrix.arch }}
path: result/bin/deltachat-rpc-server.exe path: result/bin/deltachat-rpc-server.exe
if-no-files-found: error if-no-files-found: error
build_windows_wheel:
name: Windows wheel
strategy:
fail-fast: false
matrix:
arch: [win32, win64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-wheel
- name: Upload wheel
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-wheel
path: result/*.whl
if-no-files-found: error
build_macos: build_macos:
name: macOS name: macOS
strategy: strategy:
@@ -127,10 +77,9 @@ jobs:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
- name: Setup rust target - name: Setup rust target
run: rustup target add ${{ matrix.arch }}-apple-darwin run: rustup target add ${{ matrix.arch }}-apple-darwin
@@ -139,7 +88,7 @@ jobs:
run: cargo build --release --package deltachat-rpc-server --target ${{ matrix.arch }}-apple-darwin --features vendored run: cargo build --release --package deltachat-rpc-server --target ${{ matrix.arch }}-apple-darwin --features vendored
- name: Upload binary - name: Upload binary
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: deltachat-rpc-server-${{ matrix.arch }}-macos name: deltachat-rpc-server-${{ matrix.arch }}-macos
path: target/${{ matrix.arch }}-apple-darwin/release/deltachat-rpc-server path: target/${{ matrix.arch }}-apple-darwin/release/deltachat-rpc-server
@@ -153,49 +102,25 @@ jobs:
arch: [arm64-v8a, armeabi-v7a] arch: [arm64-v8a, armeabi-v7a]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false - uses: DeterminateSystems/nix-installer-action@main
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5 - uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build deltachat-rpc-server binaries - name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
- name: Upload binary - name: Upload binary
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: deltachat-rpc-server-${{ matrix.arch }}-android name: deltachat-rpc-server-${{ matrix.arch }}-android
path: result/bin/deltachat-rpc-server path: result/bin/deltachat-rpc-server
if-no-files-found: error if-no-files-found: error
build_android_wheel:
name: Android wheel
strategy:
fail-fast: false
matrix:
arch: [arm64-v8a, armeabi-v7a]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
show-progress: false
persist-credentials: false
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5
- name: Build deltachat-rpc-server wheels
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android-wheel
- name: Upload wheel
uses: actions/upload-artifact@v7
with:
name: deltachat-rpc-server-${{ matrix.arch }}-android-wheel
path: result/*.whl
if-no-files-found: error
publish: publish:
name: Build wheels and upload binaries to the release name: Build wheels and upload binaries to the release
needs: ["build_linux", "build_linux_wheel", "build_windows", "build_windows_wheel", "build_macos", "build_android", "build_android_wheel"] needs: ["build_linux", "build_windows", "build_macos"]
environment: environment:
name: pypi name: pypi
url: https://pypi.org/p/deltachat-rpc-server url: https://pypi.org/p/deltachat-rpc-server
@@ -204,132 +129,78 @@ jobs:
contents: write contents: write
runs-on: "ubuntu-latest" runs-on: "ubuntu-latest"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false - uses: DeterminateSystems/nix-installer-action@main
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5 - uses: DeterminateSystems/magic-nix-cache-action@main
- name: Download Linux aarch64 binary - name: Download Linux aarch64 binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-aarch64-linux name: deltachat-rpc-server-aarch64-linux
path: deltachat-rpc-server-aarch64-linux.d path: deltachat-rpc-server-aarch64-linux.d
- name: Download Linux aarch64 wheel
uses: actions/download-artifact@v7
with:
name: deltachat-rpc-server-aarch64-linux-wheel
path: deltachat-rpc-server-aarch64-linux-wheel.d
- name: Download Linux armv7l binary - name: Download Linux armv7l binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-armv7l-linux name: deltachat-rpc-server-armv7l-linux
path: deltachat-rpc-server-armv7l-linux.d path: deltachat-rpc-server-armv7l-linux.d
- name: Download Linux armv7l wheel
uses: actions/download-artifact@v7
with:
name: deltachat-rpc-server-armv7l-linux-wheel
path: deltachat-rpc-server-armv7l-linux-wheel.d
- name: Download Linux armv6l binary - name: Download Linux armv6l binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-armv6l-linux name: deltachat-rpc-server-armv6l-linux
path: deltachat-rpc-server-armv6l-linux.d path: deltachat-rpc-server-armv6l-linux.d
- name: Download Linux armv6l wheel
uses: actions/download-artifact@v7
with:
name: deltachat-rpc-server-armv6l-linux-wheel
path: deltachat-rpc-server-armv6l-linux-wheel.d
- name: Download Linux i686 binary - name: Download Linux i686 binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-i686-linux name: deltachat-rpc-server-i686-linux
path: deltachat-rpc-server-i686-linux.d path: deltachat-rpc-server-i686-linux.d
- name: Download Linux i686 wheel
uses: actions/download-artifact@v7
with:
name: deltachat-rpc-server-i686-linux-wheel
path: deltachat-rpc-server-i686-linux-wheel.d
- name: Download Linux x86_64 binary - name: Download Linux x86_64 binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-x86_64-linux name: deltachat-rpc-server-x86_64-linux
path: deltachat-rpc-server-x86_64-linux.d path: deltachat-rpc-server-x86_64-linux.d
- name: Download Linux x86_64 wheel
uses: actions/download-artifact@v7
with:
name: deltachat-rpc-server-x86_64-linux-wheel
path: deltachat-rpc-server-x86_64-linux-wheel.d
- name: Download Win32 binary - name: Download Win32 binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-win32 name: deltachat-rpc-server-win32
path: deltachat-rpc-server-win32.d path: deltachat-rpc-server-win32.d
- name: Download Win32 wheel
uses: actions/download-artifact@v7
with:
name: deltachat-rpc-server-win32-wheel
path: deltachat-rpc-server-win32-wheel.d
- name: Download Win64 binary - name: Download Win64 binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-win64 name: deltachat-rpc-server-win64
path: deltachat-rpc-server-win64.d path: deltachat-rpc-server-win64.d
- name: Download Win64 wheel
uses: actions/download-artifact@v7
with:
name: deltachat-rpc-server-win64-wheel
path: deltachat-rpc-server-win64-wheel.d
- name: Download macOS binary for x86_64 - name: Download macOS binary for x86_64
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-x86_64-macos name: deltachat-rpc-server-x86_64-macos
path: deltachat-rpc-server-x86_64-macos.d path: deltachat-rpc-server-x86_64-macos.d
- name: Download macOS binary for aarch64 - name: Download macOS binary for aarch64
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-aarch64-macos name: deltachat-rpc-server-aarch64-macos
path: deltachat-rpc-server-aarch64-macos.d path: deltachat-rpc-server-aarch64-macos.d
- name: Download Android binary for arm64-v8a - name: Download Android binary for arm64-v8a
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-arm64-v8a-android name: deltachat-rpc-server-arm64-v8a-android
path: deltachat-rpc-server-arm64-v8a-android.d path: deltachat-rpc-server-arm64-v8a-android.d
- name: Download Android wheel for arm64-v8a
uses: actions/download-artifact@v7
with:
name: deltachat-rpc-server-arm64-v8a-android-wheel
path: deltachat-rpc-server-arm64-v8a-android-wheel.d
- name: Download Android binary for armeabi-v7a - name: Download Android binary for armeabi-v7a
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-armeabi-v7a-android name: deltachat-rpc-server-armeabi-v7a-android
path: deltachat-rpc-server-armeabi-v7a-android.d path: deltachat-rpc-server-armeabi-v7a-android.d
- name: Download Android wheel for armeabi-v7a
uses: actions/download-artifact@v7
with:
name: deltachat-rpc-server-armeabi-v7a-android-wheel
path: deltachat-rpc-server-armeabi-v7a-android-wheel.d
- name: Create bin/ directory - name: Create bin/ directory
run: | run: |
mkdir -p bin mkdir -p bin
@@ -348,21 +219,34 @@ jobs:
- name: List binaries - name: List binaries
run: ls -l bin/ run: ls -l bin/
# Python 3.11 is needed for tomllib used in scripts/wheel-rpc-server.py
- name: Install python 3.12
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Install wheel - name: Install wheel
run: pip install wheel run: pip install wheel
- name: Build deltachat-rpc-server Python wheels - name: Build deltachat-rpc-server Python wheels and source package
run: | run: |
mkdir -p dist mkdir -p dist
mv deltachat-rpc-server-aarch64-linux-wheel.d/*.whl dist/ nix build .#deltachat-rpc-server-x86_64-linux-wheel
mv deltachat-rpc-server-armv7l-linux-wheel.d/*.whl dist/ cp result/*.whl dist/
mv deltachat-rpc-server-armv6l-linux-wheel.d/*.whl dist/ nix build .#deltachat-rpc-server-armv7l-linux-wheel
mv deltachat-rpc-server-i686-linux-wheel.d/*.whl dist/ cp result/*.whl dist/
mv deltachat-rpc-server-x86_64-linux-wheel.d/*.whl dist/ nix build .#deltachat-rpc-server-armv6l-linux-wheel
mv deltachat-rpc-server-win64-wheel.d/*.whl dist/ cp result/*.whl dist/
mv deltachat-rpc-server-win32-wheel.d/*.whl dist/ nix build .#deltachat-rpc-server-aarch64-linux-wheel
mv deltachat-rpc-server-arm64-v8a-android-wheel.d/*.whl dist/ cp result/*.whl dist/
mv deltachat-rpc-server-armeabi-v7a-android-wheel.d/*.whl dist/ nix build .#deltachat-rpc-server-i686-linux-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-win64-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-win32-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-source
cp result/*.tar.gz dist/
python3 scripts/wheel-rpc-server.py x86_64-darwin bin/deltachat-rpc-server-x86_64-macos python3 scripts/wheel-rpc-server.py x86_64-darwin bin/deltachat-rpc-server-x86_64-macos
python3 scripts/wheel-rpc-server.py aarch64-darwin bin/deltachat-rpc-server-aarch64-macos python3 scripts/wheel-rpc-server.py aarch64-darwin bin/deltachat-rpc-server-aarch64-macos
mv *.whl dist/ mv *.whl dist/
@@ -374,99 +258,94 @@ jobs:
if: github.event_name == 'release' if: github.event_name == 'release'
env: env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
REF_NAME: ${{ github.ref_name }}
run: | run: |
gh release upload "$REF_NAME" \ gh release upload ${{ github.ref_name }} \
--repo ${{ github.repository }} \ --repo ${{ github.repository }} \
bin/* dist/* bin/* dist/*
- name: Publish deltachat-rpc-server to PyPI - name: Publish deltachat-rpc-client to PyPI
if: github.event_name == 'release' if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b uses: pypa/gh-action-pypi-publish@release/v1
publish_npm_package: publish_npm_package:
name: Build & Publish npm prebuilds and deltachat-rpc-server name: Build & Publish npm prebuilds and deltachat-rpc-server
needs: ["build_linux", "build_windows", "build_macos"] needs: ["build_linux", "build_windows", "build_macos"]
runs-on: "ubuntu-latest" runs-on: "ubuntu-latest"
environment:
name: npm-stdio-rpc-server
url: https://www.npmjs.com/package/@deltachat/stdio-rpc-server
permissions: permissions:
id-token: write id-token: write
# Needed to publish the binaries to the release. # Needed to publish the binaries to the release.
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false - uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with: with:
python-version: "3.11" python-version: "3.11"
- name: Download Linux aarch64 binary - name: Download Linux aarch64 binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-aarch64-linux name: deltachat-rpc-server-aarch64-linux
path: deltachat-rpc-server-aarch64-linux.d path: deltachat-rpc-server-aarch64-linux.d
- name: Download Linux armv7l binary - name: Download Linux armv7l binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-armv7l-linux name: deltachat-rpc-server-armv7l-linux
path: deltachat-rpc-server-armv7l-linux.d path: deltachat-rpc-server-armv7l-linux.d
- name: Download Linux armv6l binary - name: Download Linux armv6l binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-armv6l-linux name: deltachat-rpc-server-armv6l-linux
path: deltachat-rpc-server-armv6l-linux.d path: deltachat-rpc-server-armv6l-linux.d
- name: Download Linux i686 binary - name: Download Linux i686 binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-i686-linux name: deltachat-rpc-server-i686-linux
path: deltachat-rpc-server-i686-linux.d path: deltachat-rpc-server-i686-linux.d
- name: Download Linux x86_64 binary - name: Download Linux x86_64 binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-x86_64-linux name: deltachat-rpc-server-x86_64-linux
path: deltachat-rpc-server-x86_64-linux.d path: deltachat-rpc-server-x86_64-linux.d
- name: Download Win32 binary - name: Download Win32 binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-win32 name: deltachat-rpc-server-win32
path: deltachat-rpc-server-win32.d path: deltachat-rpc-server-win32.d
- name: Download Win64 binary - name: Download Win64 binary
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-win64 name: deltachat-rpc-server-win64
path: deltachat-rpc-server-win64.d path: deltachat-rpc-server-win64.d
- name: Download macOS binary for x86_64 - name: Download macOS binary for x86_64
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-x86_64-macos name: deltachat-rpc-server-x86_64-macos
path: deltachat-rpc-server-x86_64-macos.d path: deltachat-rpc-server-x86_64-macos.d
- name: Download macOS binary for aarch64 - name: Download macOS binary for aarch64
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-aarch64-macos name: deltachat-rpc-server-aarch64-macos
path: deltachat-rpc-server-aarch64-macos.d path: deltachat-rpc-server-aarch64-macos.d
- name: Download Android binary for arm64-v8a - name: Download Android binary for arm64-v8a
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-arm64-v8a-android name: deltachat-rpc-server-arm64-v8a-android
path: deltachat-rpc-server-arm64-v8a-android.d path: deltachat-rpc-server-arm64-v8a-android.d
- name: Download Android binary for armeabi-v7a - name: Download Android binary for armeabi-v7a
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: deltachat-rpc-server-armeabi-v7a-android name: deltachat-rpc-server-armeabi-v7a-android
path: deltachat-rpc-server-armeabi-v7a-android.d path: deltachat-rpc-server-armeabi-v7a-android.d
@@ -496,7 +375,7 @@ jobs:
ls -lah ls -lah
- name: Upload to artifacts - name: Upload to artifacts
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: deltachat-rpc-server-npm-package name: deltachat-rpc-server-npm-package
path: deltachat-rpc-server/npm-package/*.tgz path: deltachat-rpc-server/npm-package/*.tgz
@@ -506,26 +385,22 @@ jobs:
if: github.event_name == 'release' if: github.event_name == 'release'
env: env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
REF_NAME: ${{ github.ref_name }}
run: | run: |
gh release upload "$REF_NAME" \ gh release upload ${{ github.ref_name }} \
--repo ${{ github.repository }} \ --repo ${{ github.repository }} \
deltachat-rpc-server/npm-package/*.tgz deltachat-rpc-server/npm-package/*.tgz
# Configure Node.js for publishing. # Configure Node.js for publishing.
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
# Ensure npm 11.5.1 or later is installed.
# It is needed for <https://docs.npmjs.com/trusted-publishers>
- name: Update npm
run: npm install -g npm@latest
- name: Publish npm packets for prebuilds and `@deltachat/stdio-rpc-server` - name: Publish npm packets for prebuilds and `@deltachat/stdio-rpc-server`
if: github.event_name == 'release' if: github.event_name == 'release'
working-directory: deltachat-rpc-server/npm-package working-directory: deltachat-rpc-server/npm-package
run: | run: |
ls -lah platform_package ls -lah platform_package
for platform in *.tgz; do npm publish --provenance "$platform" --access public; done for platform in *.tgz; do npm publish --provenance "$platform" --access public; done
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -10,11 +10,11 @@ permissions:
jobs: jobs:
dependabot: dependabot:
runs-on: ubuntu-latest 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: steps:
- name: Dependabot metadata - name: Dependabot metadata
id: metadata id: metadata
uses: dependabot/fetch-metadata@v3.0.0 uses: dependabot/fetch-metadata@v2.2.0
with: with:
github-token: "${{ secrets.GITHUB_TOKEN }}" github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Approve a PR - 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@v6
with:
show-progress: false
persist-credentials: false
- name: Run version-checking script
run: scripts/check-dev-version.py

View File

@@ -4,34 +4,23 @@ on:
release: release:
types: [published] types: [published]
permissions: {}
jobs: jobs:
pack-module: pack-module:
name: "Publish @deltachat/jsonrpc-client" name: "Publish @deltachat/jsonrpc-client"
runs-on: ubuntu-latest runs-on: ubuntu-20.04
environment:
name: npm-jsonrpc-client
url: https://www.npmjs.com/package/@deltachat/jsonrpc-client
permissions: permissions:
id-token: write id-token: write
contents: read contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
# Ensure npm 11.5.1 or later is installed.
# It is needed for <https://docs.npmjs.com/trusted-publishers>
- name: Update npm
run: npm install -g npm@latest
- name: Install dependencies without running scripts - name: Install dependencies without running scripts
working-directory: deltachat-jsonrpc/typescript working-directory: deltachat-jsonrpc/typescript
run: npm install --ignore-scripts run: npm install --ignore-scripts
@@ -45,3 +34,5 @@ jobs:
- name: Publish - name: Publish
working-directory: deltachat-jsonrpc/typescript working-directory: deltachat-jsonrpc/typescript
run: npm publish --provenance deltachat-jsonrpc-client-* --access public run: npm publish --provenance deltachat-jsonrpc-client-* --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -6,8 +6,6 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
permissions: {}
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUST_MIN_STACK: "8388608" RUST_MIN_STACK: "8388608"
@@ -16,16 +14,15 @@ jobs:
build_and_test: build_and_test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
- name: Use Node.js 18.x - name: Use Node.js 18.x
uses: actions/setup-node@v6 uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version: 18.x
- name: Add Rust cache - name: Add Rust cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 uses: Swatinem/rust-cache@v2
- name: npm install - name: npm install
working-directory: deltachat-jsonrpc/typescript working-directory: deltachat-jsonrpc/typescript
run: npm install run: npm install
@@ -37,6 +34,9 @@ jobs:
run: npm run test run: npm run test
env: env:
CHATMAIL_DOMAIN: ${{ vars.CHATMAIL_DOMAIN }} CHATMAIL_DOMAIN: ${{ vars.CHATMAIL_DOMAIN }}
- name: make sure websocket server version still builds
working-directory: deltachat-jsonrpc
run: cargo build --bin deltachat-jsonrpc-server --features webserver
- name: Run linter - name: Run linter
working-directory: deltachat-jsonrpc/typescript working-directory: deltachat-jsonrpc/typescript
run: npm run prettier:check run: npm run prettier:check

View File

@@ -5,28 +5,27 @@ on:
paths: paths:
- flake.nix - flake.nix
- flake.lock - flake.lock
- .github/workflows/nix.yml
push: push:
paths: paths:
- flake.nix - flake.nix
- flake.lock - flake.lock
- .github/workflows/nix.yml
branches: branches:
- main - main
permissions: {}
jobs: jobs:
format: format:
name: check flake formatting name: check flake formatting
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false - uses: DeterminateSystems/nix-installer-action@main
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5 - uses: DeterminateSystems/magic-nix-cache-action@main
- run: nix fmt flake.nix -- --check - run: nix fmt
# Check that formatting does not change anything.
- run: git diff --exit-code
build: build:
name: nix build name: nix build
@@ -54,9 +53,7 @@ jobs:
- deltachat-rpc-server-aarch64-linux - deltachat-rpc-server-aarch64-linux
- deltachat-rpc-server-aarch64-linux-wheel - deltachat-rpc-server-aarch64-linux-wheel
- deltachat-rpc-server-arm64-v8a-android - deltachat-rpc-server-arm64-v8a-android
- deltachat-rpc-server-arm64-v8a-android-wheel
- deltachat-rpc-server-armeabi-v7a-android - deltachat-rpc-server-armeabi-v7a-android
- deltachat-rpc-server-armeabi-v7a-android-wheel
- deltachat-rpc-server-armv6l-linux - deltachat-rpc-server-armv6l-linux
- deltachat-rpc-server-armv6l-linux-wheel - deltachat-rpc-server-armv6l-linux-wheel
- deltachat-rpc-server-armv7l-linux - deltachat-rpc-server-armv7l-linux
@@ -80,11 +77,11 @@ jobs:
#- deltachat-rpc-server-x86_64-android #- deltachat-rpc-server-x86_64-android
#- deltachat-rpc-server-x86-android #- deltachat-rpc-server-x86-android
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false - uses: DeterminateSystems/nix-installer-action@main
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5 - uses: DeterminateSystems/magic-nix-cache-action@main
- run: nix build .#${{ matrix.installable }} - run: nix build .#${{ matrix.installable }}
build-macos: build-macos:
@@ -94,16 +91,14 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
installable: installable:
- deltachat-rpc-server - deltachat-rpc-server-aarch64-darwin
- deltachat-rpc-server-x86_64-darwin
# Fails to build # Fails to bulid
# because of <https://github.com/NixOS/nixpkgs/issues/413910>. # - deltachat-rpc-server-x86_64-darwin
# - deltachat-rpc-server-aarch64-darwin
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false - uses: DeterminateSystems/nix-installer-action@main
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5 - uses: DeterminateSystems/magic-nix-cache-action@main
- run: nix build .#${{ matrix.installable }} - run: nix build .#${{ matrix.installable }}

41
.github/workflows/node-docs.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
# GitHub Actions workflow to build
# Node.js bindings documentation
# and upload it to the web server.
# Built documentation is available at <https://js.delta.chat/>
name: Generate & upload node.js documentation
on:
push:
branches:
- main
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- name: Use Node.js 18.x
uses: actions/setup-node@v4
with:
node-version: 18.x
- name: npm install and generate documentation
working-directory: node
run: |
npm i --ignore-scripts
npx typedoc
mv docs js
- name: Upload
uses: horochx/deploy-via-scp@1.1.0
with:
user: ${{ secrets.USERNAME }}
key: ${{ secrets.KEY }}
host: "delta.chat"
port: 22
local: "node/js"
remote: "/var/www/html/"

235
.github/workflows/node-package.yml vendored Normal file
View File

@@ -0,0 +1,235 @@
name: "node.js build"
on:
pull_request:
push:
tags:
- "*"
- "!py-*"
jobs:
prebuild:
name: Prebuild
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- uses: actions/setup-node@v4
with:
node-version: "18"
- name: System info
run: |
rustc -vV
rustup -vV
cargo -vV
npm --version
node --version
- name: Cache node modules
uses: actions/cache@v4
with:
path: |
${{ env.APPDATA }}/npm-cache
~/.npm
key: ${{ matrix.os }}-node-${{ hashFiles('**/package.json') }}
- name: Cache cargo index
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}-2
- name: Install dependencies & build
if: steps.cache.outputs.cache-hit != 'true'
working-directory: node
run: npm install --verbose
- name: Build Prebuild
working-directory: node
run: |
npm run prebuildify
tar -zcvf "${{ matrix.os }}.tar.gz" -C prebuilds .
- name: Upload Prebuild
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}
path: node/${{ matrix.os }}.tar.gz
prebuild-linux:
name: Prebuild Linux
runs-on: ubuntu-latest
# Build Linux prebuilds inside a container with old glibc for backwards compatibility.
# Debian 10 contained glibc 2.28: https://packages.debian.org/buster/libc6
container: debian:10
steps:
# Working directory is owned by 1001:1001 by default.
# Change it to our user.
- name: Change working directory owner
run: chown root:root .
- uses: actions/checkout@v4
with:
show-progress: false
- uses: actions/setup-node@v4
with:
node-version: "18"
- run: apt-get update
# Python is needed for node-gyp
- name: Install curl, python and compilers
run: apt-get install -y curl build-essential python3
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: System info
run: |
rustc -vV
rustup -vV
cargo -vV
npm --version
node --version
- name: Cache node modules
uses: actions/cache@v4
with:
path: |
${{ env.APPDATA }}/npm-cache
~/.npm
key: ${{ matrix.os }}-node-${{ hashFiles('**/package.json') }}
- name: Cache cargo index
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}-2
- name: Install dependencies & build
if: steps.cache.outputs.cache-hit != 'true'
working-directory: node
run: npm install --verbose
- name: Build Prebuild
working-directory: node
run: |
npm run prebuildify
tar -zcvf "linux.tar.gz" -C prebuilds .
- name: Upload Prebuild
uses: actions/upload-artifact@v4
with:
name: linux
path: node/linux.tar.gz
pack-module:
needs: [prebuild, prebuild-linux]
name: Package deltachat-node and upload to download.delta.chat
runs-on: ubuntu-latest
steps:
- name: Install tree
run: sudo apt install tree
- uses: actions/checkout@v4
with:
show-progress: false
- uses: actions/setup-node@v4
with:
node-version: "18"
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
continue-on-error: true
- name: Get Pull Request ID
id: prepare
run: |
tag=${{ steps.tag.outputs.tag }}
if [ -z "$tag" ]; then
node -e "console.log('DELTACHAT_NODE_TAR_GZ=deltachat-node-' + '${{ github.ref }}'.split('/')[2] + '.tar.gz')" >> $GITHUB_ENV
else
echo "DELTACHAT_NODE_TAR_GZ=deltachat-node-${{ steps.tag.outputs.tag }}.tar.gz" >> $GITHUB_ENV
echo "No preview will be uploaded this time, but the $tag release"
fi
- name: System info
run: |
rustc -vV
rustup -vV
cargo -vV
npm --version
node --version
echo $DELTACHAT_NODE_TAR_GZ
- name: Download Linux prebuild
uses: actions/download-artifact@v4
with:
name: linux
- name: Download macOS prebuild
uses: actions/download-artifact@v4
with:
name: macos-latest
- name: Download Windows prebuild
uses: actions/download-artifact@v4
with:
name: windows-latest
- shell: bash
run: |
mkdir node/prebuilds
tar -xvzf linux.tar.gz -C node/prebuilds
tar -xvzf macos-latest.tar.gz -C node/prebuilds
tar -xvzf windows-latest.tar.gz -C node/prebuilds
tree node/prebuilds
rm -f linux.tar.gz macos-latest.tar.gz windows-latest.tar.gz
- name: Install dependencies without running scripts
run: |
npm install --ignore-scripts
- name: Build constants
run: |
npm run build:core:constants
- name: Build TypeScript part
run: |
npm run build:bindings:ts
- name: Package
shell: bash
run: |
mv node/README.md README.md
npm pack .
ls -lah
mv $(find deltachat-node-*) $DELTACHAT_NODE_TAR_GZ
- name: Upload prebuild
uses: actions/upload-artifact@v4
with:
name: deltachat-node.tgz
path: ${{ env.DELTACHAT_NODE_TAR_GZ }}
# Upload to download.delta.chat/node/preview/
- name: Upload deltachat-node preview to download.delta.chat/node/preview/
if: ${{ ! steps.tag.outputs.tag }}
id: upload-preview
shell: bash
run: |
echo -e "${{ secrets.SSH_KEY }}" >__TEMP_INPUT_KEY_FILE
chmod 600 __TEMP_INPUT_KEY_FILE
scp -o StrictHostKeyChecking=no -v -i __TEMP_INPUT_KEY_FILE -P "22" -r $DELTACHAT_NODE_TAR_GZ "${{ secrets.USERNAME }}"@"download.delta.chat":"/var/www/html/download/node/preview/"
continue-on-error: true
- name: Post links to details
if: steps.upload-preview.outcome == 'success'
run: node ./node/scripts/postLinksToDetails.js
env:
URL: preview/${{ env.DELTACHAT_NODE_TAR_GZ }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Upload to download.delta.chat/node/
- name: Upload deltachat-node build to download.delta.chat/node/
if: ${{ steps.tag.outputs.tag }}
id: upload
shell: bash
run: |
echo -e "${{ secrets.SSH_KEY }}" >__TEMP_INPUT_KEY_FILE
chmod 600 __TEMP_INPUT_KEY_FILE
scp -o StrictHostKeyChecking=no -v -i __TEMP_INPUT_KEY_FILE -P "22" -r $DELTACHAT_NODE_TAR_GZ "${{ secrets.USERNAME }}"@"download.delta.chat":"/var/www/html/download/node/"

68
.github/workflows/node-tests.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
# GitHub Actions workflow
# to test Node.js bindings.
name: "node.js tests"
# Cancel previously started workflow runs
# when the branch is updated.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
pull_request:
push:
branches:
- main
jobs:
tests:
name: Tests
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
with:
show-progress: false
- uses: actions/setup-node@v4
with:
node-version: "18"
- name: System info
run: |
rustc -vV
rustup -vV
cargo -vV
npm --version
node --version
- name: Cache node modules
uses: actions/cache@v4
with:
path: |
${{ env.APPDATA }}/npm-cache
~/.npm
key: ${{ matrix.os }}-node-${{ hashFiles('**/package.json') }}
- name: Cache cargo index
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}-2
- name: Install dependencies & build
if: steps.cache.outputs.cache-hit != 'true'
working-directory: node
run: npm install --verbose
- name: Test
timeout-minutes: 10
working-directory: node
run: npm run test
env:
CHATMAIL_DOMAIN: ${{ vars.CHATMAIL_DOMAIN }}
NODE_OPTIONS: "--force-node-api-uncaught-exceptions-policy=true"

View File

@@ -5,25 +5,22 @@ on:
release: release:
types: [published] types: [published]
permissions: {}
jobs: jobs:
build: build:
name: Build distribution name: Build distribution
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
- name: Install pypa/build - name: Install pypa/build
run: python3 -m pip install build run: python3 -m pip install build
- name: Build a binary wheel and a source tarball - name: Build a binary wheel and a source tarball
working-directory: deltachat-rpc-client working-directory: deltachat-rpc-client
run: python3 -m build run: python3 -m build
- name: Store the distribution packages - name: Store the distribution packages
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: python-package-distributions name: python-package-distributions
path: deltachat-rpc-client/dist/ path: deltachat-rpc-client/dist/
@@ -42,9 +39,9 @@ jobs:
steps: steps:
- name: Download all the dists - name: Download all the dists
uses: actions/download-artifact@v7 uses: actions/download-artifact@v4
with: with:
name: python-package-distributions name: python-package-distributions
path: dist/ path: dist/
- name: Publish deltachat-rpc-client to PyPI - name: Publish deltachat-rpc-client to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b uses: pypa/gh-action-pypi-publish@release/v1

View File

@@ -7,22 +7,20 @@ name: Build Windows REPL .exe
on: on:
workflow_dispatch: workflow_dispatch:
permissions: {}
jobs: jobs:
build_repl: build_repl:
name: Build REPL example name: Build REPL example
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false - uses: DeterminateSystems/nix-installer-action@main
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5 - uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build - name: Build
run: nix build .#deltachat-repl-win64 run: nix build .#deltachat-repl-win64
- name: Upload binary - name: Upload binary
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: repl.exe name: repl.exe
path: "result/bin/deltachat-repl.exe" path: "result/bin/deltachat-repl.exe"

View File

@@ -1,96 +1,80 @@
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: on:
push: push:
branches: branches:
- main - main
- build_jsonrpc_docs_ci
permissions: {}
jobs: jobs:
build-rs: build-rs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment:
name: rs.delta.chat
url: https://rs.delta.chat/
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
- name: Build the documentation with cargo - name: Build the documentation with cargo
run: | run: |
cargo doc --package deltachat --no-deps --document-private-items cargo doc --package deltachat --no-deps --document-private-items
- name: Upload to rs.delta.chat - name: Upload to rs.delta.chat
run: | run: |
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.RS_DOCS_SSH_KEY }}" > "$HOME/.ssh/key" echo "${{ secrets.KEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -avzh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/target/doc "${{ secrets.RS_DOCS_SSH_USER }}@rs.delta.chat:/var/www/html/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: build-python:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment:
name: py.delta.chat
url: https://py.delta.chat/
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number. fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5 - uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build Python documentation - name: Build Python documentation
run: nix build .#python-docs run: nix build .#python-docs
- name: Upload to py.delta.chat - name: Upload to py.delta.chat
run: | run: |
mkdir -p "$HOME/.ssh" 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" 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:/var/www/html/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: build-c:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment:
name: c.delta.chat
url: https://c.delta.chat/
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number. fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: cachix/install-nix-action@ab739621df7a23f52766f9ccc97f38da6b7af14f # v31.10.5 - uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build C documentation - name: Build C documentation
run: nix build .#docs run: nix build .#docs
- name: Upload to c.delta.chat - name: Upload to c.delta.chat
run: | run: |
mkdir -p "$HOME/.ssh" 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" 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:/var/www/html/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: build-ts:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment:
name: js.jsonrpc.delta.chat
url: https://js.jsonrpc.delta.chat/
defaults: defaults:
run: run:
working-directory: ./deltachat-jsonrpc/typescript working-directory: ./deltachat-jsonrpc/typescript
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
show-progress: false show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number. fetch-depth: 0 # Fetch history to calculate VCS version number.
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '18'
- name: npm install - name: npm install
@@ -102,27 +86,6 @@ jobs:
- name: Upload to js.jsonrpc.delta.chat - name: Upload to js.jsonrpc.delta.chat
run: | run: |
mkdir -p "$HOME/.ssh" 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" chmod 600 "$HOME/.ssh/key"
rsync -avzh --delete -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/deltachat-jsonrpc/typescript/docs/ "${{ secrets.JS_JSONRPC_DOCS_SSH_USER }}@js.jsonrpc.delta.chat:/var/www/html/js.jsonrpc.delta.chat/" 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/"
build-cffi:
runs-on: ubuntu-latest
environment:
name: cffi.delta.chat
url: https://cffi.delta.chat/
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.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:/var/www/html/cffi.delta.chat/"

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

@@ -0,0 +1,28 @@
# 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
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
show-progress: 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

@@ -1,26 +0,0 @@
name: GitHub Actions Security Analysis with zizmor
on:
push:
branches: ["main"]
pull_request:
branches: ["**"]
permissions: {}
jobs:
zizmor:
name: Run zizmor
runs-on: ubuntu-latest
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
contents: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3

6
.github/zizmor.yml vendored
View File

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

6
.gitignore vendored
View File

@@ -1,9 +1,7 @@
target/ /target
**/*.rs.bk **/*.rs.bk
/build /build
/dist /dist
/fuzz/fuzz_targets/corpus/
/fuzz/fuzz_targets/crashes/
# ignore vi temporaries # ignore vi temporaries
*~ *~
@@ -36,7 +34,6 @@ deltachat-ffi/xml
coverage/ coverage/
.DS_Store .DS_Store
.vscode .vscode
.zed
python/accounts.txt python/accounts.txt
python/all-testaccounts.txt python/all-testaccounts.txt
tmp/ tmp/
@@ -54,4 +51,3 @@ result
# direnv # direnv
.envrc .envrc
.direnv .direnv
.aider*

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,7 @@ add_custom_command(
PREFIX=${CMAKE_INSTALL_PREFIX} PREFIX=${CMAKE_INSTALL_PREFIX}
LIBDIR=${CMAKE_INSTALL_FULL_LIBDIR} LIBDIR=${CMAKE_INSTALL_FULL_LIBDIR}
INCLUDEDIR=${CMAKE_INSTALL_FULL_INCLUDEDIR} INCLUDEDIR=${CMAKE_INSTALL_FULL_INCLUDEDIR}
${CARGO} build --target-dir=${CMAKE_BINARY_DIR}/target --release ${CARGO} build --target-dir=${CMAKE_BINARY_DIR}/target --release --features jsonrpc
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/deltachat-ffi WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/deltachat-ffi
) )

View File

@@ -1,8 +1,8 @@
# Contributing to chatmail core # Contributing to Delta Chat
## Bug reports ## Bug reports
If you found a bug, [report it on GitHub](https://github.com/chatmail/core/issues). If you found a bug, [report it on GitHub](https://github.com/deltachat/deltachat-core-rust/issues).
If the bug you found is specific to If the bug you found is specific to
[Android](https://github.com/deltachat/deltachat-android/issues), [Android](https://github.com/deltachat/deltachat-android/issues),
[iOS](https://github.com/deltachat/deltachat-ios/issues) or [iOS](https://github.com/deltachat/deltachat-ios/issues) or
@@ -44,7 +44,7 @@ If you want to contribute a code, follow this guide.
The following prefix types are used: The following prefix types are used:
- `feat`: Features, e.g. "feat: Pause IO for BackupProvider". If you are unsure what's the category of your commit, you can often just use `feat`. - `feat`: Features, e.g. "feat: Pause IO for BackupProvider". If you are unsure what's the category of your commit, you can often just use `feat`.
- `fix`: Bug fixes, e.g. "fix: delete `smtp` rows when message sending is canceled" - `fix`: Bug fixes, e.g. "fix: delete `smtp` rows when message sending is cancelled"
- `api`: API changes, e.g. "api(rust): add `get_msg_read_receipts(context, msg_id)`" - `api`: API changes, e.g. "api(rust): add `get_msg_read_receipts(context, msg_id)`"
- `refactor`: Refactorings, e.g. "refactor: iterate over `msg_ids` without `.iter()`" - `refactor`: Refactorings, e.g. "refactor: iterate over `msg_ids` without `.iter()`"
- `perf`: Performance improvements, e.g. "perf: improve SQLite performance with `PRAGMA synchronous=normal`" - `perf`: Performance improvements, e.g. "perf: improve SQLite performance with `PRAGMA synchronous=normal`"
@@ -67,7 +67,7 @@ If you want to contribute a code, follow this guide.
BREAKING CHANGE: You have to call `dc_stop_io()`/`dc_start_io()` before/after `dc_imex(DC_IMEX_EXPORT_BACKUP)` BREAKING CHANGE: You have to call `dc_stop_io()`/`dc_start_io()` before/after `dc_imex(DC_IMEX_EXPORT_BACKUP)`
``` ```
4. [**Open a Pull Request**](https://github.com/chatmail/core/pulls). 4. [**Open a Pull Request**](https://github.com/deltachat/deltachat-core-rust/pulls).
Refer to the corresponding issue. Refer to the corresponding issue.
@@ -116,7 +116,7 @@ For other ways to contribute, refer to the [website](https://delta.chat/en/contr
You can find the list of good first issues You can find the list of good first issues
and a link to this guide and a link to this guide
on the contributing page: <https://github.com/chatmail/core/contribute> on the contributing page: <https://github.com/deltachat/deltachat-core-rust/contribute>
[Conventional Commits]: https://www.conventionalcommits.org/ [Conventional Commits]: https://www.conventionalcommits.org/
[git-cliff]: https://git-cliff.org/ [git-cliff]: https://git-cliff.org/

3729
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
[package] [package]
name = "deltachat" name = "deltachat"
version = "2.50.0-dev" version = "1.152.0"
edition = "2024" edition = "2021"
license = "MPL-2.0" license = "MPL-2.0"
rust-version = "1.89" rust-version = "1.81"
repository = "https://github.com/chatmail/core" repository = "https://github.com/deltachat/deltachat-core-rust"
[profile.dev] [profile.dev]
debug = 0 debug = 0
@@ -18,9 +18,6 @@ opt-level = 1
debug = 1 debug = 1
opt-level = 0 opt-level = 0
[profile.fuzz]
inherits = "test"
# Always optimize dependencies. # Always optimize dependencies.
# This does not apply to crates in the workspace. # This does not apply to crates in the workspace.
# <https://doc.rust-lang.org/cargo/reference/profiles.html#overrides> # <https://doc.rust-lang.org/cargo/reference/profiles.html#overrides>
@@ -42,87 +39,89 @@ format-flowed = { path = "./format-flowed" }
ratelimit = { path = "./deltachat-ratelimit" } ratelimit = { path = "./deltachat-ratelimit" }
anyhow = { workspace = true } anyhow = { workspace = true }
async-broadcast = "0.7.2" async-broadcast = "0.7.1"
async-channel = { workspace = true } async-channel = { workspace = true }
async-imap = { version = "0.11.1", default-features = false, features = ["runtime-tokio", "compress"] } async-imap = { version = "0.10.2", default-features = false, features = ["runtime-tokio", "compress"] }
async-native-tls = { version = "0.6", default-features = false, features = ["runtime-tokio"] } 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-smtp = { version = "0.10", default-features = false, features = ["runtime-tokio"] }
async_zip = { version = "0.0.18", default-features = false, features = ["deflate", "tokio-fs"] } async_zip = { version = "0.0.17", default-features = false, features = ["deflate", "tokio-fs"] }
base64 = { workspace = true } base64 = { workspace = true }
blake3 = "1.8.2" brotli = { version = "7", default-features=false, features = ["std"] }
brotli = { version = "8", default-features=false, features = ["std"] }
bytes = "1" bytes = "1"
chrono = { workspace = true, features = ["alloc", "clock", "std"] } chrono = { workspace = true, features = ["alloc", "clock", "std"] }
colorutils-rs = { version = "0.7.5", default-features = false } email = { git = "https://github.com/deltachat/rust-email", branch = "master" }
data-encoding = "2.9.0" encoded-words = { git = "https://github.com/async-email/encoded-words", branch = "master" }
escaper = "0.1" escaper = "0.1"
fast-socks5 = "1" fast-socks5 = "0.9"
fd-lock = "4" fd-lock = "4"
futures-lite = { workspace = true } futures-lite = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
hex = "0.4.0" hex = "0.4.0"
http-body-util = "0.1.3" hickory-resolver = "=0.25.0-alpha.4"
http-body-util = "0.1.2"
humansize = "2" humansize = "2"
hyper = "1" hyper = "1"
hyper-util = "0.1.16" hyper-util = "0.1.10"
image = { version = "0.25.6", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] } image = { version = "0.25.5", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
iroh-gossip = { version = "0.35", default-features = false, features = ["net"] } iroh-gossip = { version = "0.30", default-features = false, features = ["net"] }
iroh = { version = "0.35", default-features = false } iroh = { version = "0.30", default-features = false }
kamadak-exif = "0.6.1" kamadak-exif = "0.6.1"
lettre_email = { git = "https://github.com/deltachat/lettre", branch = "master" }
libc = { workspace = true } libc = { workspace = true }
mail-builder = { version = "0.4.4", default-features = false } mailparse = "0.15"
mailparse = { workspace = true }
mime = "0.3.17" mime = "0.3.17"
num_cpus = "1.17" num_cpus = "1.16"
num-derive = "0.4" num-derive = "0.4"
num-traits = { workspace = true } num-traits = { workspace = true }
parking_lot = "0.12.4" once_cell = { workspace = true }
parking_lot = "0.12"
percent-encoding = "2.3" percent-encoding = "2.3"
pgp = { version = "0.19.0", default-features = false } pgp = { version = "0.14.2", default-features = false }
pin-project = "1" pin-project = "1"
qrcodegen = "1.7.0" qrcodegen = "1.7.0"
quick-xml = { version = "0.39", features = ["escape-html"] } quick-xml = "0.37"
rand-old = { package = "rand", version = "0.8" } quoted_printable = "0.5"
rand = { workspace = true } rand = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] } rusqlite = { workspace = true, features = ["sqlcipher"] }
rust-hsluv = "0.1"
rustls-pki-types = "1.10.0"
rustls = { version = "0.23.19", default-features = false }
sanitize-filename = { workspace = true } sanitize-filename = { workspace = true }
sdp = "0.17.1"
serde_json = { workspace = true } serde_json = { workspace = true }
serde_urlencoded = "0.7.1" serde_urlencoded = "0.7.1"
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
sha-1 = "0.10" sha-1 = "0.10"
sha2 = "0.10" sha2 = "0.10"
shadowsocks = { version = "1.23.1", default-features = false, features = ["aead-cipher", "aead-cipher-2022"] } shadowsocks = { version = "1.21.0", default-features = false, features = ["aead-cipher-2022"] }
smallvec = "1.15.1" smallvec = "1.13.2"
strum = "0.28" strum = "0.26"
strum_macros = "0.28" strum_macros = "0.26"
tagger = "4.3.4" tagger = "4.3.4"
textwrap = "0.16.2" textwrap = "0.16.1"
thiserror = { workspace = true } thiserror = { workspace = true }
tokio-io-timeout = "1.2.1" tokio-io-timeout = "1.2.0"
tokio-rustls = { version = "0.26.2", default-features = false } tokio-rustls = { version = "0.26.0", default-features = false }
tokio-stream = { version = "0.1.17", features = ["fs"] } tokio-stream = { version = "0.1.16", features = ["fs"] }
astral-tokio-tar = { version = "0.6.1", default-features = false } tokio-tar = { version = "0.3" } # TODO: integrate tokio into async-tar
tokio-util = { workspace = true } tokio-util = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] } tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.9" toml = "0.8"
tracing = "0.1.41"
url = "2" url = "2"
uuid = { version = "1", features = ["serde", "v4"] } uuid = { version = "1", features = ["serde", "v4"] }
walkdir = "2.5.0" webpki-roots = "0.26.7"
webpki-roots = "0.26.8" data-encoding = "2.6.0"
[dev-dependencies] [dev-dependencies]
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests. anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.
criterion = { version = "0.8.1", features = ["async_tokio"] } criterion = { version = "0.5.1", features = ["async_tokio"] }
futures-lite = { workspace = true } futures-lite = { workspace = true }
log = { workspace = true } log = { workspace = true }
nu-ansi-term = { workspace = true } nu-ansi-term = { workspace = true }
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
proptest = { version = "1", default-features = false, features = ["std"] } proptest = { version = "1", default-features = false, features = ["std"] }
tempfile = { workspace = true } tempfile = { workspace = true }
testdir = "0.9.3" testdir = "0.9.0"
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
[workspace] [workspace]
@@ -136,7 +135,6 @@ members = [
"deltachat-time", "deltachat-time",
"format-flowed", "format-flowed",
"deltachat-contact-tools", "deltachat-contact-tools",
"fuzz",
] ]
[[bench]] [[bench]]
@@ -156,19 +154,10 @@ name = "receive_emails"
required-features = ["internals"] required-features = ["internals"]
harness = false harness = false
[[bench]]
name = "decrypting"
required-features = ["internals"]
harness = false
[[bench]] [[bench]]
name = "get_chat_msgs" name = "get_chat_msgs"
harness = false harness = false
[[bench]]
name = "marknoticed_chat"
harness = false
[[bench]] [[bench]]
name = "get_chatlist" name = "get_chatlist"
harness = false harness = false
@@ -179,38 +168,37 @@ harness = false
[workspace.dependencies] [workspace.dependencies]
anyhow = "1" anyhow = "1"
async-channel = "2.5.0" async-channel = "2.3.1"
base64 = "0.22" base64 = "0.22"
chrono = { version = "0.4.44", default-features = false } chrono = { version = "0.4.38", default-features = false }
deltachat-contact-tools = { path = "deltachat-contact-tools" } deltachat-contact-tools = { path = "deltachat-contact-tools" }
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false } deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
deltachat = { path = ".", default-features = false } deltachat = { path = ".", default-features = false }
futures = "0.3.32" futures = "0.3.31"
futures-lite = "2.6.1" futures-lite = "2.5.0"
libc = "0.2" libc = "0.2"
log = "0.4" log = "0.4"
mailparse = "0.16.1" nu-ansi-term = "0.46"
nu-ansi-term = "0.50"
num-traits = "0.2" num-traits = "0.2"
rand = "0.9" once_cell = "1.20.2"
regex = "1.12" rand = "0.8"
rusqlite = "0.37" regex = "1.10"
sanitize-filename = "0.6" rusqlite = "0.32"
sanitize-filename = "0.5"
serde = "1.0" serde = "1.0"
serde_json = "1" serde_json = "1"
tempfile = "3.27.0" tempfile = "3.14.0"
thiserror = "2" thiserror = "1"
tokio = "1" tokio = "1"
tokio-util = "0.7.18" tokio-util = "0.7.11"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
yerpc = "0.6.4" yerpc = "0.6.2"
[features] [features]
default = ["vendored"] default = ["vendored"]
internals = [] internals = []
vendored = [ vendored = [
"rusqlite/bundled-sqlcipher-vendored-openssl", "rusqlite/bundled-sqlcipher-vendored-openssl"
"async-native-tls/vendored"
] ]
[lints.rust] [lints.rust]

109
README.md
View File

@@ -1,41 +1,19 @@
<p align="center"> <p align="center">
<img alt="Chatmail logo" src="https://github.com/user-attachments/assets/25742da7-a837-48cd-a503-b303af55f10d" width="300" style="float:middle;" /> <img alt="Delta Chat Logo" height="200px" src="https://raw.githubusercontent.com/deltachat/deltachat-pages/master/assets/blog/rust-delta.png">
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/chatmail/core/actions/workflows/ci.yml"> <a href="https://github.com/deltachat/deltachat-core-rust/actions/workflows/ci.yml">
<img alt="Rust CI" src="https://github.com/chatmail/core/actions/workflows/ci.yml/badge.svg"> <img alt="Rust CI" src="https://github.com/deltachat/deltachat-core-rust/actions/workflows/ci.yml/badge.svg">
</a> </a>
<a href="https://deps.rs/repo/github/chatmail/core"> <a href="https://deps.rs/repo/github/deltachat/deltachat-core-rust">
<img alt="dependency status" src="https://deps.rs/repo/github/chatmail/core/status.svg"> <img alt="dependency status" src="https://deps.rs/repo/github/deltachat/deltachat-core-rust/status.svg">
</a> </a>
</p> </p>
The chatmail core library implements low-level network and encryption protocols, <p align="center">
integrated by many chat bots and higher level applications, The core library for Delta Chat, written in Rust
allowing to securely participate in the globally scaled e-mail server network. </p>
We provide reproducibly-built `deltachat-rpc-server` static binaries
that offer a stdio-based high-level JSON-RPC API for instant messaging purposes.
The following protocols are handled without requiring API users to know much about them:
- secure TLS setup with DNS caching and shadowsocks/proxy support
- 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).
- security-audited end-to-end encryption with [rPGP](https://github.com/rpgp/rpgp)
and [Autocrypt and SecureJoin protocols](https://securejoin.rtfd.io)
- ephemeral [Peer-to-Peer networking using Iroh](https://iroh.computer) for multi-device setup and
[webxdc realtime data](https://delta.chat/en/2024-11-20-webxdc-realtime).
- a simulation- and real-world tested [P2P group membership
protocol without requiring server state](https://github.com/chatmail/models/tree/main/group-membership).
## Installing Rust and Cargo ## Installing Rust and Cargo
@@ -49,12 +27,12 @@ $ curl https://sh.rustup.rs -sSf | sh
## Using the CLI client ## Using the CLI client
Compile and run the command line utility, using `cargo`: Compile and run Delta Chat Core command line utility, using `cargo`:
``` ```
$ cargo run --locked -p deltachat-repl -- ~/profile-db $ cargo run --locked -p deltachat-repl -- ~/deltachat-db
``` ```
where ~/profile-db is the database file. The utility will create it if it does not exist. where ~/deltachat-db is the database file. Delta Chat will create it if it does not exist.
Optionally, install `deltachat-repl` binary with Optionally, install `deltachat-repl` binary with
``` ```
@@ -62,13 +40,13 @@ $ cargo install --locked --path deltachat-repl/
``` ```
and run as and run as
``` ```
$ deltachat-repl ~/profile-db $ deltachat-repl ~/deltachat-db
``` ```
Configure your account (if not already configured): Configure your account (if not already configured):
``` ```
Chatmail is awaiting your commands. Delta Chat Core is awaiting your commands.
> set addr your@email.org > set addr your@email.org
> set mail_pw yourpassword > set mail_pw yourpassword
> configure > configure
@@ -80,43 +58,37 @@ Connect to your mail server (if already configured):
> connect > connect
``` ```
Export your public key to a vCard file: Create a contact:
```
> make-vcard my.vcard 1
```
Create contacts by address or vCard file:
``` ```
> addcontact yourfriends@email.org > addcontact yourfriends@email.org
> import-vcard key-contact.vcard Command executed successfully.
``` ```
List contacts: List contacts:
``` ```
> listcontacts > listcontacts
Contact#Contact#11: key-contact@email.org <key-contact@email.org> Contact#10: <name unset> <yourfriends@email.org>
Contact#Contact#Self: Me √ <your@email.org> Contact#1: Me √ <your@email.org>
2 key contacts.
Contact#Contact#10: yourfriends@email.org <yourfriends@email.org>
1 address contacts.
``` ```
Create a chat with your friend and send a message: Create a chat with your friend and send a message:
``` ```
> createchat 10 > createchat 10
Single#Chat#12 created successfully. Single#10 created successfully.
> chat 12 > chat 10
Selecting chat Chat#12 Single#10: yourfriends@email.org [yourfriends@email.org]
Single#Chat#12: yourfriends@email.org [yourfriends@email.org] Icon: profile-db-blobs/4138c52e5bc1c576cda7dd44d088c07.png
0 messages.
81.252µs to create this list, 123.625µs to mark all messages as noticed.
> send hi > send hi
Message sent.
``` ```
If `yourfriend@email.org` uses DeltaChat, but does not receive message just
sent, it is advisable to check `Spam` folder. It is known that at least
`gmx.com` treat such test messages as spam, unless told otherwise with web
interface.
List messages when inside a chat: List messages when inside a chat:
``` ```
@@ -132,7 +104,7 @@ For more commands type:
## Installing libdeltachat system wide ## Installing libdeltachat system wide
``` ```
$ git clone https://github.com/chatmail/core.git $ git clone https://github.com/deltachat/deltachat-core-rust.git
$ cd deltachat-core-rust $ cd deltachat-core-rust
$ cmake -B build . -DCMAKE_INSTALL_PREFIX=/usr $ cmake -B build . -DCMAKE_INSTALL_PREFIX=/usr
$ cmake --build build $ cmake --build build
@@ -173,7 +145,7 @@ $ cargo install cargo-bolero
Run fuzzing tests with Run fuzzing tests with
```sh ```sh
$ cd fuzz $ cd fuzz
$ cargo bolero test fuzz_mailparse -s NONE $ cargo bolero test fuzz_mailparse --release=false -s NONE
``` ```
Corpus is created at `fuzz/fuzz_targets/corpus`, Corpus is created at `fuzz/fuzz_targets/corpus`,
@@ -181,6 +153,11 @@ you can add initial inputs there.
For `fuzz_mailparse` target corpus can be populated with For `fuzz_mailparse` target corpus can be populated with
`../test-data/message/*.eml`. `../test-data/message/*.eml`.
To run with AFL instead of libFuzzer:
```sh
$ cargo bolero test fuzz_format_flowed --release=false -e afl -s NONE
```
## Features ## Features
- `vendored`: When using Openssl for TLS, this bundles a vendored version. - `vendored`: When using Openssl for TLS, this bundles a vendored version.
@@ -188,19 +165,25 @@ For `fuzz_mailparse` target corpus can be populated with
## Update Provider Data ## Update Provider Data
To add the updates from the To add the updates from the
[provider-db](https://github.com/chatmail/provider-db) to the core, [provider-db](https://github.com/deltachat/provider-db) to the core, run:
check line `REV=` inside `./scripts/update-provider-database.sh`
and then run the script. ```
./src/provider/update.py ../provider-db/_providers/ > src/provider/data.rs
```
## Language bindings and frontend projects ## Language bindings and frontend projects
Language bindings are available for: Language bindings are available for:
- **C** \[[📂 source](./deltachat-ffi) | [📚 docs](https://c.delta.chat)\] - **C** \[[📂 source](./deltachat-ffi) | [📚 docs](https://c.delta.chat)\]
- -> libdeltachat is going to be deprecated and only exists because Android, iOS and Ubuntu Touch are still using it. If you build a new project, then please use the jsonrpc api instead. - **Node.js**
- **JS**: \[[📂 source](./deltachat-rpc-client) | [📦 npm](https://www.npmjs.com/package/@deltachat/jsonrpc-client) | [📚 docs](https://js.jsonrpc.delta.chat/)\] - over JSON-RPC: \[[📂 source](./deltachat-rpc-client) | [📦 npm](https://www.npmjs.com/package/@deltachat/jsonrpc-client) | [📚 docs](https://js.jsonrpc.delta.chat/)\]
- over CFFI[^1]: \[[📂 source](./node) | [📦 npm](https://www.npmjs.com/package/deltachat-node) | [📚 docs](https://js.delta.chat/)\]
- **Python** \[[📂 source](./python) | [📦 pypi](https://pypi.org/project/deltachat) | [📚 docs](https://py.delta.chat)\] - **Python** \[[📂 source](./python) | [📦 pypi](https://pypi.org/project/deltachat) | [📚 docs](https://py.delta.chat)\]
- **Go** \[[📂 source](https://github.com/deltachat/deltachat-rpc-client-go/)\] - **Go**
- over jsonrpc: \[[📂 source](https://github.com/deltachat/deltachat-rpc-client-go/)\]
- over cffi[^1]: \[[📂 source](https://github.com/deltachat/go-deltachat/)\]
- **Free Pascal**[^1] \[[📂 source](https://github.com/deltachat/deltachat-fp/)\]
- **Java** and **Swift** (contained in the Android/iOS repos) - **Java** and **Swift** (contained in the Android/iOS repos)
The following "frontend" projects make use of the Rust-library The following "frontend" projects make use of the Rust-library
@@ -213,3 +196,5 @@ or its language bindings:
- [Telepathy](https://code.ur.gs/lupine/telepathy-padfoot/) - [Telepathy](https://code.ur.gs/lupine/telepathy-padfoot/)
- [Ubuntu Touch](https://codeberg.org/lk108/deltatouch) - [Ubuntu Touch](https://codeberg.org/lk108/deltatouch)
- several **Bots** - several **Bots**
[^1]: Out of date / unmaintained, if you like those languages feel free to start maintaining them. If you have questions we'll help you, please ask in the issues.

View File

@@ -1,68 +1,21 @@
# 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. For example, to release version 1.116.0 of the core, do the following steps.
1. Resolve all [blocker issues](https://github.com/chatmail/core/labels/blocker). 1. Resolve all [blocker issues](https://github.com/deltachat/deltachat-core-rust/labels/blocker).
2. Update the changelog: `git cliff --unreleased --tag 1.116.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.116.0 -p CHANGELOG.md`. 2. Update the changelog: `git cliff --unreleased --tag 1.116.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.116.0 -p CHANGELOG.md`.
3. add a link to compare previous with current version to the end of CHANGELOG.md: 3. add a link to compare previous with current version to the end of CHANGELOG.md:
`[1.116.0]: https://github.com/chatmail/core/compare/v1.115.2...v1.116.0` `[1.116.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.115.2...v1.116.0`
4. Update the version by running `scripts/set_core_version.py 1.116.0`. 4. Update the version by running `scripts/set_core_version.py 1.116.0`.
5. Commit the changes as `chore(release): prepare for 1.116.0`. 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. 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`. 8. Create a GitHub release: `gh release create v1.116.0 --notes ''`.
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.

View File

@@ -16,12 +16,11 @@ id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT DEFAULT '' NOT NULL -- message text text TEXT DEFAULT '' NOT NULL -- message text
) STRICT", ) STRICT",
) )
.await .await?;
.context("CREATE TABLE messages")?;
``` ```
Do not use macros like [`concat!`](https://doc.rust-lang.org/std/macro.concat.html) 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: Do not escape newlines like this:
``` ```
sql.execute( sql.execute(
@@ -30,8 +29,7 @@ id INTEGER PRIMARY KEY AUTOINCREMENT, \
text TEXT DEFAULT '' NOT NULL \ text TEXT DEFAULT '' NOT NULL \
) STRICT", ) STRICT",
) )
.await .await?;
.context("CREATE TABLE messages")?;
``` ```
Escaping newlines Escaping newlines
is prone to errors like this if space before backslash is missing: is prone to errors like this if space before backslash is missing:
@@ -65,15 +63,6 @@ an older version. Also don't change the column type, consider adding a new colum
instead. Finally, never change column semantics, this is especially dangerous because the `STRICT` instead. Finally, never change column semantics, this is especially dangerous because the `STRICT`
keyword doesn't help here. 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 ## Errors
Delta Chat core mostly uses [`anyhow`](https://docs.rs/anyhow/) errors. Delta Chat core mostly uses [`anyhow`](https://docs.rs/anyhow/) errors.
@@ -89,52 +78,12 @@ All errors should be handled in one of these ways:
- With `.log_err().ok()`. - With `.log_err().ok()`.
- Bubbled up with `?`. - Bubbled up with `?`.
When working with [async streams](https://docs.rs/futures/0.3.31/futures/stream/index.html),
prefer [`try_next`](https://docs.rs/futures/0.3.31/futures/stream/trait.TryStreamExt.html#method.try_next) over `next()`, e.g. do
```
while let Some(event) = stream.try_next().await? {
todo!();
}
```
instead of
```
while let Some(event_res) = stream.next().await {
todo!();
}
```
as it allows bubbling up the error early with `?`
with no way to accidentally skip error processing
with early `continue` or `break`.
Some streams reading from a connection
return infinite number of `Some(Err(_))`
items when connection breaks and not processing
errors may result in infinite loop.
`backtrace` feature is enabled for `anyhow` crate `backtrace` feature is enabled for `anyhow` crate
and `debug = 1` option is set in the test profile. and `debug = 1` option is set in the test profile.
This allows to run `RUST_BACKTRACE=1 cargo test` This allows to run `RUST_BACKTRACE=1 cargo test`
and get a backtrace with line numbers in resultified tests and get a backtrace with line numbers in resultified tests
which return `anyhow::Result`. which return `anyhow::Result`.
`unwrap` and `expect` are not used in the library
because panics are difficult to debug on user devices.
However, in the tests `.expect` may be used.
Follow
<https://doc.rust-lang.org/core/error/index.html#common-message-styles>
for `.expect` message style.
## BTreeMap vs HashMap
Prefer [BTreeMap](https://doc.rust-lang.org/std/collections/struct.BTreeMap.html)
over [HashMap](https://doc.rust-lang.org/std/collections/struct.HashMap.html)
and [BTreeSet](https://doc.rust-lang.org/std/collections/struct.BTreeSet.html)
over [HashSet](https://doc.rust-lang.org/std/collections/struct.HashSet.html)
as iterating over these structures returns items in deterministic order.
Non-deterministic code may result in difficult to reproduce bugs,
flaky tests, regression tests that miss bugs
or different behavior on different devices when processing the same messages.
## Logging ## Logging
For logging, use `info!`, `warn!` and `error!` macros. For logging, use `info!`, `warn!` and `error!` macros.
@@ -147,30 +96,3 @@ Format anyhow errors with `{:#}` to print all the contexts like this:
``` ```
error!(context, "Failed to set selfavatar timestamp: {err:#}."); error!(context, "Failed to set selfavatar timestamp: {err:#}.");
``` ```
## Documentation comments
All public modules, methods and fields should be documented.
This is checked by [`missing_docs`](https://doc.rust-lang.org/rustdoc/lints.html#missing_docs) lint.
Private items do not have to be documented,
but CI uses `cargo doc --document-private-items`
to build the documentation,
so it is preferred that new items
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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="480"
viewBox="0 -960 9600 9600"
width="480"
fill="#ffffff"
version="1.1"
id="svg1"
sodipodi:docname="icon-email.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.99091847"
inkscape:cx="263.392"
inkscape:cy="177.613"
inkscape:window-width="1884"
inkscape:window-height="1052"
inkscape:window-x="36"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<rect
style="fill:#8c8c8c;fill-opacity:1;stroke:none;stroke-width:680.523;stroke-dasharray:none;paint-order:markers fill stroke"
id="rect1"
width="9951.9541"
height="9767.4756"
x="-71.697792"
y="-1012.83"
ry="0.43547946" />
<path
d="m 2948.0033,5553.6941 q -130.7292,0 -228.7761,-96.3953 -98.0468,-96.3953 -98.0468,-224.9223 V 2447.6234 q 0,-128.527 98.0468,-224.9223 98.0469,-96.3953 228.7761,-96.3953 h 3703.9934 q 130.7292,0 228.776,96.3953 98.0469,96.3953 98.0469,224.9223 v 2784.7531 q 0,128.527 -98.0469,224.9223 -98.0468,96.3953 -228.776,96.3953 z M 4800,3936.3952 2948.0033,2742.1646 V 5232.3765 H 6651.9967 V 2742.1646 Z m 0,-321.3176 1830.2085,-1167.4541 h -3654.97 z m -1851.9967,-872.913 v -294.5412 2784.7531 z"
id="path1"
style="stroke-width:5.40098" />
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,7 +0,0 @@
BEGIN:VCARD
VERSION:4.0
EMAIL:self_reporting@testrun.org
FN:Statistics bot
KEY:data:application/pgp-keys;base64,xjMEZbfBlBYJKwYBBAHaRw8BAQdABpLWS2PUIGGo4pslVt4R8sylP5wZihmhf1DTDr3oCMPNHDxzZWxmX3JlcG9ydGluZ0B0ZXN0cnVuLm9yZz7CiwQQFggAMwIZAQUCZbfBlAIbAwQLCQgHBhUICQoLAgMWAgEWIQTS2i16sHeYTckGn284K3M5Z4oohAAKCRA4K3M5Z4oohD8dAQCQV7CoH6UP4PD+NqI4kW5tbbqdh2AnDROg60qotmLExAEAxDfd3QHAK9f8b9qQUbLmHIztCLxhEuVbWPBEYeVW0gvOOARlt8GUEgorBgEEAZdVAQUBAQdAMBUhYoAAcI625vGZqnM5maPX4sGJ7qvJxPAFILPy6AcDAQgHwngEGBYIACAFAmW3wZQCGwwWIQTS2i16sHeYTckGn284K3M5Z4oohAAKCRA4K3M5Z4oohPwCAQCvzk1ObIkj2GqsuIfaULlgdnfdZY8LNary425CEfHZDQD5AblXVrlMO1frdlc/Vo9z3pEeCrfYdD7ITD3/OeVoiQ4=
REV:20250412T195751Z
END:VCARD

View File

@@ -1,11 +1,9 @@
#![recursion_limit = "256"] #![recursion_limit = "256"]
use std::hint::black_box; use criterion::{black_box, criterion_group, criterion_main, Criterion};
use criterion::{Criterion, criterion_group, criterion_main};
use deltachat::Events;
use deltachat::contact::Contact; use deltachat::contact::Contact;
use deltachat::context::Context; use deltachat::context::Context;
use deltachat::stock_str::StockStrings; use deltachat::stock_str::StockStrings;
use deltachat::Events;
use tempfile::tempdir; use tempfile::tempdir;
async fn address_book_benchmark(n: u32, read_count: u32) { async fn address_book_benchmark(n: u32, read_count: u32) {

View File

@@ -1,8 +1,7 @@
#![recursion_limit = "256"] #![recursion_limit = "256"]
use std::hint::black_box;
use std::path::PathBuf; use std::path::PathBuf;
use criterion::{Criterion, criterion_group, criterion_main}; use criterion::{black_box, criterion_group, criterion_main, Criterion};
use deltachat::accounts::Accounts; use deltachat::accounts::Accounts;
use tempfile::tempdir; use tempfile::tempdir;

View File

@@ -1,142 +0,0 @@
//! Benchmarks for message decryption,
//! comparing decryption of symmetrically-encrypted messages
//! to decryption of asymmetrically-encrypted messages.
//!
//! Call with
//!
//! ```text
//! cargo bench --bench decrypting --features="internals"
//! ```
//!
//! or, if you want to only run e.g. the 'Decrypt and parse a symmetrically encrypted message' benchmark:
//!
//! ```text
//! cargo bench --bench decrypting --features="internals" -- 'Decrypt and parse a symmetrically encrypted message'
//! ```
//!
//! You can also pass a substring:
//!
//! ```text
//! cargo bench --bench decrypting --features="internals" -- 'symmetrically'
//! ```
//!
//! 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::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,
stock_str::StockStrings,
};
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()
});
async fn create_context() -> Context {
let dir = tempdir().unwrap();
let dbfile = dir.path().join("db.sqlite");
let context = Context::new(dbfile.as_path(), 100, Events::new(), StockStrings::new())
.await
.unwrap();
context
.set_config(Config::ConfiguredAddr, Some("bob@example.net"))
.await
.unwrap();
let secret = key_from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap();
store_self_keypair(&context, &secret)
.await
.expect("Failed to save key");
context
}
fn criterion_benchmark(c: &mut Criterion) {
let mut group = c.benchmark_group("Decrypt");
// ===========================================================================================
// Benchmarks for the whole parsing pipeline, incl. decryption (but excl. receive_imf())
// ===========================================================================================
let rt = tokio::runtime::Runtime::new().unwrap();
let mut secrets = generate_secrets();
// "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();
let context = rt.block_on(async {
let context = create_context().await;
for (i, secret) in secrets.iter().enumerate() {
save_broadcast_secret(&context, ChatId::new(10 + i as u32), secret)
.await
.unwrap();
}
for _i in 0..*NUM_AUTH_TOKENS {
get_securejoin_qr(&context, None).await.unwrap();
}
println!("NUM_AUTH_TOKENS={}", *NUM_AUTH_TOKENS);
context
});
group.bench_function("Decrypt and parse a symmetrically encrypted message", |b| {
b.to_async(&rt).iter(|| {
let ctx = context.clone();
async move {
let text = parse_and_get_text(
&ctx,
include_bytes!("../test-data/message/text_symmetrically_encrypted.eml"),
)
.await
.unwrap();
assert_eq!(black_box(text), "Symmetrically encrypted message");
}
});
});
group.bench_function("Decrypt and parse a public-key encrypted message", |b| {
b.to_async(&rt).iter(|| {
let ctx = context.clone();
async move {
let text = parse_and_get_text(
&ctx,
include_bytes!("../test-data/message/text_from_alice_encrypted.eml"),
)
.await
.unwrap();
assert_eq!(black_box(text), "hi");
}
});
});
group.finish();
}
fn generate_secrets() -> Vec<String> {
let secrets: Vec<String> = (0..*NUM_BROADCAST_SECRETS)
.map(|_| create_broadcast_secret())
.collect();
println!("NUM_BROADCAST_SECRETS={}", *NUM_BROADCAST_SECRETS);
secrets
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

View File

@@ -1,13 +1,12 @@
#![recursion_limit = "256"] #![recursion_limit = "256"]
use std::hint::black_box;
use std::path::Path; use std::path::Path;
use criterion::{Criterion, criterion_group, criterion_main}; use criterion::{black_box, criterion_group, criterion_main, Criterion};
use deltachat::Events;
use deltachat::chat::{self, ChatId}; use deltachat::chat::{self, ChatId};
use deltachat::chatlist::Chatlist; use deltachat::chatlist::Chatlist;
use deltachat::context::Context; use deltachat::context::Context;
use deltachat::stock_str::StockStrings; use deltachat::stock_str::StockStrings;
use deltachat::Events;
async fn get_chat_msgs_benchmark(dbfile: &Path, chats: &[ChatId]) { async fn get_chat_msgs_benchmark(dbfile: &Path, chats: &[ChatId]) {
let id = 100; let id = 100;

View File

@@ -1,12 +1,11 @@
#![recursion_limit = "256"] #![recursion_limit = "256"]
use std::hint::black_box;
use std::path::Path; use std::path::Path;
use criterion::{Criterion, criterion_group, criterion_main}; use criterion::{black_box, criterion_group, criterion_main, Criterion};
use deltachat::Events;
use deltachat::chatlist::Chatlist; use deltachat::chatlist::Chatlist;
use deltachat::context::Context; use deltachat::context::Context;
use deltachat::stock_str::StockStrings; use deltachat::stock_str::StockStrings;
use deltachat::Events;
async fn get_chat_list_benchmark(context: &Context) { async fn get_chat_list_benchmark(context: &Context) {
Chatlist::try_load(context, 0, None, None).await.unwrap(); Chatlist::try_load(context, 0, None, None).await.unwrap();

View File

@@ -1,95 +0,0 @@
#![recursion_limit = "256"]
use std::hint::black_box;
use std::path::Path;
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
use deltachat::Events;
use deltachat::chat::{self, ChatId};
use deltachat::chatlist::Chatlist;
use deltachat::context::Context;
use deltachat::stock_str::StockStrings;
use futures_lite::future::block_on;
use tempfile::tempdir;
async fn marknoticed_chat_benchmark(context: &Context, chats: &[ChatId]) {
for c in chats.iter().take(20) {
chat::marknoticed_chat(context, *c).await.unwrap();
}
}
fn criterion_benchmark(c: &mut Criterion) {
// To enable this benchmark, set `DELTACHAT_BENCHMARK_DATABASE` to some large database with many
// messages, such as your primary account.
if let Ok(path) = std::env::var("DELTACHAT_BENCHMARK_DATABASE") {
let rt = tokio::runtime::Runtime::new().unwrap();
let chats: Vec<_> = rt.block_on(async {
let context = Context::new(Path::new(&path), 100, Events::new(), StockStrings::new())
.await
.unwrap();
let chatlist = Chatlist::try_load(&context, 0, None, None).await.unwrap();
let len = chatlist.len();
(1..len).map(|i| chatlist.get_chat_id(i).unwrap()).collect()
});
// This mainly tests the performance of marknoticed_chat()
// when nothing has to be done
c.bench_function(
"chat::marknoticed_chat (mark 20 chats as noticed repeatedly)",
|b| {
let dir = tempdir().unwrap();
let dir = dir.path();
let new_db = dir.join("dc.db");
std::fs::copy(&path, &new_db).unwrap();
let context = block_on(async {
Context::new(Path::new(&new_db), 100, Events::new(), StockStrings::new())
.await
.unwrap()
});
b.to_async(&rt)
.iter(|| marknoticed_chat_benchmark(&context, black_box(&chats)))
},
);
// If the first 20 chats contain fresh messages or reactions,
// this tests the performance of marking them as noticed.
c.bench_function(
"chat::marknoticed_chat (mark 20 chats as noticed, resetting after every iteration)",
|b| {
b.to_async(&rt).iter_batched(
|| {
let dir = tempdir().unwrap();
let new_db = dir.path().join("dc.db");
std::fs::copy(&path, &new_db).unwrap();
let context = block_on(async {
Context::new(
Path::new(&new_db),
100,
Events::new(),
StockStrings::new(),
)
.await
.unwrap()
});
(dir, context)
},
|(_dir, context)| {
let chats = &chats;
async move {
marknoticed_chat_benchmark(black_box(&context), black_box(chats)).await
}
},
BatchSize::PerIteration,
);
},
);
} else {
println!("env var not set: DELTACHAT_BENCHMARK_DATABASE");
}
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

View File

@@ -1,15 +1,14 @@
#![recursion_limit = "256"] #![recursion_limit = "256"]
use std::hint::black_box;
use std::path::PathBuf; use std::path::PathBuf;
use criterion::{Criterion, criterion_group, criterion_main}; use criterion::{black_box, criterion_group, criterion_main, Criterion};
use deltachat::{ use deltachat::{
Events,
config::Config, config::Config,
context::Context, context::Context,
imex::{ImexMode, imex}, imex::{imex, ImexMode},
receive_imf::receive_imf, receive_imf::receive_imf,
stock_str::StockStrings, stock_str::StockStrings,
Events,
}; };
use tempfile::tempdir; use tempfile::tempdir;

View File

@@ -1,11 +1,10 @@
#![recursion_limit = "256"] #![recursion_limit = "256"]
use std::hint::black_box;
use std::path::Path; use std::path::Path;
use criterion::{Criterion, criterion_group, criterion_main}; use criterion::{black_box, criterion_group, criterion_main, Criterion};
use deltachat::Events;
use deltachat::context::Context; use deltachat::context::Context;
use deltachat::stock_str::StockStrings; use deltachat::stock_str::StockStrings;
use deltachat::Events;
async fn search_benchmark(dbfile: impl AsRef<Path>) { async fn search_benchmark(dbfile: impl AsRef<Path>) {
let id = 100; let id = 100;

View File

@@ -1,17 +1,17 @@
#![recursion_limit = "256"] #![recursion_limit = "256"]
use criterion::{Criterion, criterion_group, criterion_main}; use criterion::{criterion_group, criterion_main, Criterion};
use deltachat::context::Context; use deltachat::context::Context;
use deltachat::stock_str::StockStrings; use deltachat::stock_str::StockStrings;
use deltachat::{Event, EventType, Events}; use deltachat::{info, Event, EventType, Events};
use tempfile::tempdir; use tempfile::tempdir;
async fn send_events_benchmark(context: &Context) { async fn send_events_benchmark(context: &Context) {
let emitter = context.get_event_emitter(); let emitter = context.get_event_emitter();
for _i in 0..1_000_000 { for _i in 0..1_000_000 {
context.emit_event(EventType::Info("interesting event...".to_string())); info!(context, "interesting event...");
} }
context.emit_event(EventType::Info("DONE".to_string())); info!(context, "DONE");
loop { loop {
match emitter.recv().await.unwrap() { match emitter.recv().await.unwrap() {

View File

@@ -11,7 +11,7 @@ filter_unconventional = false
split_commits = false split_commits = false
# regex for preprocessing the commit messages # regex for preprocessing the commit messages
commit_preprocessors = [ commit_preprocessors = [
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/chatmail/core/pull/${2}))"}, # replace pull request / issue numbers { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/deltachat/deltachat-core-rust/pull/${2}))"}, # replace pull request / issue numbers
] ]
# regex for parsing and grouping commits # regex for parsing and grouping commits
commit_parsers = [ commit_parsers = [
@@ -66,7 +66,7 @@ body = """
{% for commit in commits %} {% for commit in commits %}
- {% if commit.breaking %}[**breaking**] {% endif %}\ - {% if commit.breaking %}[**breaking**] {% endif %}\
{% if commit.scope %}{{ commit.scope }}: {% endif %}\ {% if commit.scope %}{{ commit.scope }}: {% endif %}\
{{ commit.message }}.\ {{ commit.message | upper_first }}.\
{% if commit.footers is defined %}\ {% if commit.footers is defined %}\
{% for footer in commit.footers %}{% if 'BREAKING CHANGE' in footer.token %} {% for footer in commit.footers %}{% if 'BREAKING CHANGE' in footer.token %}
{% raw %} {% endraw %}- {{ footer.value }}\ {% raw %} {% endraw %}- {{ footer.value }}\
@@ -82,11 +82,11 @@ footer = """
{% if release.version -%} {% if release.version -%}
{% if release.previous.version -%} {% if release.previous.version -%}
[{{ release.version | trim_start_matches(pat="v") }}]: \ [{{ release.version | trim_start_matches(pat="v") }}]: \
https://github.com/chatmail/core\ https://github.com/deltachat/deltachat-core-rust\
/compare/{{ release.previous.version }}..{{ release.version }} /compare/{{ release.previous.version }}..{{ release.version }}
{% endif -%} {% endif -%}
{% else -%} {% else -%}
[unreleased]: https://github.com/chatmail/core\ [unreleased]: https://github.com/deltachat/deltachat-core-rust\
/compare/{{ release.previous.version }}..HEAD /compare/{{ release.previous.version }}..HEAD
{% endif -%} {% endif -%}
{% endfor %} {% endfor %}

View File

@@ -9,6 +9,7 @@ license = "MPL-2.0"
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = { workspace = true }
once_cell = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
rusqlite = { workspace = true } # Needed in order to `impl rusqlite::types::ToSql for EmailAddress`. Could easily be put behind a feature. rusqlite = { workspace = true } # Needed in order to `impl rusqlite::types::ToSql for EmailAddress`. Could easily be put behind a feature.
chrono = { workspace = true, features = ["alloc", "clock", "std"] } chrono = { workspace = true, features = ["alloc", "clock", "std"] }

View File

@@ -29,14 +29,203 @@
use std::fmt; use std::fmt;
use std::ops::Deref; use std::ops::Deref;
use std::sync::LazyLock;
use anyhow::bail; use anyhow::bail;
use anyhow::Context as _;
use anyhow::Result; use anyhow::Result;
use chrono::{DateTime, NaiveDateTime};
use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
mod vcard; #[derive(Debug)]
pub use vcard::{make_vcard, parse_vcard, VcardContact}; /// A Contact, as represented in a VCard.
pub struct VcardContact {
/// The email address, vcard property `email`
pub addr: String,
/// This must be the name authorized by the contact itself, not a locally given name. Vcard
/// property `fn`. Can be empty, one should use `display_name()` to obtain the display name.
pub authname: String,
/// The contact's public PGP key in Base64, vcard property `key`
pub key: Option<String>,
/// The contact's profile image (=avatar) in Base64, vcard property `photo`
pub profile_image: Option<String>,
/// The timestamp when the vcard was created / last updated, vcard property `rev`
pub timestamp: Result<i64>,
}
impl VcardContact {
/// Returns the contact's display name.
pub fn display_name(&self) -> &str {
match self.authname.is_empty() {
false => &self.authname,
true => &self.addr,
}
}
}
/// Returns a vCard containing given contacts.
///
/// Calling [`parse_vcard()`] on the returned result is a reverse operation.
pub fn make_vcard(contacts: &[VcardContact]) -> String {
fn format_timestamp(c: &VcardContact) -> Option<String> {
let timestamp = *c.timestamp.as_ref().ok()?;
let datetime = DateTime::from_timestamp(timestamp, 0)?;
Some(datetime.format("%Y%m%dT%H%M%SZ").to_string())
}
let mut res = "".to_string();
for c in contacts {
let addr = &c.addr;
let display_name = c.display_name();
res += &format!(
"BEGIN:VCARD\n\
VERSION:4.0\n\
EMAIL:{addr}\n\
FN:{display_name}\n"
);
if let Some(key) = &c.key {
res += &format!("KEY:data:application/pgp-keys;base64,{key}\n");
}
if let Some(profile_image) = &c.profile_image {
res += &format!("PHOTO:data:image/jpeg;base64,{profile_image}\n");
}
if let Some(timestamp) = format_timestamp(c) {
res += &format!("REV:{timestamp}\n");
}
res += "END:VCARD\n";
}
res
}
/// Parses `VcardContact`s from a given `&str`.
pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
fn remove_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
let start_of_s = s.get(..prefix.len())?;
if start_of_s.eq_ignore_ascii_case(prefix) {
s.get(prefix.len()..)
} else {
None
}
}
fn vcard_property<'a>(s: &'a str, property: &str) -> Option<&'a str> {
let remainder = remove_prefix(s, property)?;
// If `s` is `EMAIL;TYPE=work:alice@example.com` and `property` is `EMAIL`,
// then `remainder` is now `;TYPE=work:alice@example.com`
// Note: This doesn't handle the case where there are quotes around a colon,
// like `NAME;Foo="Some quoted text: that contains a colon":value`.
// This could be improved in the future, but for now, the parsing is good enough.
let (params, value) = remainder.split_once(':')?;
// In the example from above, `params` is now `;TYPE=work`
// and `value` is now `alice@example.com`
if params
.chars()
.next()
.filter(|c| !c.is_ascii_punctuation() || *c == '_')
.is_some()
{
// `s` started with `property`, but the next character after it was not punctuation,
// so this line's property is actually something else
return None;
}
Some(value)
}
fn parse_datetime(datetime: &str) -> Result<i64> {
// According to https://www.rfc-editor.org/rfc/rfc6350#section-4.3.5, the timestamp
// is in ISO.8601.2004 format. DateTime::parse_from_rfc3339() apparently parses
// ISO.8601, but fails to parse any of the examples given.
// So, instead just parse using a format string.
// Parses 19961022T140000Z, 19961022T140000-05, or 19961022T140000-0500.
let timestamp = match DateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S%#z") {
Ok(datetime) => datetime.timestamp(),
// Parses 19961022T140000.
Err(e) => match NaiveDateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S") {
Ok(datetime) => datetime
.and_local_timezone(chrono::offset::Local)
.single()
.context("Could not apply local timezone to parsed date and time")?
.timestamp(),
Err(_) => return Err(e.into()),
},
};
Ok(timestamp)
}
// Remove line folding, see https://datatracker.ietf.org/doc/html/rfc6350#section-3.2
static NEWLINE_AND_SPACE_OR_TAB: Lazy<Regex> = Lazy::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();
while lines.peek().is_some() {
// Skip to the start of the vcard:
for line in lines.by_ref() {
if line.eq_ignore_ascii_case("BEGIN:VCARD") {
break;
}
}
let mut display_name = None;
let mut addr = None;
let mut key = None;
let mut photo = None;
let mut datetime = None;
for mut line in lines.by_ref() {
if let Some(remainder) = remove_prefix(line, "item1.") {
// Remove the group name, if the group is called "item1".
// If necessary, we can improve this to also remove groups that are called something different that "item1".
//
// Search "group name" at https://datatracker.ietf.org/doc/html/rfc6350 for more infos.
line = remainder;
}
if let Some(email) = vcard_property(line, "email") {
addr.get_or_insert(email);
} else if let Some(name) = vcard_property(line, "fn") {
display_name.get_or_insert(name);
} else if let Some(k) = remove_prefix(line, "KEY;PGP;ENCODING=BASE64:")
.or_else(|| remove_prefix(line, "KEY;TYPE=PGP;ENCODING=b:"))
.or_else(|| remove_prefix(line, "KEY:data:application/pgp-keys;base64,"))
.or_else(|| remove_prefix(line, "KEY;PREF=1:data:application/pgp-keys;base64,"))
{
key.get_or_insert(k);
} else if let Some(p) = remove_prefix(line, "PHOTO;JPEG;ENCODING=BASE64:")
.or_else(|| remove_prefix(line, "PHOTO;ENCODING=BASE64;JPEG:"))
.or_else(|| remove_prefix(line, "PHOTO;TYPE=JPEG;ENCODING=b:"))
.or_else(|| remove_prefix(line, "PHOTO;ENCODING=b;TYPE=JPEG:"))
.or_else(|| remove_prefix(line, "PHOTO;ENCODING=BASE64;TYPE=JPEG:"))
.or_else(|| remove_prefix(line, "PHOTO;TYPE=JPEG;ENCODING=BASE64:"))
.or_else(|| remove_prefix(line, "PHOTO:data:image/jpeg;base64,"))
{
photo.get_or_insert(p);
} else if let Some(rev) = vcard_property(line, "rev") {
datetime.get_or_insert(rev);
} else if line.eq_ignore_ascii_case("END:VCARD") {
break;
}
}
let (authname, addr) =
sanitize_name_and_addr(display_name.unwrap_or(""), addr.unwrap_or(""));
contacts.push(VcardContact {
authname,
addr,
key: key.map(|s| s.to_string()),
profile_image: photo.map(|s| s.to_string()),
timestamp: datetime
.context("No timestamp in vcard")
.and_then(parse_datetime),
});
}
contacts
}
/// Valid contact address. /// Valid contact address.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -68,7 +257,7 @@ impl ContactAddress {
pub fn new(s: &str) -> Result<Self> { pub fn new(s: &str) -> Result<Self> {
let addr = addr_normalize(s); let addr = addr_normalize(s);
if !may_be_valid_addr(&addr) { if !may_be_valid_addr(&addr) {
bail!("invalid address {s:?}"); bail!("invalid address {:?}", s);
} }
Ok(Self(addr.to_string())) Ok(Self(addr.to_string()))
} }
@@ -76,7 +265,7 @@ impl ContactAddress {
/// Allow converting [`ContactAddress`] to an SQLite type. /// Allow converting [`ContactAddress`] to an SQLite type.
impl rusqlite::types::ToSql for ContactAddress { impl rusqlite::types::ToSql for ContactAddress {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> { fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput> {
let val = rusqlite::types::Value::Text(self.0.to_string()); let val = rusqlite::types::Value::Text(self.0.to_string());
let out = rusqlite::types::ToSqlOutput::Owned(val); let out = rusqlite::types::ToSqlOutput::Owned(val);
Ok(out) Ok(out)
@@ -88,8 +277,7 @@ impl rusqlite::types::ToSql for ContactAddress {
/// - Removes special characters from the name, see [`sanitize_name()`] /// - Removes special characters from the name, see [`sanitize_name()`]
/// - Removes the name if it is equal to the address by setting it to "" /// - Removes the name if it is equal to the address by setting it to ""
pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) { pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) {
static ADDR_WITH_NAME_REGEX: LazyLock<Regex> = static ADDR_WITH_NAME_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new("(.*)<(.*)>").unwrap());
LazyLock::new(|| Regex::new("(.*)<(.*)>").unwrap());
let (name, addr) = if let Some(captures) = ADDR_WITH_NAME_REGEX.captures(addr.as_ref()) { let (name, addr) = if let Some(captures) = ADDR_WITH_NAME_REGEX.captures(addr.as_ref()) {
( (
if name.is_empty() { if name.is_empty() {
@@ -257,16 +445,16 @@ impl EmailAddress {
.chars() .chars()
.any(|c| c.is_whitespace() || c == '<' || c == '>') .any(|c| c.is_whitespace() || c == '<' || c == '>')
{ {
bail!("Email {input:?} must not contain whitespaces, '>' or '<'"); bail!("Email {:?} must not contain whitespaces, '>' or '<'", input);
} }
match &parts[..] { match &parts[..] {
[domain, local] => { [domain, local] => {
if local.is_empty() { if local.is_empty() {
bail!("empty string is not valid for local part in {input:?}"); bail!("empty string is not valid for local part in {:?}", input);
} }
if domain.is_empty() { if domain.is_empty() {
bail!("missing domain after '@' in {input:?}"); bail!("missing domain after '@' in {:?}", input);
} }
if domain.ends_with('.') { if domain.ends_with('.') {
bail!("Domain {domain:?} should not contain the dot in the end"); bail!("Domain {domain:?} should not contain the dot in the end");
@@ -276,13 +464,13 @@ impl EmailAddress {
domain: (*domain).to_string(), domain: (*domain).to_string(),
}) })
} }
_ => bail!("Email {input:?} must contain '@' character"), _ => bail!("Email {:?} must contain '@' character", input),
} }
} }
} }
impl rusqlite::types::ToSql for EmailAddress { impl rusqlite::types::ToSql for EmailAddress {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> { fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput> {
let val = rusqlite::types::Value::Text(self.to_string()); let val = rusqlite::types::Value::Text(self.to_string());
let out = rusqlite::types::ToSqlOutput::Owned(val); let out = rusqlite::types::ToSqlOutput::Owned(val);
Ok(out) Ok(out)
@@ -291,8 +479,124 @@ impl rusqlite::types::ToSql for EmailAddress {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use chrono::TimeZone;
use super::*; use super::*;
#[test]
fn test_vcard_thunderbird() {
let contacts = parse_vcard(
"BEGIN:VCARD
VERSION:4.0
FN:'Alice Mueller'
EMAIL;PREF=1:alice.mueller@posteo.de
UID:a8083264-ca47-4be7-98a8-8ec3db1447ca
END:VCARD
BEGIN:VCARD
VERSION:4.0
FN:'bobzzz@freenet.de'
EMAIL;PREF=1:bobzzz@freenet.de
UID:cac4fef4-6351-4854-bbe4-9b6df857eaed
END:VCARD
",
);
assert_eq!(contacts[0].addr, "alice.mueller@posteo.de".to_string());
assert_eq!(contacts[0].authname, "Alice Mueller".to_string());
assert_eq!(contacts[0].key, None);
assert_eq!(contacts[0].profile_image, None);
assert!(contacts[0].timestamp.is_err());
assert_eq!(contacts[1].addr, "bobzzz@freenet.de".to_string());
assert_eq!(contacts[1].authname, "".to_string());
assert_eq!(contacts[1].key, None);
assert_eq!(contacts[1].profile_image, None);
assert!(contacts[1].timestamp.is_err());
assert_eq!(contacts.len(), 2);
}
#[test]
fn test_vcard_simple_example() {
let contacts = parse_vcard(
"BEGIN:VCARD
VERSION:4.0
FN:Alice Wonderland
N:Wonderland;Alice;;;Ms.
GENDER:W
EMAIL;TYPE=work:alice@example.com
KEY;TYPE=PGP;ENCODING=b:[base64-data]
REV:20240418T184242Z
END:VCARD",
);
assert_eq!(contacts[0].addr, "alice@example.com".to_string());
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
assert_eq!(contacts[0].key, Some("[base64-data]".to_string()));
assert_eq!(contacts[0].profile_image, None);
assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762);
assert_eq!(contacts.len(), 1);
}
#[test]
fn test_make_and_parse_vcard() {
let contacts = [
VcardContact {
addr: "alice@example.org".to_string(),
authname: "Alice Wonderland".to_string(),
key: Some("[base64-data]".to_string()),
profile_image: Some("image in Base64".to_string()),
timestamp: Ok(1713465762),
},
VcardContact {
addr: "bob@example.com".to_string(),
authname: "".to_string(),
key: None,
profile_image: None,
timestamp: Ok(0),
},
];
let items = [
"BEGIN:VCARD\n\
VERSION:4.0\n\
EMAIL:alice@example.org\n\
FN:Alice Wonderland\n\
KEY:data:application/pgp-keys;base64,[base64-data]\n\
PHOTO:data:image/jpeg;base64,image in Base64\n\
REV:20240418T184242Z\n\
END:VCARD\n",
"BEGIN:VCARD\n\
VERSION:4.0\n\
EMAIL:bob@example.com\n\
FN:bob@example.com\n\
REV:19700101T000000Z\n\
END:VCARD\n",
];
let mut expected = "".to_string();
for len in 0..=contacts.len() {
let contacts = &contacts[0..len];
let vcard = make_vcard(contacts);
if len > 0 {
expected += items[len - 1];
}
assert_eq!(vcard, expected);
let parsed = parse_vcard(&vcard);
assert_eq!(parsed.len(), contacts.len());
for i in 0..parsed.len() {
assert_eq!(parsed[i].addr, contacts[i].addr);
assert_eq!(parsed[i].authname, contacts[i].authname);
assert_eq!(parsed[i].key, contacts[i].key);
assert_eq!(parsed[i].profile_image, contacts[i].profile_image);
assert_eq!(
parsed[i].timestamp.as_ref().unwrap(),
contacts[i].timestamp.as_ref().unwrap()
);
}
}
}
#[test] #[test]
fn test_contact_address() -> Result<()> { fn test_contact_address() -> Result<()> {
let alice_addr = "alice@example.org"; let alice_addr = "alice@example.org";
@@ -339,6 +643,112 @@ mod tests {
assert_eq!(EmailAddress::new("@d.tt").is_ok(), false); assert_eq!(EmailAddress::new("@d.tt").is_ok(), false);
} }
#[test]
fn test_vcard_android() {
let contacts = parse_vcard(
"BEGIN:VCARD
VERSION:2.1
N:;Bob;;;
FN:Bob
TEL;CELL:+1-234-567-890
EMAIL;HOME:bob@example.org
END:VCARD
BEGIN:VCARD
VERSION:2.1
N:;Alice;;;
FN:Alice
EMAIL;HOME:alice@example.org
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, None);
assert_eq!(contacts[1].addr, "alice@example.org".to_string());
assert_eq!(contacts[1].authname, "Alice".to_string());
assert_eq!(contacts[1].key, None);
assert_eq!(contacts[1].profile_image, None);
assert_eq!(contacts.len(), 2);
}
#[test]
fn test_vcard_local_datetime() {
let contacts = parse_vcard(
"BEGIN:VCARD\n\
VERSION:4.0\n\
FN:Alice Wonderland\n\
EMAIL;TYPE=work:alice@example.org\n\
REV:20240418T184242\n\
END:VCARD",
);
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0].addr, "alice@example.org".to_string());
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
assert_eq!(
*contacts[0].timestamp.as_ref().unwrap(),
chrono::offset::Local
.with_ymd_and_hms(2024, 4, 18, 18, 42, 42)
.unwrap()
.timestamp()
);
}
#[test]
fn test_vcard_with_base64_avatar() {
// This is not an actual base64-encoded avatar, it's just to test the parsing.
// This one is Android-like.
let vcard0 = "BEGIN:VCARD
VERSION:2.1
N:;Bob;;;
FN:Bob
EMAIL;HOME:bob@example.org
PHOTO;ENCODING=BASE64;JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEU
AAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAA
L8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q==
END:VCARD
";
// This one is DOS-like.
let vcard1 = vcard0.replace('\n', "\r\n");
for vcard in [vcard0, vcard1.as_str()] {
let contacts = parse_vcard(vcard);
assert_eq!(contacts.len(), 1);
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==");
}
}
#[test]
fn test_protonmail_vcard() {
let contacts = parse_vcard(
"BEGIN:VCARD
VERSION:4.0
FN;PREF=1:Alice Wonderland
UID:proton-web-03747582-328d-38dc-5ddd-000000000000
ITEM1.EMAIL;PREF=1:alice@example.org
ITEM1.KEY;PREF=1:data:application/pgp-keys;base64,aaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
ITEM1.KEY;PREF=2:data:application/pgp-keys;base64,bbbbbbbbbbbbbbbbbbbbbbbbb
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
ITEM1.X-PM-ENCRYPT:true
ITEM1.X-PM-SIGN:true
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!(contacts[0].timestamp.is_err());
assert_eq!(contacts[0].profile_image, None);
}
#[test] #[test]
fn test_sanitize_name() { fn test_sanitize_name() {
assert_eq!(&sanitize_name(" hello world "), "hello world"); assert_eq!(&sanitize_name(" hello world "), "hello world");

View File

@@ -1,282 +0,0 @@
use std::sync::LazyLock;
use anyhow::Context as _;
use anyhow::Result;
use chrono::DateTime;
use chrono::NaiveDateTime;
use regex::Regex;
use crate::sanitize_name_and_addr;
#[derive(Debug)]
/// A Contact, as represented in a VCard.
pub struct VcardContact {
/// The email address, vcard property `email`
pub addr: String,
/// This must be the name authorized by the contact itself, not a locally given name. Vcard
/// property `fn`. Can be empty, one should use `display_name()` to obtain the display name.
pub authname: String,
/// The contact's public PGP key in Base64, vcard property `key`
pub key: Option<String>,
/// The contact's profile image (=avatar) in Base64, vcard property `photo`
pub profile_image: Option<String>,
/// The biography, stored in the vcard property `note`
pub biography: Option<String>,
/// The timestamp when the vcard was created / last updated, vcard property `rev`
pub timestamp: Result<i64>,
}
impl VcardContact {
/// Returns the contact's display name.
pub fn display_name(&self) -> &str {
match self.authname.is_empty() {
false => &self.authname,
true => &self.addr,
}
}
}
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.
pub fn make_vcard(contacts: &[VcardContact]) -> String {
fn format_timestamp(c: &VcardContact) -> Option<String> {
let timestamp = *c.timestamp.as_ref().ok()?;
let datetime = DateTime::from_timestamp(timestamp, 0)?;
Some(datetime.format("%Y%m%dT%H%M%SZ").to_string())
}
let mut res = "".to_string();
for c in contacts {
// Mustn't contain ',', but it's easier to escape than to error out.
let addr = escape(&c.addr);
let display_name = escape(c.display_name());
res += &format!(
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
EMAIL:{addr}\r\n\
FN:{display_name}\r\n"
);
if let Some(key) = &c.key {
res += &format!("KEY:data:application/pgp-keys;base64\\,{key}\r\n");
}
if let Some(profile_image) = &c.profile_image {
res += &format!("PHOTO:data:image/jpeg;base64\\,{profile_image}\r\n");
}
if let Some(biography) = &c.biography {
res += &format!("NOTE:{}\r\n", escape(biography));
}
if let Some(timestamp) = format_timestamp(c) {
res += &format!("REV:{timestamp}\r\n");
}
res += "END:VCARD\r\n";
}
res
}
/// Parses `VcardContact`s from a given `&str`.
pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
fn remove_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
let start_of_s = s.get(..prefix.len())?;
if start_of_s.eq_ignore_ascii_case(prefix) {
s.get(prefix.len()..)
} else {
None
}
}
/// Returns (parameters, raw value) tuple.
fn vcard_property_raw<'a>(line: &'a str, property: &str) -> Option<(&'a str, &'a str)> {
let remainder = remove_prefix(line, property)?;
// If `s` is `EMAIL;TYPE=work:alice@example.com` and `property` is `EMAIL`,
// then `remainder` is now `;TYPE=work:alice@example.com`
// Note: This doesn't handle the case where there are quotes around a colon,
// like `NAME;Foo="Some quoted text: that contains a colon":value`.
// This could be improved in the future, but for now, the parsing is good enough.
let (mut params, value) = remainder.split_once(':')?;
// In the example from above, `params` is now `;TYPE=work`
// and `value` is now `alice@example.com`
if params
.chars()
.next()
.filter(|c| !c.is_ascii_punctuation() || *c == '_')
.is_some()
{
// `s` started with `property`, but the next character after it was not punctuation,
// so this line's property is actually something else
return None;
}
if let Some(p) = remove_prefix(params, ";") {
params = p;
}
if let Some(p) = remove_prefix(params, "PREF=1") {
params = p;
}
Some((params, value))
}
/// Returns (parameters, unescaped value) tuple.
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)))
}
fn base64_key(line: &str) -> Option<&str> {
let (params, value) = vcard_property_raw(line, "key")?;
if params.eq_ignore_ascii_case("PGP;ENCODING=BASE64")
|| params.eq_ignore_ascii_case("TYPE=PGP;ENCODING=b")
{
return Some(value);
}
remove_prefix(value, "data:application/pgp-keys;base64\\,")
// Old Delta Chat format.
.or_else(|| remove_prefix(value, "data:application/pgp-keys;base64,"))
}
fn base64_photo(line: &str) -> Option<&str> {
let (params, value) = vcard_property_raw(line, "photo")?;
if params.eq_ignore_ascii_case("JPEG;ENCODING=BASE64")
|| params.eq_ignore_ascii_case("ENCODING=BASE64;JPEG")
|| params.eq_ignore_ascii_case("TYPE=JPEG;ENCODING=b")
|| params.eq_ignore_ascii_case("ENCODING=b;TYPE=JPEG")
|| params.eq_ignore_ascii_case("ENCODING=BASE64;TYPE=JPEG")
|| params.eq_ignore_ascii_case("TYPE=JPEG;ENCODING=BASE64")
{
return Some(value);
}
remove_prefix(value, "data:image/jpeg;base64\\,")
// Old Delta Chat format.
.or_else(|| remove_prefix(value, "data:image/jpeg;base64,"))
}
fn parse_datetime(datetime: &str) -> Result<i64> {
// According to https://www.rfc-editor.org/rfc/rfc6350#section-4.3.5, the timestamp
// is in ISO.8601.2004 format. DateTime::parse_from_rfc3339() apparently parses
// ISO.8601, but fails to parse any of the examples given.
// So, instead just parse using a format string.
// Parses 19961022T140000Z, 19961022T140000-05, or 19961022T140000-0500.
let timestamp = match DateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S%#z") {
Ok(datetime) => datetime.timestamp(),
// Parses 19961022T140000.
Err(e) => match NaiveDateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S") {
Ok(datetime) => datetime
.and_local_timezone(chrono::offset::Local)
.single()
.context("Could not apply local timezone to parsed date and time")?
.timestamp(),
Err(_) => return Err(e.into()),
},
};
Ok(timestamp)
}
// Remove line folding, see https://datatracker.ietf.org/doc/html/rfc6350#section-3.2
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();
while lines.peek().is_some() {
// Skip to the start of the vcard:
for line in lines.by_ref() {
if line.eq_ignore_ascii_case("BEGIN:VCARD") {
break;
}
}
let mut display_name = None;
let mut addr = None;
let mut key = None;
let mut photo = None;
let mut biography = None;
let mut datetime = None;
for mut line in lines.by_ref() {
if let Some(remainder) = remove_prefix(line, "item1.") {
// Remove the group name, if the group is called "item1".
// If necessary, we can improve this to also remove groups that are called something different that "item1".
//
// Search "group name" at https://datatracker.ietf.org/doc/html/rfc6350 for more infos.
line = remainder;
}
if let Some((_params, email)) = vcard_property(line, "email") {
addr.get_or_insert(email);
} else if let Some((_params, name)) = vcard_property(line, "fn") {
display_name.get_or_insert(name);
} else if let Some(k) = base64_key(line) {
key.get_or_insert(k);
} else if let Some(p) = base64_photo(line) {
photo.get_or_insert(p);
} else if let Some((_params, bio)) = vcard_property(line, "note") {
biography.get_or_insert(bio);
} else if let Some((_params, rev)) = vcard_property(line, "rev") {
datetime.get_or_insert(rev);
} else if line.eq_ignore_ascii_case("END:VCARD") {
let (authname, addr) = sanitize_name_and_addr(
&display_name.unwrap_or_default(),
&addr.unwrap_or_default(),
);
contacts.push(VcardContact {
authname,
addr,
key: key.map(|s| s.to_string()),
profile_image: photo.map(|s| s.to_string()),
biography,
timestamp: datetime
.as_deref()
.context("No timestamp in vcard")
.and_then(parse_datetime),
});
break;
}
}
}
contacts
}
#[cfg(test)]
mod vcard_tests;

View File

@@ -1,289 +0,0 @@
use chrono::TimeZone as _;
use super::*;
#[test]
fn test_vcard_thunderbird() {
let contacts = parse_vcard(
"BEGIN:VCARD
VERSION:4.0
FN:'Alice Mueller'
EMAIL;PREF=1:alice.mueller@posteo.de
UID:a8083264-ca47-4be7-98a8-8ec3db1447ca
END:VCARD
BEGIN:VCARD
VERSION:4.0
FN:'bobzzz@freenet.de'
EMAIL;PREF=1:bobzzz@freenet.de
UID:cac4fef4-6351-4854-bbe4-9b6df857eaed
END:VCARD
",
);
assert_eq!(contacts[0].addr, "alice.mueller@posteo.de".to_string());
assert_eq!(contacts[0].authname, "Alice Mueller".to_string());
assert_eq!(contacts[0].key, None);
assert_eq!(contacts[0].profile_image, None);
assert!(contacts[0].timestamp.is_err());
assert_eq!(contacts[1].addr, "bobzzz@freenet.de".to_string());
assert_eq!(contacts[1].authname, "".to_string());
assert_eq!(contacts[1].key, None);
assert_eq!(contacts[1].profile_image, None);
assert!(contacts[1].timestamp.is_err());
assert_eq!(contacts.len(), 2);
}
#[test]
fn test_vcard_simple_example() {
let contacts = parse_vcard(
"BEGIN:VCARD
VERSION:4.0
FN:Alice Wonderland
N:Wonderland;Alice;;;Ms.
GENDER:W
EMAIL;TYPE=work:alice@example.com
KEY;TYPE=PGP;ENCODING=b:[base64-data]
REV:20240418T184242Z
END:VCARD",
);
assert_eq!(contacts[0].addr, "alice@example.com".to_string());
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
assert_eq!(contacts[0].key, Some("[base64-data]".to_string()));
assert_eq!(contacts[0].profile_image, None);
assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762);
assert_eq!(contacts.len(), 1);
}
#[test]
fn test_vcard_with_trailing_newline() {
let contacts = parse_vcard(
"BEGIN:VCARD\r
VERSION:4.0\r
FN:Alice Wonderland\r
N:Wonderland;Alice;;;Ms.\r
GENDER:W\r
EMAIL;TYPE=work:alice@example.com\r
KEY;TYPE=PGP;ENCODING=b:[base64-data]\r
REV:20240418T184242Z\r
END:VCARD\r
\r",
);
assert_eq!(contacts[0].addr, "alice@example.com".to_string());
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
assert_eq!(contacts[0].key, Some("[base64-data]".to_string()));
assert_eq!(contacts[0].profile_image, None);
assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762);
assert_eq!(contacts.len(), 1);
}
#[test]
fn test_make_and_parse_vcard() {
let contacts = [
VcardContact {
addr: "alice@example.org".to_string(),
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()),
timestamp: Ok(1713465762),
},
VcardContact {
addr: "bob@example.com".to_string(),
authname: "".to_string(),
key: None,
profile_image: None,
biography: None,
timestamp: Ok(0),
},
];
let items = [
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
EMAIL:alice@example.org\r\n\
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\
REV:20240418T184242Z\r\n\
END:VCARD\r\n",
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
EMAIL:bob@example.com\r\n\
FN:bob@example.com\r\n\
REV:19700101T000000Z\r\n\
END:VCARD\r\n",
];
let mut expected = "".to_string();
for len in 0..=contacts.len() {
let contacts = &contacts[0..len];
let vcard = make_vcard(contacts);
if len > 0 {
expected += items[len - 1];
}
assert_eq!(vcard, expected);
let parsed = parse_vcard(&vcard);
assert_eq!(parsed.len(), contacts.len());
for i in 0..parsed.len() {
assert_eq!(parsed[i].addr, contacts[i].addr);
assert_eq!(parsed[i].authname, contacts[i].authname);
assert_eq!(parsed[i].key, contacts[i].key);
assert_eq!(parsed[i].profile_image, contacts[i].profile_image);
assert_eq!(
parsed[i].timestamp.as_ref().unwrap(),
contacts[i].timestamp.as_ref().unwrap()
);
}
}
}
#[test]
fn test_vcard_android() {
let contacts = parse_vcard(
"BEGIN:VCARD
VERSION:2.1
N:;Bob;;;
FN:Bob
TEL;CELL:+1-234-567-890
EMAIL;HOME:bob@example.org
END:VCARD
BEGIN:VCARD
VERSION:2.1
N:;Alice;;;
FN:Alice
EMAIL;HOME:alice@example.org
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, None);
assert_eq!(contacts[1].addr, "alice@example.org".to_string());
assert_eq!(contacts[1].authname, "Alice".to_string());
assert_eq!(contacts[1].key, None);
assert_eq!(contacts[1].profile_image, None);
assert_eq!(contacts.len(), 2);
}
#[test]
fn test_vcard_local_datetime() {
let contacts = parse_vcard(
"BEGIN:VCARD\n\
VERSION:4.0\n\
FN:Alice Wonderland\n\
EMAIL;TYPE=work:alice@example.org\n\
REV:20240418T184242\n\
END:VCARD",
);
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0].addr, "alice@example.org".to_string());
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
assert_eq!(
*contacts[0].timestamp.as_ref().unwrap(),
chrono::offset::Local
.with_ymd_and_hms(2024, 4, 18, 18, 42, 42)
.unwrap()
.timestamp()
);
}
#[test]
fn test_vcard_with_base64_avatar() {
// This is not an actual base64-encoded avatar, it's just to test the parsing.
// This one is Android-like.
let vcard0 = "BEGIN:VCARD
VERSION:2.1
N:;Bob;;;
FN:Bob
EMAIL;HOME:bob@example.org
PHOTO;ENCODING=BASE64;JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEU
AAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAA
L8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q==
END:VCARD
";
// This one is DOS-like.
let vcard1 = vcard0.replace('\n', "\r\n");
for vcard in [vcard0, vcard1.as_str()] {
let contacts = parse_vcard(vcard);
assert_eq!(contacts.len(), 1);
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==");
}
}
#[test]
fn test_protonmail_vcard() {
let contacts = parse_vcard(
"BEGIN:VCARD
VERSION:4.0
FN;PREF=1:Alice Wonderland
UID:proton-web-03747582-328d-38dc-5ddd-000000000000
ITEM1.EMAIL;PREF=1:alice@example.org
ITEM1.KEY;PREF=1:data:application/pgp-keys;base64,aaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
ITEM1.KEY;PREF=2:data:application/pgp-keys;base64,bbbbbbbbbbbbbbbbbbbbbbbbb
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
ITEM1.X-PM-ENCRYPT:true
ITEM1.X-PM-SIGN:true
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!(contacts[0].timestamp.is_err());
assert_eq!(contacts[0].profile_image, None);
}
/// Proton at some point slightly changed the format of their vcards.
/// This also tests unescaped commas in PHOTO and KEY (old Delta Chat format).
#[test]
fn test_protonmail_vcard2() {
let contacts = parse_vcard(
r"BEGIN:VCARD
VERSION:4.0
FN;PREF=1:Alice
PHOTO;PREF=1:data:image/jpeg;base64,/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z
REV:Invalid Date
ITEM1.EMAIL;PREF=1:alice@example.org
KEY;PREF=1:data:application/pgp-keys;base64,xsaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa==
UID:proton-web-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
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!(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);
}

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "deltachat_ffi" name = "deltachat_ffi"
version = "2.50.0-dev" version = "1.152.0"
description = "Deltachat FFI" description = "Deltachat FFI"
edition = "2018" edition = "2018"
readme = "README.md" readme = "README.md"
@@ -15,7 +15,7 @@ crate-type = ["cdylib", "staticlib"]
[dependencies] [dependencies]
deltachat = { workspace = true, default-features = false } deltachat = { workspace = true, default-features = false }
deltachat-jsonrpc = { workspace = true } deltachat-jsonrpc = { workspace = true, optional = true }
libc = { workspace = true } libc = { workspace = true }
human-panic = { version = "2", default-features = false } human-panic = { version = "2", default-features = false }
num-traits = { workspace = true } num-traits = { workspace = true }
@@ -24,9 +24,11 @@ tokio = { workspace = true, features = ["rt-multi-thread"] }
anyhow = { workspace = true } anyhow = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
rand = { workspace = true } rand = { workspace = true }
once_cell = { workspace = true }
yerpc = { workspace = true, features = ["anyhow_expose"] } yerpc = { workspace = true, features = ["anyhow_expose"] }
[features] [features]
default = ["vendored"] default = ["vendored"]
vendored = ["deltachat/vendored", "deltachat-jsonrpc/vendored"] vendored = ["deltachat/vendored", "deltachat-jsonrpc/vendored"]
jsonrpc = ["dep:deltachat-jsonrpc"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <doxygenlayout version="1.0">
<doxygenlayout version="2.0"> <!-- Generated by doxygen 1.8.20 -->
<!-- Generated by doxygen 1.13.2 -->
<!-- Navigation index tabs for HTML output --> <!-- Navigation index tabs for HTML output -->
<navindex> <navindex>
<tab type="mainpage" visible="yes" title=""/> <tab type="mainpage" visible="yes" title=""/>
@@ -12,16 +11,10 @@
</tab> </tab>
<tab type="topics" visible="yes" title="Constants" intro="Here is a list of constants:"/> <tab type="topics" visible="yes" title="Constants" intro="Here is a list of constants:"/>
<tab type="pages" visible="yes" title="" intro=""/> <tab type="pages" visible="yes" title="" intro=""/>
<tab type="modules" visible="yes" title="" intro="">
<tab type="modulelist" visible="yes" title="" intro=""/>
<tab type="modulemembers" visible="yes" title="" intro=""/>
</tab>
<tab type="namespaces" visible="yes" title=""> <tab type="namespaces" visible="yes" title="">
<tab type="namespacelist" visible="yes" title="" intro=""/> <tab type="namespacelist" visible="yes" title="" intro=""/>
<tab type="namespacemembers" visible="yes" title="" intro=""/> <tab type="namespacemembers" visible="yes" title="" intro=""/>
</tab> </tab>
<tab type="concepts" visible="yes" title="">
</tab>
<tab type="interfaces" visible="yes" title=""> <tab type="interfaces" visible="yes" title="">
<tab type="interfacelist" visible="yes" title="" intro=""/> <tab type="interfacelist" visible="yes" title="" intro=""/>
<tab type="interfaceindex" visible="$ALPHABETICAL_INDEX" title=""/> <tab type="interfaceindex" visible="$ALPHABETICAL_INDEX" title=""/>
@@ -42,228 +35,4 @@
</tab> </tab>
<tab type="examples" visible="yes" title="" intro=""/> <tab type="examples" visible="yes" title="" intro=""/>
</navindex> </navindex>
<!-- Layout definition for a class page -->
<class>
<briefdescription visible="yes"/>
<includes visible="$SHOW_HEADERFILE"/>
<inheritancegraph visible="yes"/>
<collaborationgraph visible="yes"/>
<memberdecl>
<nestedclasses visible="yes" title=""/>
<publictypes visible="yes" title=""/>
<services visible="yes" title=""/>
<interfaces visible="yes" title=""/>
<publicslots visible="yes" title=""/>
<signals visible="yes" title=""/>
<publicmethods visible="yes" title=""/>
<publicstaticmethods visible="yes" title=""/>
<publicattributes visible="yes" title=""/>
<publicstaticattributes visible="yes" title=""/>
<protectedtypes visible="yes" title=""/>
<protectedslots visible="yes" title=""/>
<protectedmethods visible="yes" title=""/>
<protectedstaticmethods visible="yes" title=""/>
<protectedattributes visible="yes" title=""/>
<protectedstaticattributes visible="yes" title=""/>
<packagetypes visible="yes" title=""/>
<packagemethods visible="yes" title=""/>
<packagestaticmethods visible="yes" title=""/>
<packageattributes visible="yes" title=""/>
<packagestaticattributes visible="yes" title=""/>
<properties visible="yes" title=""/>
<events visible="yes" title=""/>
<privatetypes visible="yes" title=""/>
<privateslots visible="yes" title=""/>
<privatemethods visible="yes" title=""/>
<privatestaticmethods visible="yes" title=""/>
<privateattributes visible="yes" title=""/>
<privatestaticattributes visible="yes" title=""/>
<friends visible="yes" title=""/>
<related visible="yes" title="" subtitle=""/>
<membergroups visible="yes"/>
</memberdecl>
<detaileddescription visible="yes" title=""/>
<memberdef>
<inlineclasses visible="yes" title=""/>
<typedefs visible="yes" title=""/>
<enums visible="yes" title=""/>
<services visible="yes" title=""/>
<interfaces visible="yes" title=""/>
<constructors visible="yes" title=""/>
<functions visible="yes" title=""/>
<related visible="yes" title=""/>
<variables visible="yes" title=""/>
<properties visible="yes" title=""/>
<events visible="yes" title=""/>
</memberdef>
<allmemberslink visible="yes"/>
<usedfiles visible="$SHOW_USED_FILES"/>
<authorsection visible="yes"/>
</class>
<!-- Layout definition for a namespace page -->
<namespace>
<briefdescription visible="yes"/>
<memberdecl>
<nestednamespaces visible="yes" title=""/>
<constantgroups visible="yes" title=""/>
<interfaces visible="yes" title=""/>
<classes visible="yes" title=""/>
<concepts visible="yes" title=""/>
<structs visible="yes" title=""/>
<exceptions visible="yes" title=""/>
<typedefs visible="yes" title=""/>
<sequences visible="yes" title=""/>
<dictionaries visible="yes" title=""/>
<enums visible="yes" title=""/>
<functions visible="yes" title=""/>
<variables visible="yes" title=""/>
<properties visible="yes" title=""/>
<membergroups visible="yes" visible="yes"/>
</memberdecl>
<detaileddescription visible="yes" title=""/>
<memberdef>
<inlineclasses visible="yes" title=""/>
<typedefs visible="yes" title=""/>
<sequences visible="yes" title=""/>
<dictionaries visible="yes" title=""/>
<enums visible="yes" title=""/>
<functions visible="yes" title=""/>
<variables visible="yes" title=""/>
<properties visible="yes" title=""/>
</memberdef>
<authorsection visible="yes"/>
</namespace>
<!-- Layout definition for a concept page -->
<concept>
<briefdescription visible="yes"/>
<includes visible="$SHOW_HEADERFILE"/>
<definition visible="yes" title=""/>
<detaileddescription visible="yes" title=""/>
<authorsection visible="yes"/>
</concept>
<!-- Layout definition for a file page -->
<file>
<briefdescription visible="yes"/>
<includes visible="$SHOW_INCLUDE_FILES"/>
<includegraph visible="yes"/>
<includedbygraph visible="yes"/>
<sourcelink visible="yes"/>
<memberdecl>
<interfaces visible="yes" title=""/>
<classes visible="yes" title=""/>
<structs visible="yes" title=""/>
<exceptions visible="yes" title=""/>
<namespaces visible="yes" title=""/>
<concepts visible="yes" title=""/>
<constantgroups visible="yes" title=""/>
<defines visible="yes" title=""/>
<typedefs visible="yes" title=""/>
<sequences visible="yes" title=""/>
<dictionaries visible="yes" title=""/>
<enums visible="yes" title=""/>
<functions visible="yes" title=""/>
<variables visible="yes" title=""/>
<properties visible="yes" title=""/>
<membergroups visible="yes" visible="yes"/>
</memberdecl>
<detaileddescription visible="yes" title=""/>
<memberdef>
<inlineclasses visible="yes" title=""/>
<defines visible="yes" title=""/>
<typedefs visible="yes" title=""/>
<sequences visible="yes" title=""/>
<dictionaries visible="yes" title=""/>
<enums visible="yes" title=""/>
<functions visible="yes" title=""/>
<variables visible="yes" title=""/>
<properties visible="yes" title=""/>
</memberdef>
<authorsection/>
</file>
<!-- Layout definition for a group page -->
<group>
<briefdescription visible="yes"/>
<groupgraph visible="yes"/>
<memberdecl>
<nestedgroups visible="yes" title=""/>
<modules visible="yes" title=""/>
<dirs visible="yes" title=""/>
<files visible="yes" title=""/>
<namespaces visible="yes" title=""/>
<concepts visible="yes" title=""/>
<classes visible="yes" title=""/>
<defines visible="yes" title=""/>
<typedefs visible="yes" title=""/>
<sequences visible="yes" title=""/>
<dictionaries visible="yes" title=""/>
<enums visible="yes" title=""/>
<enumvalues visible="yes" title=""/>
<functions visible="yes" title=""/>
<variables visible="yes" title=""/>
<signals visible="yes" title=""/>
<publicslots visible="yes" title=""/>
<protectedslots visible="yes" title=""/>
<privateslots visible="yes" title=""/>
<events visible="yes" title=""/>
<properties visible="yes" title=""/>
<friends visible="yes" title=""/>
<membergroups visible="yes"/>
</memberdecl>
<detaileddescription visible="yes" title=""/>
<memberdef>
<pagedocs/>
<inlineclasses visible="yes" title=""/>
<defines visible="yes" title=""/>
<typedefs visible="yes" title=""/>
<sequences visible="yes" title=""/>
<dictionaries visible="yes" title=""/>
<enums visible="yes" title=""/>
<enumvalues visible="yes" title=""/>
<functions visible="yes" title=""/>
<variables visible="yes" title=""/>
<signals visible="yes" title=""/>
<publicslots visible="yes" title=""/>
<protectedslots visible="yes" title=""/>
<privateslots visible="yes" title=""/>
<events visible="yes" title=""/>
<properties visible="yes" title=""/>
<friends visible="yes" title=""/>
</memberdef>
<authorsection visible="yes"/>
</group>
<!-- Layout definition for a C++20 module page -->
<module>
<briefdescription visible="yes"/>
<exportedmodules visible="yes"/>
<memberdecl>
<concepts visible="yes" title=""/>
<classes visible="yes" title=""/>
<enums visible="yes" title=""/>
<typedefs visible="yes" title=""/>
<functions visible="yes" title=""/>
<variables visible="yes" title=""/>
<membergroups visible="yes" title=""/>
</memberdecl>
<detaileddescription visible="yes" title=""/>
<memberdecl>
<files visible="yes"/>
</memberdecl>
</module>
<!-- Layout definition for a directory page -->
<directory>
<briefdescription visible="yes"/>
<directorygraph visible="yes"/>
<memberdecl>
<dirs visible="yes"/>
<files visible="yes"/>
</memberdecl>
<detaileddescription visible="yes" title=""/>
</directory>
</doxygenlayout> </doxygenlayout>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,7 @@ pub enum Meaning {
} }
impl Lot { impl Lot {
pub fn get_text1(&self) -> Option<Cow<'_, str>> { pub fn get_text1(&self) -> Option<Cow<str>> {
match self { match self {
Self::Summary(summary) => match &summary.prefix { Self::Summary(summary) => match &summary.prefix {
None => None, None => None,
@@ -45,30 +45,27 @@ impl Lot {
Self::Qr(qr) => match qr { Self::Qr(qr) => match qr {
Qr::AskVerifyContact { .. } => None, Qr::AskVerifyContact { .. } => None,
Qr::AskVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)), Qr::AskVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::AskJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::FprOk { .. } => None, Qr::FprOk { .. } => None,
Qr::FprMismatch { .. } => None, Qr::FprMismatch { .. } => None,
Qr::FprWithoutAddr { fingerprint, .. } => Some(Cow::Borrowed(fingerprint)), Qr::FprWithoutAddr { fingerprint, .. } => Some(Cow::Borrowed(fingerprint)),
Qr::Account { domain } => Some(Cow::Borrowed(domain)), Qr::Account { domain } => Some(Cow::Borrowed(domain)),
Qr::Backup2 { .. } => None, Qr::Backup2 { .. } => None,
Qr::BackupTooNew { .. } => None, Qr::WebrtcInstance { domain, .. } => Some(Cow::Borrowed(domain)),
Qr::Proxy { host, port, .. } => Some(Cow::Owned(format!("{host}:{port}"))), Qr::Proxy { host, port, .. } => Some(Cow::Owned(format!("{host}:{port}"))),
Qr::Addr { draft, .. } => draft.as_deref().map(Cow::Borrowed), Qr::Addr { draft, .. } => draft.as_deref().map(Cow::Borrowed),
Qr::Url { url } => Some(Cow::Borrowed(url)), Qr::Url { url } => Some(Cow::Borrowed(url)),
Qr::Text { text } => Some(Cow::Borrowed(text)), Qr::Text { text } => Some(Cow::Borrowed(text)),
Qr::WithdrawVerifyContact { .. } => None, Qr::WithdrawVerifyContact { .. } => None,
Qr::WithdrawVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)), Qr::WithdrawVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::WithdrawJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::ReviveVerifyContact { .. } => None, Qr::ReviveVerifyContact { .. } => None,
Qr::ReviveVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)), Qr::ReviveVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::ReviveJoinBroadcast { name, .. } => Some(Cow::Borrowed(name)),
Qr::Login { address, .. } => Some(Cow::Borrowed(address)), Qr::Login { address, .. } => Some(Cow::Borrowed(address)),
}, },
Self::Error(err) => Some(Cow::Borrowed(err)), Self::Error(err) => Some(Cow::Borrowed(err)),
} }
} }
pub fn get_text2(&self) -> Option<Cow<'_, str>> { pub fn get_text2(&self) -> Option<Cow<str>> {
match self { match self {
Self::Summary(summary) => Some(summary.truncated_text(160)), Self::Summary(summary) => Some(summary.truncated_text(160)),
Self::Qr(_) => None, Self::Qr(_) => None,
@@ -101,23 +98,20 @@ impl Lot {
Self::Qr(qr) => match qr { Self::Qr(qr) => match qr {
Qr::AskVerifyContact { .. } => LotState::QrAskVerifyContact, Qr::AskVerifyContact { .. } => LotState::QrAskVerifyContact,
Qr::AskVerifyGroup { .. } => LotState::QrAskVerifyGroup, Qr::AskVerifyGroup { .. } => LotState::QrAskVerifyGroup,
Qr::AskJoinBroadcast { .. } => LotState::QrAskJoinBroadcast,
Qr::FprOk { .. } => LotState::QrFprOk, Qr::FprOk { .. } => LotState::QrFprOk,
Qr::FprMismatch { .. } => LotState::QrFprMismatch, Qr::FprMismatch { .. } => LotState::QrFprMismatch,
Qr::FprWithoutAddr { .. } => LotState::QrFprWithoutAddr, Qr::FprWithoutAddr { .. } => LotState::QrFprWithoutAddr,
Qr::Account { .. } => LotState::QrAccount, Qr::Account { .. } => LotState::QrAccount,
Qr::Backup2 { .. } => LotState::QrBackup2, Qr::Backup2 { .. } => LotState::QrBackup2,
Qr::BackupTooNew { .. } => LotState::QrBackupTooNew, Qr::WebrtcInstance { .. } => LotState::QrWebrtcInstance,
Qr::Proxy { .. } => LotState::QrProxy, Qr::Proxy { .. } => LotState::QrProxy,
Qr::Addr { .. } => LotState::QrAddr, Qr::Addr { .. } => LotState::QrAddr,
Qr::Url { .. } => LotState::QrUrl, Qr::Url { .. } => LotState::QrUrl,
Qr::Text { .. } => LotState::QrText, Qr::Text { .. } => LotState::QrText,
Qr::WithdrawVerifyContact { .. } => LotState::QrWithdrawVerifyContact, Qr::WithdrawVerifyContact { .. } => LotState::QrWithdrawVerifyContact,
Qr::WithdrawVerifyGroup { .. } => LotState::QrWithdrawVerifyGroup, Qr::WithdrawVerifyGroup { .. } => LotState::QrWithdrawVerifyGroup,
Qr::WithdrawJoinBroadcast { .. } => LotState::QrWithdrawJoinBroadcast,
Qr::ReviveVerifyContact { .. } => LotState::QrReviveVerifyContact, Qr::ReviveVerifyContact { .. } => LotState::QrReviveVerifyContact,
Qr::ReviveVerifyGroup { .. } => LotState::QrReviveVerifyGroup, Qr::ReviveVerifyGroup { .. } => LotState::QrReviveVerifyGroup,
Qr::ReviveJoinBroadcast { .. } => LotState::QrReviveJoinBroadcast,
Qr::Login { .. } => LotState::QrLogin, Qr::Login { .. } => LotState::QrLogin,
}, },
Self::Error(_err) => LotState::QrError, Self::Error(_err) => LotState::QrError,
@@ -130,23 +124,20 @@ impl Lot {
Self::Qr(qr) => match qr { Self::Qr(qr) => match qr {
Qr::AskVerifyContact { contact_id, .. } => contact_id.to_u32(), Qr::AskVerifyContact { contact_id, .. } => contact_id.to_u32(),
Qr::AskVerifyGroup { .. } => Default::default(), Qr::AskVerifyGroup { .. } => Default::default(),
Qr::AskJoinBroadcast { .. } => Default::default(),
Qr::FprOk { contact_id } => contact_id.to_u32(), Qr::FprOk { contact_id } => contact_id.to_u32(),
Qr::FprMismatch { contact_id } => contact_id.unwrap_or_default().to_u32(), Qr::FprMismatch { contact_id } => contact_id.unwrap_or_default().to_u32(),
Qr::FprWithoutAddr { .. } => Default::default(), Qr::FprWithoutAddr { .. } => Default::default(),
Qr::Account { .. } => Default::default(), Qr::Account { .. } => Default::default(),
Qr::Backup2 { .. } => Default::default(), Qr::Backup2 { .. } => Default::default(),
Qr::BackupTooNew { .. } => Default::default(), Qr::WebrtcInstance { .. } => Default::default(),
Qr::Proxy { .. } => Default::default(), Qr::Proxy { .. } => Default::default(),
Qr::Addr { contact_id, .. } => contact_id.to_u32(), Qr::Addr { contact_id, .. } => contact_id.to_u32(),
Qr::Url { .. } => Default::default(), Qr::Url { .. } => Default::default(),
Qr::Text { .. } => Default::default(), Qr::Text { .. } => Default::default(),
Qr::WithdrawVerifyContact { contact_id, .. } => contact_id.to_u32(), Qr::WithdrawVerifyContact { contact_id, .. } => contact_id.to_u32(),
Qr::WithdrawVerifyGroup { .. } | Qr::WithdrawJoinBroadcast { .. } => { Qr::WithdrawVerifyGroup { .. } => Default::default(),
Default::default()
}
Qr::ReviveVerifyContact { contact_id, .. } => contact_id.to_u32(), Qr::ReviveVerifyContact { contact_id, .. } => contact_id.to_u32(),
Qr::ReviveVerifyGroup { .. } | Qr::ReviveJoinBroadcast { .. } => Default::default(), Qr::ReviveVerifyGroup { .. } => Default::default(),
Qr::Login { .. } => Default::default(), Qr::Login { .. } => Default::default(),
}, },
Self::Error(_) => Default::default(), Self::Error(_) => Default::default(),
@@ -175,9 +166,6 @@ pub enum LotState {
/// text1=groupname /// text1=groupname
QrAskVerifyGroup = 202, QrAskVerifyGroup = 202,
/// text1=broadcast_name
QrAskJoinBroadcast = 204,
/// id=contact /// id=contact
QrFprOk = 210, QrFprOk = 210,
@@ -190,9 +178,12 @@ pub enum LotState {
/// text1=domain /// text1=domain
QrAccount = 250, QrAccount = 250,
QrBackup = 251,
QrBackup2 = 252, QrBackup2 = 252,
QrBackupTooNew = 255, /// text1=domain, text2=instance pattern
QrWebrtcInstance = 260,
/// text1=address, text2=protocol /// text1=address, text2=protocol
QrProxy = 271, QrProxy = 271,
@@ -213,15 +204,11 @@ pub enum LotState {
/// text1=groupname /// text1=groupname
QrWithdrawVerifyGroup = 502, QrWithdrawVerifyGroup = 502,
/// text1=broadcast channel name
QrWithdrawJoinBroadcast = 504,
QrReviveVerifyContact = 510, QrReviveVerifyContact = 510,
/// text1=groupname /// text1=groupname
QrReviveVerifyGroup = 512, QrReviveVerifyGroup = 512,
/// text1=groupname
QrReviveJoinBroadcast = 514,
/// text1=email_address /// text1=email_address
QrLogin = 520, QrLogin = 520,

View File

@@ -1,32 +1,45 @@
[package] [package]
name = "deltachat-jsonrpc" name = "deltachat-jsonrpc"
version = "2.50.0-dev" version = "1.152.0"
description = "DeltaChat JSON-RPC API" description = "DeltaChat JSON-RPC API"
edition = "2021" edition = "2021"
default-run = "deltachat-jsonrpc-server"
license = "MPL-2.0" license = "MPL-2.0"
repository = "https://github.com/chatmail/core" repository = "https://github.com/deltachat/deltachat-core-rust"
[[bin]]
name = "deltachat-jsonrpc-server"
path = "src/webserver.rs"
required-features = ["webserver"]
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = { workspace = true }
deltachat = { workspace = true } deltachat = { workspace = true }
deltachat-contact-tools = { workspace = true } deltachat-contact-tools = { workspace = true }
num-traits = { workspace = true } num-traits = { workspace = true }
schemars = "0.8.22" schemars = "0.8.21"
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
tempfile = { workspace = true }
log = { workspace = true }
async-channel = { workspace = true } async-channel = { workspace = true }
futures = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
yerpc = { workspace = true, features = ["anyhow_expose", "openrpc"] } yerpc = { workspace = true, features = ["anyhow_expose", "openrpc"] }
typescript-type-def = { version = "0.5.13", features = ["json_value"] } typescript-type-def = { version = "0.5.13", features = ["json_value"] }
tokio = { workspace = true } tokio = { workspace = true }
sanitize-filename = { workspace = true } sanitize-filename = { workspace = true }
walkdir = "2.5.0"
base64 = { workspace = true } base64 = { workspace = true }
# optional dependencies
axum = { version = "0.7", optional = true, features = ["ws"] }
env_logger = { version = "0.11.5", optional = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["full", "rt-multi-thread"] } tokio = { workspace = true, features = ["full", "rt-multi-thread"] }
tempfile = { workspace = true }
futures = { workspace = true }
[features] [features]
default = ["vendored"] default = ["vendored"]
webserver = ["dep:env_logger", "dep:axum", "tokio/full", "yerpc/support-axum"]
vendored = ["deltachat/vendored"] vendored = ["deltachat/vendored"]

View File

@@ -4,16 +4,46 @@ This crate provides a [JSON-RPC 2.0](https://www.jsonrpc.org/specification) inte
The JSON-RPC API is exposed in two fashions: The JSON-RPC API is exposed in two fashions:
* A executable `deltachat-rpc-server` that exposes the JSON-RPC API through stdio. * A executable that exposes the JSON-RPC API through a [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) server running on localhost.
* The JSON-RPC API can also be called through the [C FFI](../deltachat-ffi). It exposes the functions `dc_jsonrpc_init`, `dc_jsonrpc_request`, `dc_jsonrpc_next_response` and `dc_jsonrpc_unref`. See the docs in the [header file](../deltachat-ffi/deltachat.h) for details. * The JSON-RPC API can also be called through the [C FFI](../deltachat-ffi). The C FFI needs to be built with the `jsonrpc` feature. It will then expose the functions `dc_jsonrpc_init`, `dc_jsonrpc_request`, `dc_jsonrpc_next_response` and `dc_jsonrpc_unref`. See the docs in the [header file](../deltachat-ffi/deltachat.h) for details.
We also include a JavaScript and TypeScript client for the JSON-RPC API. The source for this is in the [`typescript`](typescript) folder. We also include a JavaScript and TypeScript client for the JSON-RPC API. The source for this is in the [`typescript`](typescript) folder. The client can easily be used with the WebSocket server to build DeltaChat apps for web browsers or Node.js. See the [examples](typescript/example) for details.
## Usage ## Usage
#### Running the WebSocket server
From within this folder, you can start the WebSocket server with the following command:
```sh
cargo run --features webserver
```
If you want to use the server in a production setup, first build it in release mode:
```sh
cargo build --features webserver --release
```
You will then find the `deltachat-jsonrpc-server` executable in your `target/release` folder.
The executable currently does not support any command-line arguments. By default, once started it will accept WebSocket connections on `ws://localhost:20808/ws`. It will store the persistent configuration and databases in a `./accounts` folder relative to the directory from where it is started.
The server can be configured with environment variables:
|variable|default|description|
|-|-|-|
|`DC_PORT`|`20808`|port to listen on|
|`DC_ACCOUNTS_PATH`|`./accounts`|path to storage directory|
If you are targeting other architectures (like KaiOS or Android), the webserver binary can be cross-compiled easily with [rust-cross](https://github.com/cross-rs/cross):
```sh
cross build --features=webserver --target armv7-linux-androideabi --release
```
#### Using the TypeScript/JavaScript client #### Using the TypeScript/JavaScript client
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. 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/Frando/yerpc/)). Find the source in the [`typescript`](typescript) folder.
To use it locally, first install the dependencies and compile the TypeScript code to JavaScript: To use it locally, first install the dependencies and compile the TypeScript code to JavaScript:
```sh ```sh
@@ -22,7 +52,15 @@ npm install
npm run build npm run build
``` ```
The JavaScript client is [published on NPM](https://www.npmjs.com/package/@deltachat/jsonrpc-client). The JavaScript client is not yet published on NPM (but will likely be soon). Currently, it is recommended to vendor the bundled build. After running `npm run build` as documented above, there will be a file `dist/deltachat.bundle.js`. This is an ESM module containing all dependencies. Copy this file to your project and import the DeltaChat class.
```typescript
import { DeltaChat } from './deltachat.bundle.js'
const dc = new DeltaChat('ws://localhost:20808/ws')
const accounts = await dc.rpc.getAllAccounts()
console.log('accounts', accounts)
```
A script is included to build autogenerated documentation, which includes all RPC methods: A script is included to build autogenerated documentation, which includes all RPC methods:
```sh ```sh
@@ -35,6 +73,18 @@ Then open the [`typescript/docs`](typescript/docs) folder in a web browser.
#### Running the example app #### Running the example app
We include a small demo web application that talks to the WebSocket server. It can be used for testing. Feel invited to expand this.
```sh
cd typescript
npm run build
npm run example:build
npm run example:start
```
Then, open [`http://localhost:8080/example.html`](http://localhost:8080/example.html) in a web browser.
Run `npm run example:dev` to live-rebuild the example app when files changes.
### Testing ### Testing
The crate includes both a basic Rust smoke test and more featureful integration tests that use the TypeScript client. The crate includes both a basic Rust smoke test and more featureful integration tests that use the TypeScript client.
@@ -54,12 +104,14 @@ cd typescript
npm run test npm run test
``` ```
This will build the `deltachat-jsonrpc-server` binary and then run a test suite. This will build the `deltachat-jsonrpc-server` binary and then run a test suite against the WebSocket server.
The test suite includes some tests that need online connectivity and a way to create test email accounts. To run these tests, set the `CHATMAIL_DOMAIN` environment variable to your testing email server domain. The test suite includes some tests that need online connectivity and a way to create test email accounts. To run these tests, talk to DeltaChat developers to get a token for the `testrun.org` service, or use a local instance of [`mailadm`](https://github.com/deltachat/docker-mailadm).
Then, set the `CHATMAIL_DOMAIN` environment variable to your testing email server domain.
``` ```
CHATMAIL_DOMAIN=ci-chatmail.testrun.org npm run test CHATMAIL_DOMAIN=chat.example.org npm run test
``` ```
#### Test Coverage #### Test Coverage

28
deltachat-jsonrpc/TODO.md Normal file
View File

@@ -0,0 +1,28 @@
# TODO
- [ ] different test type to simulate two devices: to test autocrypt_initiate_key_transfer & autocrypt_continue_key_transfer
## MVP - Websocket server&client
For kaiOS and other experiments, like a deltachat "web" over network from an android phone.
- [ ] coverage for a majority of the API
- [ ] Blobs served
- [ ] Blob upload (for attachments, setting profile-picture, importing backup and so on)
- [ ] other way blobs can be addressed when using websocket vs. jsonrpc over dc-node
- [ ] Web push API? At least some kind of notification hook closure this lib can accept.
### Other Ideas for the Websocket server
- [ ] make sure there can only be one connection at a time to the ws
- why? , it could give problems if its commanded from multiple connections
- [ ] encrypted connection?
- [ ] authenticated connection?
- [ ] Look into unit-testing for the proc macros?
- [ ] proc macro taking over doc comments to generated typescript file
## Desktop Apis
Incomplete todo for desktop api porting, just some remainders for points that might need more work:
- [ ] manual start/stop io functions in the api for context and accounts, so "not syncing all accounts" can still be done in desktop -> webserver should then not do start io on all accounts by default

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@ pub enum Account {
display_name: Option<String>, display_name: Option<String>,
addr: Option<String>, addr: Option<String>,
// size: u32, // size: u32,
profile_image: Option<String>, profile_image: Option<String>, // TODO: This needs to be converted to work with blob http server.
color: String, color: String,
/// Optional tag as "Work", "Family". /// Optional tag as "Work", "Family".
/// Meant to help profile owner to differ between profiles with similar names. /// Meant to help profile owner to differ between profiles with similar names.
@@ -32,10 +32,7 @@ impl Account {
let addr = ctx.get_config(Config::Addr).await?; let addr = ctx.get_config(Config::Addr).await?;
let profile_image = ctx.get_config(Config::Selfavatar).await?; let profile_image = ctx.get_config(Config::Selfavatar).await?;
let color = color_int_to_hex_string( let color = color_int_to_hex_string(
Contact::get_by_id(ctx, ContactId::SELF) Contact::get_by_id(ctx, ContactId::SELF).await?.get_color(),
.await?
.get_or_gen_color(ctx)
.await?,
); );
let private_tag = ctx.get_config(Config::PrivateTag).await?; let private_tag = ctx.get_config(Config::PrivateTag).await?;
Ok(Account::Configured { Ok(Account::Configured {

View File

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

View File

@@ -1,15 +1,17 @@
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use anyhow::{bail, Context as _, Result}; use anyhow::{bail, Context as _, Result};
use deltachat::chat::{self, get_chat_contacts, get_past_chat_contacts, ChatVisibility}; use deltachat::chat::{self, get_chat_contacts, ChatVisibility};
use deltachat::chat::{Chat, ChatId}; use deltachat::chat::{Chat, ChatId};
use deltachat::constants::Chattype; use deltachat::constants::Chattype;
use deltachat::contact::{Contact, ContactId}; use deltachat::contact::{Contact, ContactId};
use deltachat::context::Context; use deltachat::context::Context;
use num_traits::cast::ToPrimitive;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use typescript_type_def::TypeDef; use typescript_type_def::TypeDef;
use super::color_int_to_hex_string; use super::color_int_to_hex_string;
use super::contact::ContactObject;
#[derive(Serialize, TypeDef, schemars::JsonSchema)] #[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -17,57 +19,35 @@ pub struct FullChat {
id: u32, id: u32,
name: String, name: String,
/// True if the chat is encrypted. /// True if the chat is protected.
/// This means that all messages in the chat are encrypted,
/// and all contacts in the chat are "key-contacts",
/// i.e. identified by the PGP key fingerprint.
/// ///
/// False if the chat is unencrypted. /// UI should display a green checkmark
/// This means that all messages in the chat are unencrypted, /// in the chat title,
/// and all contacts in the chat are "address-contacts", /// in the chat profile title and
/// i.e. identified by the email address. /// in the chatlist item
/// The UI should mark this chat e.g. with a mail-letter icon. /// if chat protection is enabled.
/// /// UI should also display a green checkmark
/// Unencrypted groups are called "ad-hoc groups" /// in the contact profile
/// and the user can't add/remove members, /// if 1:1 chat with this contact exists and is protected.
/// create a QR invite code, is_protected: bool,
/// or set an avatar.
/// These options should therefore be disabled in the UI.
///
/// Note that it can happen that an encrypted chat
/// contains unencrypted messages that were received in core <= v1.159.*
/// and vice versa.
///
/// See also `is_key_contact` on `Contact`.
is_encrypted: bool,
profile_image: Option<String>, //BLOBS ? profile_image: Option<String>, //BLOBS ?
archived: bool, archived: bool,
pinned: bool, pinned: bool,
// subtitle - will be moved to frontend because it uses translation functions // subtitle - will be moved to frontend because it uses translation functions
chat_type: JsonrpcChatType, chat_type: u32,
is_unpromoted: bool, is_unpromoted: bool,
is_self_talk: bool, is_self_talk: bool,
contacts: Vec<ContactObject>,
contact_ids: Vec<u32>, contact_ids: Vec<u32>,
/// Contact IDs of the past chat members.
past_contact_ids: Vec<u32>,
color: String, color: String,
fresh_message_counter: usize, fresh_message_counter: usize,
// is_group - please check over chat.type in frontend instead // is_group - please check over chat.type in frontend instead
is_contact_request: bool, is_contact_request: bool,
is_protection_broken: bool,
is_device_chat: bool, is_device_chat: bool,
/// Note that this is different from
/// [`ChatListItem::is_self_in_group`](`crate::api::types::chat_list::ChatListItemFetchResult::ChatListItem::is_self_in_group`).
/// This property should only be accessed
/// when [`FullChat::chat_type`] is [`Chattype::Group`].
//
// We could utilize [`Chat::is_self_in_chat`],
// but that would be an extra DB query.
self_in_group: bool, self_in_group: bool,
is_muted: bool, is_muted: bool,
ephemeral_timer: u32, ephemeral_timer: u32, //TODO look if there are more important properties in newer core versions
can_send: bool, can_send: bool,
was_seen_recently: bool, was_seen_recently: bool,
mailing_list_address: Option<String>, mailing_list_address: Option<String>,
@@ -79,7 +59,20 @@ impl FullChat {
let chat = Chat::load_from_db(context, rust_chat_id).await?; let chat = Chat::load_from_db(context, rust_chat_id).await?;
let contact_ids = get_chat_contacts(context, rust_chat_id).await?; let contact_ids = get_chat_contacts(context, rust_chat_id).await?;
let past_contact_ids = get_past_chat_contacts(context, rust_chat_id).await?;
let mut contacts = Vec::with_capacity(contact_ids.len());
for contact_id in &contact_ids {
contacts.push(
ContactObject::try_from_dc_contact(
context,
Contact::get_by_id(context, *contact_id)
.await
.context("failed to load contact")?,
)
.await?,
)
}
let profile_image = match chat.get_profile_image(context).await? { let profile_image = match chat.get_profile_image(context).await? {
Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()), Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()),
@@ -109,18 +102,19 @@ impl FullChat {
Ok(FullChat { Ok(FullChat {
id: chat_id, id: chat_id,
name: chat.name.clone(), name: chat.name.clone(),
is_encrypted: chat.is_encrypted(context).await?, is_protected: chat.is_protected(),
profile_image, //BLOBS ? profile_image, //BLOBS ?
archived: chat.get_visibility() == chat::ChatVisibility::Archived, archived: chat.get_visibility() == chat::ChatVisibility::Archived,
pinned: chat.get_visibility() == chat::ChatVisibility::Pinned, pinned: chat.get_visibility() == chat::ChatVisibility::Pinned,
chat_type: chat.get_type().into(), chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
is_unpromoted: chat.is_unpromoted(), is_unpromoted: chat.is_unpromoted(),
is_self_talk: chat.is_self_talk(), is_self_talk: chat.is_self_talk(),
contacts,
contact_ids: contact_ids.iter().map(|id| id.to_u32()).collect(), contact_ids: contact_ids.iter().map(|id| id.to_u32()).collect(),
past_contact_ids: past_contact_ids.iter().map(|id| id.to_u32()).collect(),
color, color,
fresh_message_counter, fresh_message_counter,
is_contact_request: chat.is_contact_request(), is_contact_request: chat.is_contact_request(),
is_protection_broken: chat.is_protection_broken(),
is_device_chat: chat.is_device_talk(), is_device_chat: chat.is_device_talk(),
self_in_group: contact_ids.contains(&ContactId::SELF), self_in_group: contact_ids.contains(&ContactId::SELF),
is_muted: chat.is_muted(), is_muted: chat.is_muted(),
@@ -133,6 +127,7 @@ impl FullChat {
} }
/// cheaper version of fullchat, omits: /// cheaper version of fullchat, omits:
/// - contacts
/// - contact_ids /// - contact_ids
/// - fresh_message_counter /// - fresh_message_counter
/// - ephemeral_timer /// - ephemeral_timer
@@ -147,38 +142,26 @@ pub struct BasicChat {
id: u32, id: u32,
name: String, name: String,
/// True if the chat is encrypted. /// True if the chat is protected.
/// This means that all messages in the chat are encrypted,
/// and all contacts in the chat are "key-contacts",
/// i.e. identified by the PGP key fingerprint.
/// ///
/// False if the chat is unencrypted. /// UI should display a green checkmark
/// This means that all messages in the chat are unencrypted, /// in the chat title,
/// and all contacts in the chat are "address-contacts", /// in the chat profile title and
/// i.e. identified by the email address. /// in the chatlist item
/// The UI should mark this chat e.g. with a mail-letter icon. /// if chat protection is enabled.
/// /// UI should also display a green checkmark
/// Unencrypted groups are called "ad-hoc groups" /// in the contact profile
/// and the user can't add/remove members, /// if 1:1 chat with this contact exists and is protected.
/// create a QR invite code, is_protected: bool,
/// or set an avatar.
/// These options should therefore be disabled in the UI.
///
/// Note that it can happen that an encrypted chat
/// contains unencrypted messages that were received in core <= v1.159.*
/// and vice versa.
///
/// See also `is_key_contact` on `Contact`.
is_encrypted: bool,
profile_image: Option<String>, //BLOBS ? profile_image: Option<String>, //BLOBS ?
archived: bool, archived: bool,
pinned: bool, pinned: bool,
chat_type: JsonrpcChatType, chat_type: u32,
is_unpromoted: bool, is_unpromoted: bool,
is_self_talk: bool, is_self_talk: bool,
color: String, color: String,
is_contact_request: bool, is_contact_request: bool,
is_protection_broken: bool,
is_device_chat: bool, is_device_chat: bool,
is_muted: bool, is_muted: bool,
} }
@@ -197,15 +180,16 @@ impl BasicChat {
Ok(BasicChat { Ok(BasicChat {
id: chat_id, id: chat_id,
name: chat.name.clone(), name: chat.name.clone(),
is_encrypted: chat.is_encrypted(context).await?, is_protected: chat.is_protected(),
profile_image, //BLOBS ? profile_image, //BLOBS ?
archived: chat.get_visibility() == chat::ChatVisibility::Archived, archived: chat.get_visibility() == chat::ChatVisibility::Archived,
pinned: chat.get_visibility() == chat::ChatVisibility::Pinned, pinned: chat.get_visibility() == chat::ChatVisibility::Pinned,
chat_type: chat.get_type().into(), chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
is_unpromoted: chat.is_unpromoted(), is_unpromoted: chat.is_unpromoted(),
is_self_talk: chat.is_self_talk(), is_self_talk: chat.is_self_talk(),
color, color,
is_contact_request: chat.is_contact_request(), is_contact_request: chat.is_contact_request(),
is_protection_broken: chat.is_protection_broken(),
is_device_chat: chat.is_device_talk(), is_device_chat: chat.is_device_talk(),
is_muted: chat.is_muted(), is_muted: chat.is_muted(),
}) })
@@ -240,52 +224,18 @@ impl MuteDuration {
#[derive(Clone, Serialize, Deserialize, TypeDef, schemars::JsonSchema)] #[derive(Clone, Serialize, Deserialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "ChatVisibility")] #[serde(rename = "ChatVisibility")]
pub enum JsonrpcChatVisibility { pub enum JSONRPCChatVisibility {
Normal, Normal,
Archived, Archived,
Pinned, Pinned,
} }
impl JsonrpcChatVisibility { impl JSONRPCChatVisibility {
pub fn into_core_type(self) -> ChatVisibility { pub fn into_core_type(self) -> ChatVisibility {
match self { match self {
JsonrpcChatVisibility::Normal => ChatVisibility::Normal, JSONRPCChatVisibility::Normal => ChatVisibility::Normal,
JsonrpcChatVisibility::Archived => ChatVisibility::Archived, JSONRPCChatVisibility::Archived => ChatVisibility::Archived,
JsonrpcChatVisibility::Pinned => ChatVisibility::Pinned, JSONRPCChatVisibility::Pinned => ChatVisibility::Pinned,
}
}
}
#[derive(Clone, Serialize, Deserialize, PartialEq, TypeDef, schemars::JsonSchema)]
#[serde(rename = "ChatType")]
pub enum JsonrpcChatType {
Single,
Group,
Mailinglist,
OutBroadcast,
InBroadcast,
}
impl From<Chattype> for JsonrpcChatType {
fn from(chattype: Chattype) -> Self {
match chattype {
Chattype::Single => JsonrpcChatType::Single,
Chattype::Group => JsonrpcChatType::Group,
Chattype::Mailinglist => JsonrpcChatType::Mailinglist,
Chattype::OutBroadcast => JsonrpcChatType::OutBroadcast,
Chattype::InBroadcast => JsonrpcChatType::InBroadcast,
}
}
}
impl From<JsonrpcChatType> for Chattype {
fn from(chattype: JsonrpcChatType) -> Self {
match chattype {
JsonrpcChatType::Single => Chattype::Single,
JsonrpcChatType::Group => Chattype::Group,
JsonrpcChatType::Mailinglist => Chattype::Mailinglist,
JsonrpcChatType::OutBroadcast => Chattype::OutBroadcast,
JsonrpcChatType::InBroadcast => Chattype::InBroadcast,
} }
} }
} }

View File

@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use deltachat::chat::{Chat, ChatId}; use deltachat::chat::{Chat, ChatId};
use deltachat::chatlist::get_last_message_for_chat; use deltachat::chatlist::get_last_message_for_chat;
use deltachat::constants::*; use deltachat::constants::*;
use deltachat::contact::Contact; use deltachat::contact::{Contact, ContactId};
use deltachat::{ use deltachat::{
chat::{get_chat_contacts, ChatVisibility}, chat::{get_chat_contacts, ChatVisibility},
chatlist::Chatlist, chatlist::Chatlist,
@@ -11,7 +11,6 @@ use num_traits::cast::ToPrimitive;
use serde::Serialize; use serde::Serialize;
use typescript_type_def::TypeDef; use typescript_type_def::TypeDef;
use super::chat::JsonrpcChatType;
use super::color_int_to_hex_string; use super::color_int_to_hex_string;
use super::message::MessageViewtype; use super::message::MessageViewtype;
@@ -24,38 +23,13 @@ pub enum ChatListItemFetchResult {
name: String, name: String,
avatar_path: Option<String>, avatar_path: Option<String>,
color: String, color: String,
chat_type: JsonrpcChatType,
last_updated: Option<i64>, last_updated: Option<i64>,
summary_text1: String, summary_text1: String,
summary_text2: String, summary_text2: String,
summary_status: u32, summary_status: u32,
/// showing preview if last chat message is image /// showing preview if last chat message is image
summary_preview_image: Option<String>, summary_preview_image: Option<String>,
is_protected: bool,
/// True if the chat is encrypted.
/// This means that all messages in the chat are encrypted,
/// and all contacts in the chat are "key-contacts",
/// i.e. identified by the PGP key fingerprint.
///
/// False if the chat is unencrypted.
/// This means that all messages in the chat are unencrypted,
/// and all contacts in the chat are "address-contacts",
/// i.e. identified by the email address.
/// The UI should mark this chat e.g. with a mail-letter icon.
///
/// Unencrypted groups are called "ad-hoc groups"
/// and the user can't add/remove members,
/// create a QR invite code,
/// or set an avatar.
/// These options should therefore be disabled in the UI.
///
/// Note that it can happen that an encrypted chat
/// contains unencrypted messages that were received in core <= v1.159.*
/// and vice versa.
///
/// See also `is_key_contact` on `Contact`.
is_encrypted: bool,
/// deprecated 2025-07, use chat_type instead
is_group: bool, is_group: bool,
fresh_message_counter: usize, fresh_message_counter: usize,
is_self_talk: bool, is_self_talk: bool,
@@ -66,6 +40,8 @@ pub enum ChatListItemFetchResult {
is_pinned: bool, is_pinned: bool,
is_muted: bool, is_muted: bool,
is_contact_request: bool, is_contact_request: bool,
/// true when chat is a broadcastlist
is_broadcast: bool,
/// contact id if this is a dm chat (for view profile entry in context menu) /// contact id if this is a dm chat (for view profile entry in context menu)
dm_chat_contact: Option<u32>, dm_chat_contact: Option<u32>,
was_seen_recently: bool, was_seen_recently: bool,
@@ -127,8 +103,11 @@ pub(crate) async fn get_chat_list_item_by_id(
None => (None, None), None => (None, None),
}; };
let chat_contacts = get_chat_contacts(ctx, chat_id).await?;
let self_in_group = chat_contacts.contains(&ContactId::SELF);
let (dm_chat_contact, was_seen_recently) = if chat.get_type() == Chattype::Single { let (dm_chat_contact, was_seen_recently) = if chat.get_type() == Chattype::Single {
let chat_contacts = get_chat_contacts(ctx, chat_id).await?;
let contact = chat_contacts.first(); let contact = chat_contacts.first();
let was_seen_recently = match contact { let was_seen_recently = match contact {
Some(contact) => Contact::get_by_id(ctx, *contact) Some(contact) => Contact::get_by_id(ctx, *contact)
@@ -152,23 +131,23 @@ pub(crate) async fn get_chat_list_item_by_id(
name: chat.get_name().to_owned(), name: chat.get_name().to_owned(),
avatar_path, avatar_path,
color, color,
chat_type: chat.get_type().into(),
last_updated, last_updated,
summary_text1, summary_text1,
summary_text2, summary_text2,
summary_status: summary.state.to_u32().expect("impossible"), // idea and a function to transform the constant to strings? or return string enum summary_status: summary.state.to_u32().expect("impossible"), // idea and a function to transform the constant to strings? or return string enum
summary_preview_image, summary_preview_image,
is_encrypted: chat.is_encrypted(ctx).await?, is_protected: chat.is_protected(),
is_group: chat.get_type() == Chattype::Group, is_group: chat.get_type() == Chattype::Group,
fresh_message_counter, fresh_message_counter,
is_self_talk: chat.is_self_talk(), is_self_talk: chat.is_self_talk(),
is_device_talk: chat.is_device_talk(), is_device_talk: chat.is_device_talk(),
is_self_in_group: chat.is_self_in_chat(ctx).await?, is_self_in_group: self_in_group,
is_sending_location: chat.is_sending_locations(), is_sending_location: chat.is_sending_locations(),
is_archived: visibility == ChatVisibility::Archived, is_archived: visibility == ChatVisibility::Archived,
is_pinned: visibility == ChatVisibility::Pinned, is_pinned: visibility == ChatVisibility::Pinned,
is_muted: chat.is_muted(), is_muted: chat.is_muted(),
is_contact_request: chat.is_contact_request(), is_contact_request: chat.is_contact_request(),
is_broadcast: chat.get_type() == Chattype::Broadcast,
dm_chat_contact, dm_chat_contact,
was_seen_recently, was_seen_recently,
last_message_type: message_type, last_message_type: message_type,

View File

@@ -1,6 +1,6 @@
use anyhow::Result; use anyhow::Result;
use deltachat::color;
use deltachat::context::Context; use deltachat::context::Context;
use deltachat::key::{DcKey, SignedPublicKey};
use serde::Serialize; use serde::Serialize;
use typescript_type_def::TypeDef; use typescript_type_def::TypeDef;
@@ -19,47 +19,29 @@ pub struct ContactObject {
profile_image: Option<String>, // BLOBS profile_image: Option<String>, // BLOBS
name_and_addr: String, name_and_addr: String,
is_blocked: bool, is_blocked: bool,
/// Is the contact a key contact.
is_key_contact: bool,
/// Is encryption available for this contact.
///
/// This can only be true for key-contacts.
/// However, it is possible to have a key-contact
/// for which encryption is not available because we don't have a key yet,
/// e.g. if we just scanned the fingerprint from a QR code.
e2ee_avail: bool, e2ee_avail: bool,
/// True if the contact /// True if the contact can be added to verified groups.
/// can be added to protected chats
/// because SELF and contact have verified their fingerprints in both directions.
/// ///
/// See [`Self::verifier_id`]/`Contact.verifierId` for a guidance how to display these information. /// If this is true
/// UI should display green checkmark after the contact name
/// in contact list items,
/// in chat member list items
/// and in profiles if no chat with the contact exist.
is_verified: bool, is_verified: bool,
/// The contact ID that verified a contact. /// True if the contact profile title should have a green checkmark.
/// ///
/// As verifier may be unknown, /// This indicates whether 1:1 chat has a green checkmark
/// use [`Self::is_verified`]/`Contact.isVerified` to check if a contact can be added to a protected chat. /// or will have a green checkmark if created.
is_profile_verified: bool,
/// The ID of the contact that verified this contact.
/// ///
/// UI should display the information in the contact's profile as follows: /// If this is present,
/// /// display a green checkmark and "Introduced by ..."
/// - If `verifierId` != 0, /// string followed by the verifier contact name and address
/// display text "Introduced by ..." /// in the contact profile.
/// with the name of the contact.
/// Prefix the text by a green checkmark.
///
/// - If `verifierId` == 0 and `isVerified` != 0,
/// display "Introduced" prefixed by a green checkmark.
///
/// - if `verifierId` == 0 and `isVerified` == 0,
/// display nothing
///
/// This contains the contact ID of the verifier.
/// If it is `DC_CONTACT_ID_SELF`, we verified the contact ourself.
/// If it is None/Null, we don't have verifier information or
/// the contact is not verified.
verifier_id: Option<u32>, verifier_id: Option<u32>,
/// the contact's last seen timestamp /// the contact's last seen timestamp
@@ -80,11 +62,11 @@ impl ContactObject {
None => None, None => None,
}; };
let is_verified = contact.is_verified(context).await?; let is_verified = contact.is_verified(context).await?;
let is_profile_verified = contact.is_profile_verified(context).await?;
let verifier_id = contact let verifier_id = contact
.get_verifier_id(context) .get_verifier_id(context)
.await? .await?
.flatten()
.map(|contact_id| contact_id.to_u32()); .map(|contact_id| contact_id.to_u32());
Ok(ContactObject { Ok(ContactObject {
@@ -98,9 +80,9 @@ impl ContactObject {
profile_image, //BLOBS profile_image, //BLOBS
name_and_addr: contact.get_name_n_addr(), name_and_addr: contact.get_name_n_addr(),
is_blocked: contact.is_blocked(), is_blocked: contact.is_blocked(),
is_key_contact: contact.is_key_contact(),
e2ee_avail: contact.e2ee_avail(context).await?, e2ee_avail: contact.e2ee_avail(context).await?,
is_verified, is_verified,
is_profile_verified,
verifier_id, verifier_id,
last_seen: contact.last_seen(), last_seen: contact.last_seen(),
was_seen_recently: contact.was_seen_recently(), was_seen_recently: contact.was_seen_recently(),
@@ -129,13 +111,7 @@ pub struct VcardContact {
impl From<deltachat_contact_tools::VcardContact> for VcardContact { impl From<deltachat_contact_tools::VcardContact> for VcardContact {
fn from(vc: deltachat_contact_tools::VcardContact) -> Self { fn from(vc: deltachat_contact_tools::VcardContact) -> Self {
let display_name = vc.display_name().to_string(); let display_name = vc.display_name().to_string();
let is_self = false; let color = color::str_to_color(&vc.addr.to_lowercase());
let fpr = vc.key.as_deref().and_then(|k| {
SignedPublicKey::from_base64(k)
.ok()
.map(|k| k.dc_fingerprint())
});
let color = deltachat::contact::get_color(is_self, &vc.addr, &fpr);
Self { Self {
addr: vc.addr, addr: vc.addr,
display_name, display_name,

View File

@@ -2,8 +2,6 @@ use deltachat::{Event as CoreEvent, EventType as CoreEventType};
use serde::Serialize; use serde::Serialize;
use typescript_type_def::TypeDef; use typescript_type_def::TypeDef;
use super::chat::JsonrpcChatType;
#[derive(Serialize, TypeDef, schemars::JsonSchema)] #[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Event { pub struct Event {
@@ -86,78 +84,43 @@ pub enum EventType {
/// - Messages sent, received or removed /// - Messages sent, received or removed
/// - Chats created, deleted or archived /// - Chats created, deleted or archived
/// - A draft has been set /// - A draft has been set
///
/// `chatId` is set if only a single chat is affected by the changes, otherwise 0.
/// `msgId` is set if only a single message is affected by the changes, otherwise 0.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
MsgsChanged { MsgsChanged { chat_id: u32, msg_id: u32 },
/// Set if only a single chat is affected by the changes, otherwise 0.
chat_id: u32,
/// Set if only a single message is affected by the changes, otherwise 0.
msg_id: u32,
},
/// Reactions for the message changed. /// Reactions for the message changed.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
ReactionsChanged { ReactionsChanged {
/// ID of the chat which the message belongs to.
chat_id: u32, chat_id: u32,
/// ID of the message for which reactions were changed.
msg_id: u32, msg_id: u32,
/// ID of the contact whose reaction set is changed.
contact_id: u32, contact_id: u32,
}, },
/// A reaction to one's own sent message received. /// Incoming reaction, should be notified.
/// Typically, the UI will show a notification for that.
///
/// In addition to this event, ReactionsChanged is emitted.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
IncomingReaction { IncomingReaction {
/// ID of the chat which the message belongs to.
chat_id: u32,
/// ID of the contact whose reaction set is changed.
contact_id: u32, contact_id: u32,
/// ID of the message for which reactions were changed.
msg_id: u32, msg_id: u32,
/// The reaction.
reaction: String, reaction: String,
}, },
/// Incoming webxdc info or summary update, should be notified. /// Incoming webxdc info or summary update, should be notified.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
IncomingWebxdcNotify { IncomingWebxdcNotify {
/// ID of the chat.
chat_id: u32,
/// ID of the contact sending.
contact_id: u32, contact_id: u32,
/// ID of the added info message or webxdc instance in case of summary change.
msg_id: u32, msg_id: u32,
/// Text to notify.
text: String, text: String,
/// Link assigned to this notification, if any.
href: Option<String>, href: Option<String>,
}, },
/// There is a fresh message. Typically, the user will show a notification /// There is a fresh message. Typically, the user will show an notification
/// when receiving this message. /// when receiving this message.
/// ///
/// There is no extra #DC_EVENT_MSGS_CHANGED event sent together with this event. /// There is no extra #DC_EVENT_MSGS_CHANGED event sent together with this event.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
IncomingMsg { IncomingMsg { chat_id: u32, msg_id: u32 },
/// ID of the chat where the message is assigned.
chat_id: u32,
/// ID of the message.
msg_id: u32,
},
/// Downloading a bunch of messages just finished. This is an /// Downloading a bunch of messages just finished. This is an
/// event to allow the UI to only show one notification per message bunch, /// event to allow the UI to only show one notification per message bunch,
@@ -173,59 +136,24 @@ pub enum EventType {
/// A single message is sent successfully. State changed from DC_STATE_OUT_PENDING to /// A single message is sent successfully. State changed from DC_STATE_OUT_PENDING to
/// DC_STATE_OUT_DELIVERED, see `Message.state`. /// DC_STATE_OUT_DELIVERED, see `Message.state`.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
MsgDelivered { MsgDelivered { chat_id: u32, msg_id: u32 },
/// ID of the chat which the message belongs to.
chat_id: u32,
/// ID of the message that was successfully sent.
msg_id: u32,
},
/// A single message could not be sent. State changed from DC_STATE_OUT_PENDING or DC_STATE_OUT_DELIVERED to /// A single message could not be sent. State changed from DC_STATE_OUT_PENDING or DC_STATE_OUT_DELIVERED to
/// DC_STATE_OUT_FAILED, see `Message.state`. /// DC_STATE_OUT_FAILED, see `Message.state`.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
MsgFailed { MsgFailed { chat_id: u32, msg_id: u32 },
/// ID of the chat which the message belongs to.
chat_id: u32,
/// ID of the message that could not be sent.
msg_id: u32,
},
/// A single message is read by the receiver. State changed from DC_STATE_OUT_DELIVERED to /// A single message is read by the receiver. State changed from DC_STATE_OUT_DELIVERED to
/// DC_STATE_OUT_MDN_RCVD, see `Message.state`. /// DC_STATE_OUT_MDN_RCVD, see `Message.state`.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
MsgRead { MsgRead { chat_id: u32, msg_id: u32 },
/// ID of the chat which the message belongs to.
chat_id: u32,
/// ID of the message that was read. /// A single message is deleted.
msg_id: u32,
},
/// A single message was deleted.
///
/// This event means that the message will no longer appear in the messagelist.
/// UI should remove the message from the messagelist
/// in response to this event if the message is currently displayed.
///
/// The message may have been explicitly deleted by the user or expired.
/// Internally the message may have been removed from the database,
/// moved to the trash chat or hidden.
///
/// This event does not indicate the message
/// deletion from the server.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
MsgDeleted { MsgDeleted { chat_id: u32, msg_id: u32 },
/// ID of the chat where the message was prior to deletion.
/// Never 0.
chat_id: u32,
/// ID of the deleted message. Never 0.
msg_id: u32,
},
/// Chat changed. The name or the image of a chat group was changed or members were added or removed. /// Chat changed. The name or the image of a chat group was changed or members were added or removed.
/// Or the verify state of a chat has changed.
/// See setChatName(), setChatProfileImage(), addContactToChat() /// See setChatName(), setChatProfileImage(), addContactToChat()
/// and removeContactFromChat(). /// and removeContactFromChat().
/// ///
@@ -236,42 +164,28 @@ pub enum EventType {
/// Chat ephemeral timer changed. /// Chat ephemeral timer changed.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
ChatEphemeralTimerModified { ChatEphemeralTimerModified { chat_id: u32, timer: u32 },
/// Chat ID.
chat_id: u32,
/// New ephemeral timer value.
timer: u32,
},
/// Chat deleted.
ChatDeleted {
/// Chat ID.
chat_id: u32,
},
/// Contact(s) created, renamed, blocked or deleted. /// Contact(s) created, renamed, blocked or deleted.
///
/// @param data1 (int) If set, this is the contact_id of an added contact that should be selected.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
ContactsChanged { ContactsChanged { contact_id: Option<u32> },
/// If set, this is the contact_id of an added contact that should be selected.
contact_id: Option<u32>,
},
/// Location of one or more contact has changed. /// Location of one or more contact has changed.
///
/// @param data1 (u32) contact_id of the contact for which the location has changed.
/// If the locations of several contacts have been changed,
/// this parameter is set to `None`.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
LocationChanged { LocationChanged { contact_id: Option<u32> },
/// contact_id of the contact for which the location has changed.
/// If the locations of several contacts have been changed,
/// this parameter is set to `None`.
contact_id: Option<u32>,
},
/// Inform about the configuration progress started by configure(). /// Inform about the configuration progress started by configure().
ConfigureProgress { ConfigureProgress {
/// Progress. /// Progress.
/// ///
/// 0=error, 1-999=progress in permille, 1000=success and done /// 0=error, 1-999=progress in permille, 1000=success and done
progress: u16, progress: usize,
/// Progress comment or error, something to display to the user. /// Progress comment or error, something to display to the user.
comment: Option<String>, comment: Option<String>,
@@ -279,11 +193,10 @@ pub enum EventType {
/// Inform about the import/export progress started by imex(). /// Inform about the import/export progress started by imex().
/// ///
/// @param data1 (usize) 0=error, 1-999=progress in permille, 1000=success and done
/// @param data2 0
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
ImexProgress { ImexProgress { progress: usize },
/// 0=error, 1-999=progress in permille, 1000=success and done
progress: u16,
},
/// A file has been exported. A file has been written by imex(). /// A file has been exported. A file has been written by imex().
/// This event may be sent multiple times by a single call to imex(). /// This event may be sent multiple times by a single call to imex().
@@ -295,42 +208,31 @@ pub enum EventType {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
ImexFileWritten { path: String }, ImexFileWritten { path: String },
/// Progress event sent when SecureJoin protocol has finished /// Progress information of a secure-join handshake from the view of the inviter
/// from the view of the inviter (Alice, the person who shows the QR code). /// (Alice, the person who shows the QR code).
/// ///
/// These events are typically sent after a joiner has scanned the QR code /// These events are typically sent after a joiner has scanned the QR code
/// generated by getChatSecurejoinQrCodeSvg(). /// generated by getChatSecurejoinQrCodeSvg().
///
/// @param data1 (int) ID of the contact that wants to join.
/// @param data2 (int) Progress as:
/// 300=vg-/vc-request received, typically shown as "bob@addr joins".
/// 600=vg-/vc-request-with-auth received, vg-member-added/vc-contact-confirm sent, typically shown as "bob@addr verified".
/// 800=vg-member-added-received received, shown as "bob@addr securely joined GROUP", only sent for the verified-group-protocol.
/// 1000=Protocol finished for this contact.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
SecurejoinInviterProgress { SecurejoinInviterProgress { contact_id: u32, progress: usize },
/// ID of the contact that wants to join.
contact_id: u32,
/// The type of the joined chat.
/// This can take the same values
/// as `BasicChat.chatType` ([`crate::api::types::chat::BasicChat::chat_type`]).
chat_type: JsonrpcChatType,
/// ID of the chat in case of success.
chat_id: u32,
/// Progress, always 1000.
progress: u16,
},
/// Progress information of a secure-join handshake from the view of the joiner /// Progress information of a secure-join handshake from the view of the joiner
/// (Bob, the person who scans the QR code). /// (Bob, the person who scans the QR code).
/// The events are typically sent while secureJoin(), which /// The events are typically sent while secureJoin(), which
/// may take some time, is executed. /// may take some time, is executed.
/// @param data1 (int) ID of the inviting contact.
/// @param data2 (int) Progress as:
/// 400=vg-/vc-request-with-auth sent, typically shown as "alice@addr verified, introducing myself."
/// (Bob has verified alice and waits until Alice does the same for him)
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
SecurejoinJoinerProgress { SecurejoinJoinerProgress { contact_id: u32, progress: usize },
/// ID of the inviting contact.
contact_id: u32,
/// Progress as:
/// 400=vg-/vc-request-with-auth sent, typically shown as "alice@addr verified, introducing myself."
/// (Bob has verified alice and waits until Alice does the same for him)
/// 1000=vg-member-added/vc-contact-confirm received
progress: u16,
},
/// The connectivity to the server changed. /// The connectivity to the server changed.
/// This means that you should refresh the connectivity view /// This means that you should refresh the connectivity view
@@ -351,37 +253,22 @@ pub enum EventType {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
WebxdcStatusUpdate { WebxdcStatusUpdate {
/// Message ID.
msg_id: u32, msg_id: u32,
/// Status update ID.
status_update_serial: u32, status_update_serial: u32,
}, },
/// Data received over an ephemeral peer channel. /// Data received over an ephemeral peer channel.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
WebxdcRealtimeData { WebxdcRealtimeData { msg_id: u32, data: Vec<u8> },
/// Message ID.
msg_id: u32,
/// Realtime data.
data: Vec<u8>,
},
/// Advertisement received over an ephemeral peer channel. /// Advertisement received over an ephemeral peer channel.
/// This can be used by bots to initiate peer-to-peer communication from their side. /// This can be used by bots to initiate peer-to-peer communication from their side.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
WebxdcRealtimeAdvertisementReceived { WebxdcRealtimeAdvertisementReceived { msg_id: u32 },
/// Message ID of the webxdc instance.
msg_id: u32,
},
/// Inform that a message containing a webxdc instance has been deleted /// Inform that a message containing a webxdc instance has been deleted
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
WebxdcInstanceDeleted { WebxdcInstanceDeleted { msg_id: u32 },
/// ID of the deleted message.
msg_id: u32,
},
/// Tells that the Background fetch was completed (or timed out). /// 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 /// This event acts as a marker, when you reach this event you can be sure
@@ -397,10 +284,7 @@ pub enum EventType {
/// Inform that a single chat list item changed and needs to be rerendered. /// Inform that a single chat list item changed and needs to be rerendered.
/// If `chat_id` is set to None, then all currently visible chats need to be rerendered, and all not-visible items need to be cleared from cache if the UI has a cache. /// If `chat_id` is set to None, then all currently visible chats need to be rerendered, and all not-visible items need to be cleared from cache if the UI has a cache.
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
ChatlistItemChanged { ChatlistItemChanged { chat_id: Option<u32> },
/// ID of the changed chat
chat_id: Option<u32>,
},
/// Inform that the list of accounts has changed (an account removed or added or (not yet implemented) the account order changes) /// Inform that the list of accounts has changed (an account removed or added or (not yet implemented) the account order changes)
/// ///
@@ -417,60 +301,7 @@ pub enum EventType {
AccountsItemChanged, AccountsItemChanged,
/// Inform than some events have been skipped due to event channel overflow. /// Inform than some events have been skipped due to event channel overflow.
EventChannelOverflow { EventChannelOverflow { n: u64 },
/// Number of events skipped.
n: u64,
},
/// Incoming call.
IncomingCall {
/// ID of the info message referring to the call.
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
/// User-defined info as passed to place_outgoing_call()
place_call_info: String,
/// True if incoming call is a video call.
has_video: bool,
},
/// Incoming call accepted.
/// This is esp. interesting to stop ringing on other devices.
IncomingCallAccepted {
/// ID of the info message referring to the call.
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
/// The call was accepted from this device (process).
from_this_device: bool,
},
/// Outgoing call accepted.
OutgoingCallAccepted {
/// ID of the info message referring to the call.
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
/// User-defined info passed to dc_accept_incoming_call(
accept_call_info: String,
},
/// Call ended.
CallEnded {
/// ID of the info message referring to the call.
msg_id: u32,
/// ID of the chat which the message belongs to.
chat_id: u32,
},
/// One or more transports has changed.
///
/// UI should update the list.
///
/// This event is emitted when transport
/// synchronization messages arrives,
/// but not when the UI modifies the transport list by itself.
TransportsModified,
} }
impl From<CoreEventType> for EventType { impl From<CoreEventType> for EventType {
@@ -503,24 +334,20 @@ impl From<CoreEventType> for EventType {
contact_id: contact_id.to_u32(), contact_id: contact_id.to_u32(),
}, },
CoreEventType::IncomingReaction { CoreEventType::IncomingReaction {
chat_id,
contact_id, contact_id,
msg_id, msg_id,
reaction, reaction,
} => IncomingReaction { } => IncomingReaction {
chat_id: chat_id.to_u32(),
contact_id: contact_id.to_u32(), contact_id: contact_id.to_u32(),
msg_id: msg_id.to_u32(), msg_id: msg_id.to_u32(),
reaction: reaction.as_str().to_string(), reaction: reaction.as_str().to_string(),
}, },
CoreEventType::IncomingWebxdcNotify { CoreEventType::IncomingWebxdcNotify {
chat_id,
contact_id, contact_id,
msg_id, msg_id,
text, text,
href, href,
} => IncomingWebxdcNotify { } => IncomingWebxdcNotify {
chat_id: chat_id.to_u32(),
contact_id: contact_id.to_u32(), contact_id: contact_id.to_u32(),
msg_id: msg_id.to_u32(), msg_id: msg_id.to_u32(),
text, text,
@@ -559,9 +386,6 @@ impl From<CoreEventType> for EventType {
timer: timer.to_u32(), timer: timer.to_u32(),
} }
} }
CoreEventType::ChatDeleted { chat_id } => ChatDeleted {
chat_id: chat_id.to_u32(),
},
CoreEventType::ContactsChanged(contact) => ContactsChanged { CoreEventType::ContactsChanged(contact) => ContactsChanged {
contact_id: contact.map(|c| c.to_u32()), contact_id: contact.map(|c| c.to_u32()),
}, },
@@ -577,13 +401,9 @@ impl From<CoreEventType> for EventType {
}, },
CoreEventType::SecurejoinInviterProgress { CoreEventType::SecurejoinInviterProgress {
contact_id, contact_id,
chat_type,
chat_id,
progress, progress,
} => SecurejoinInviterProgress { } => SecurejoinInviterProgress {
contact_id: contact_id.to_u32(), contact_id: contact_id.to_u32(),
chat_type: chat_type.into(),
chat_id: chat_id.to_u32(),
progress, progress,
}, },
CoreEventType::SecurejoinJoinerProgress { CoreEventType::SecurejoinJoinerProgress {
@@ -625,41 +445,6 @@ impl From<CoreEventType> for EventType {
CoreEventType::EventChannelOverflow { n } => EventChannelOverflow { n }, CoreEventType::EventChannelOverflow { n } => EventChannelOverflow { n },
CoreEventType::AccountsChanged => AccountsChanged, CoreEventType::AccountsChanged => AccountsChanged,
CoreEventType::AccountsItemChanged => AccountsItemChanged, CoreEventType::AccountsItemChanged => AccountsItemChanged,
CoreEventType::IncomingCall {
msg_id,
chat_id,
place_call_info,
has_video,
} => IncomingCall {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
place_call_info,
has_video,
},
CoreEventType::IncomingCallAccepted {
msg_id,
chat_id,
from_this_device,
} => IncomingCallAccepted {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
from_this_device,
},
CoreEventType::OutgoingCallAccepted {
msg_id,
chat_id,
accept_call_info,
} => OutgoingCallAccepted {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
accept_call_info,
},
CoreEventType::CallEnded { msg_id, chat_id } => CallEnded {
msg_id: msg_id.to_u32(),
chat_id: chat_id.to_u32(),
},
CoreEventType::TransportsModified => TransportsModified,
#[allow(unreachable_patterns)] #[allow(unreachable_patterns)]
#[cfg(test)] #[cfg(test)]
_ => unreachable!("This is just to silence a rust_analyzer false-positive"), _ => unreachable!("This is just to silence a rust_analyzer false-positive"),

View File

@@ -1,230 +0,0 @@
use anyhow::Result;
use deltachat::login_param as dc;
use serde::Deserialize;
use serde::Serialize;
use yerpc::TypeDef;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct TransportListEntry {
/// The login data entered by the user.
pub param: EnteredLoginParam,
/// Whether this transport is set to 'unpublished'.
/// See `set_transport_unpublished` / `setTransportUnpublished` for details.
pub is_unpublished: bool,
}
/// Login parameters entered by the user.
///
/// Usually it will be enough to only set `addr` and `password`,
/// and all the other settings will be autoconfigured.
#[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct EnteredLoginParam {
/// Email address.
pub addr: String,
/// Password.
pub password: String,
/// Imap server hostname or IP address.
pub imap_server: Option<String>,
/// 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>,
/// Imap username.
pub imap_user: Option<String>,
/// SMTP server hostname or IP address.
pub smtp_server: Option<String>,
/// SMTP server port.
pub smtp_port: Option<u16>,
/// SMTP socket security.
pub smtp_security: Option<Socket>,
/// SMTP username.
pub smtp_user: Option<String>,
/// SMTP Password.
///
/// Only needs to be specified if different than IMAP password.
pub smtp_password: Option<String>,
/// TLS options: whether to allow invalid certificates and/or
/// 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::TransportListEntry> for TransportListEntry {
fn from(transport: dc::TransportListEntry) -> Self {
TransportListEntry {
param: transport.param.into(),
is_unpublished: transport.is_unpublished,
}
}
}
impl From<dc::EnteredLoginParam> for EnteredLoginParam {
fn from(param: dc::EnteredLoginParam) -> Self {
let imap_security: Socket = param.imap.security.into();
let smtp_security: Socket = param.smtp.security.into();
let certificate_checks: EnteredCertificateChecks = param.certificate_checks.into();
Self {
addr: param.addr,
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(),
smtp_port: param.smtp.port.into_option(),
smtp_security: smtp_security.into_option(),
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(),
}
}
}
impl TryFrom<EnteredLoginParam> for dc::EnteredLoginParam {
type Error = anyhow::Error;
fn try_from(param: EnteredLoginParam) -> Result<Self> {
Ok(Self {
addr: param.addr,
imap: dc::EnteredImapLoginParam {
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 {
server: param.smtp_server.unwrap_or_default(),
port: param.smtp_port.unwrap_or_default(),
security: param.smtp_security.unwrap_or_default().into(),
user: param.smtp_user.unwrap_or_default(),
password: param.smtp_password.unwrap_or_default(),
},
certificate_checks: param.certificate_checks.unwrap_or_default().into(),
oauth2: param.oauth2.unwrap_or_default(),
})
}
}
#[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum Socket {
/// Unspecified socket security, select automatically.
#[default]
Automatic,
/// TLS connection.
Ssl,
/// STARTTLS connection.
Starttls,
/// No TLS, plaintext connection.
Plain,
}
impl From<dc::Socket> for Socket {
fn from(value: dc::Socket) -> Self {
match value {
dc::Socket::Automatic => Self::Automatic,
dc::Socket::Ssl => Self::Ssl,
dc::Socket::Starttls => Self::Starttls,
dc::Socket::Plain => Self::Plain,
}
}
}
impl From<Socket> for dc::Socket {
fn from(value: Socket) -> Self {
match value {
Socket::Automatic => Self::Automatic,
Socket::Ssl => Self::Ssl,
Socket::Starttls => Self::Starttls,
Socket::Plain => Self::Plain,
}
}
}
#[derive(Serialize, Deserialize, TypeDef, schemars::JsonSchema, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum EnteredCertificateChecks {
/// `Automatic` means that provider database setting should be taken.
/// If there is no provider database setting for certificate checks,
/// check certificates strictly.
#[default]
Automatic,
/// Ensure that TLS certificate is valid for the server hostname.
Strict,
/// Accept certificates that are expired, self-signed
/// or otherwise not valid for the server hostname.
AcceptInvalidCertificates,
}
impl From<dc::EnteredCertificateChecks> for EnteredCertificateChecks {
fn from(value: dc::EnteredCertificateChecks) -> Self {
match value {
dc::EnteredCertificateChecks::Automatic => Self::Automatic,
dc::EnteredCertificateChecks::Strict => Self::Strict,
dc::EnteredCertificateChecks::AcceptInvalidCertificates => {
Self::AcceptInvalidCertificates
}
dc::EnteredCertificateChecks::AcceptInvalidCertificates2 => {
Self::AcceptInvalidCertificates
}
}
}
}
impl From<EnteredCertificateChecks> for dc::EnteredCertificateChecks {
fn from(value: EnteredCertificateChecks) -> Self {
match value {
EnteredCertificateChecks::Automatic => Self::Automatic,
EnteredCertificateChecks::Strict => Self::Strict,
EnteredCertificateChecks::AcceptInvalidCertificates => Self::AcceptInvalidCertificates,
}
}
}
trait IntoOption<T> {
fn into_option(self) -> Option<T>;
}
impl<T> IntoOption<T> for T
where
T: Default + std::cmp::PartialEq,
{
fn into_option(self) -> Option<T> {
if self == T::default() {
None
} else {
Some(self)
}
}
}

View File

@@ -1,5 +1,3 @@
use std::path::Path;
use crate::api::VcardContact; use crate::api::VcardContact;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use deltachat::chat::Chat; use deltachat::chat::Chat;
@@ -16,14 +14,13 @@ use num_traits::cast::ToPrimitive;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use typescript_type_def::TypeDef; use typescript_type_def::TypeDef;
use super::chat::JsonrpcChatType;
use super::color_int_to_hex_string; use super::color_int_to_hex_string;
use super::contact::ContactObject; use super::contact::ContactObject;
use super::reactions::JsonrpcReactions; use super::reactions::JSONRPCReactions;
use super::webxdc::WebxdcMessageInfo;
#[derive(Serialize, TypeDef, schemars::JsonSchema)] #[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", tag = "kind")] #[serde(rename_all = "camelCase", tag = "kind")]
#[expect(clippy::large_enum_variant)]
pub enum MessageLoadResult { pub enum MessageLoadResult {
Message(MessageObject), Message(MessageObject),
LoadingError { error: String }, LoadingError { error: String },
@@ -40,8 +37,6 @@ pub struct MessageObject {
text: String, text: String,
is_edited: bool,
/// Check if a message has a POI location bound to it. /// Check if a message has a POI location bound to it.
/// These locations are also returned by `get_locations` method. /// These locations are also returned by `get_locations` method.
/// The UI may decide to display a special icon beside such messages. /// The UI may decide to display a special icon beside such messages.
@@ -60,14 +55,8 @@ pub struct MessageObject {
// summary - use/create another function if you need it // summary - use/create another function if you need it
subject: String, subject: String,
/// True if the message was correctly encrypted&signed, false otherwise.
/// Historically, UIs showed a small padlock on the message then.
///
/// Today, the UIs should instead show a small email-icon on the message
/// if `show_padlock` is `false`,
/// and nothing if it is `true`.
show_padlock: bool, show_padlock: bool,
is_setupmessage: bool,
is_info: bool, is_info: bool,
is_forwarded: bool, is_forwarded: bool,
@@ -77,33 +66,30 @@ pub struct MessageObject {
/// when is_info is true this describes what type of system message it is /// when is_info is true this describes what type of system message it is
system_message_type: SystemMessageType, system_message_type: SystemMessageType,
/// if is_info is set, this refers to the contact profile that should be opened when the info message is tapped.
info_contact_id: Option<u32>,
duration: i32, duration: i32,
dimensions_height: i32, dimensions_height: i32,
dimensions_width: i32, dimensions_width: i32,
videochat_type: Option<u32>,
videochat_url: Option<String>,
override_sender_name: Option<String>, override_sender_name: Option<String>,
sender: ContactObject, sender: ContactObject,
setup_code_begin: Option<String>,
file: Option<String>, file: Option<String>,
file_mime: Option<String>, file_mime: Option<String>,
/// The size of the file in bytes, if applicable.
/// If message is a pre-message, then this is the size of the file to be downloaded.
file_bytes: u64, file_bytes: u64,
file_name: Option<String>, file_name: Option<String>,
webxdc_info: Option<WebxdcMessageInfo>,
webxdc_href: Option<String>, webxdc_href: Option<String>,
download_state: DownloadState, download_state: DownloadState,
original_msg_id: Option<u32>, reactions: Option<JSONRPCReactions>,
saved_message_id: Option<u32>,
reactions: Option<JsonrpcReactions>,
vcard_contact: Option<VcardContact>, vcard_contact: Option<VcardContact>,
} }
@@ -118,9 +104,6 @@ enum MessageQuote {
WithMessage { WithMessage {
text: String, text: String,
message_id: u32, message_id: u32,
/// The quoted message does not always belong
/// to the same chat, e.g. when "Reply Privately" is used.
chat_id: u32,
author_display_name: String, author_display_name: String,
author_display_color: String, author_display_color: String,
override_sender_name: Option<String>, override_sender_name: Option<String>,
@@ -145,6 +128,12 @@ impl MessageObject {
let file_bytes = message.get_filebytes(context).await?.unwrap_or_default(); let file_bytes = message.get_filebytes(context).await?.unwrap_or_default();
let override_sender_name = message.get_override_sender_name(); let override_sender_name = message.get_override_sender_name();
let webxdc_info = if message.get_viewtype() == Viewtype::Webxdc {
Some(WebxdcMessageInfo::get_for_message(context, msg_id).await?)
} else {
None
};
let parent_id = message.parent(context).await?.map(|m| m.get_id().to_u32()); let parent_id = message.parent(context).await?.map(|m| m.get_id().to_u32());
let download_state = message.download_state().into(); let download_state = message.download_state().into();
@@ -158,7 +147,6 @@ impl MessageObject {
Some(MessageQuote::WithMessage { Some(MessageQuote::WithMessage {
text: quoted_text, text: quoted_text,
message_id: quote.get_id().to_u32(), message_id: quote.get_id().to_u32(),
chat_id: quote.get_chat_id().to_u32(),
author_display_name: quote_author.get_display_name().to_owned(), author_display_name: quote_author.get_display_name().to_owned(),
author_display_color: color_int_to_hex_string(quote_author.get_color()), author_display_color: color_int_to_hex_string(quote_author.get_color()),
override_sender_name: quote.get_override_sender_name(), override_sender_name: quote.get_override_sender_name(),
@@ -206,7 +194,6 @@ impl MessageObject {
quote, quote,
parent_id, parent_id,
text: message.get_text(), text: message.get_text(),
is_edited: message.is_edited(),
has_location: message.has_location(), has_location: message.has_location(),
has_html: message.has_html(), has_html: message.has_html(),
view_type: message.get_viewtype().into(), view_type: message.get_viewtype().into(),
@@ -223,22 +210,30 @@ impl MessageObject {
subject: message.get_subject().to_owned(), subject: message.get_subject().to_owned(),
show_padlock: message.get_showpadlock(), show_padlock: message.get_showpadlock(),
is_setupmessage: message.is_setupmessage(),
is_info: message.is_info(), is_info: message.is_info(),
is_forwarded: message.is_forwarded(), is_forwarded: message.is_forwarded(),
is_bot: message.is_bot(), is_bot: message.is_bot(),
system_message_type: message.get_info_type().into(), system_message_type: message.get_info_type().into(),
info_contact_id: message
.get_info_contact_id(context)
.await?
.map(|id| id.to_u32()),
duration: message.get_duration(), duration: message.get_duration(),
dimensions_height: message.get_height(), dimensions_height: message.get_height(),
dimensions_width: message.get_width(), dimensions_width: message.get_width(),
videochat_type: match message.get_videochat_type() {
Some(vct) => Some(
vct.to_u32()
.context("videochat type conversion to number failed")?,
),
None => None,
},
videochat_url: message.get_videochat_url(),
override_sender_name, override_sender_name,
sender, sender,
setup_code_begin: message.get_setupcodebegin(context).await,
file: match message.get_file(context) { file: match message.get_file(context) {
Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()), Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()),
None => None, None => None,
@@ -246,6 +241,7 @@ impl MessageObject {
file_mime: message.get_filemime(), file_mime: message.get_filemime(),
file_bytes, file_bytes,
file_name: message.get_filename(), file_name: message.get_filename(),
webxdc_info,
// On a WebxdcInfoMessage this might include a hash holding // On a WebxdcInfoMessage this might include a hash holding
// information about a specific position or state in a webxdc app // information about a specific position or state in a webxdc app
@@ -253,16 +249,6 @@ impl MessageObject {
download_state, download_state,
original_msg_id: message
.get_original_msg_id(context)
.await?
.map(|id| id.to_u32()),
saved_message_id: message
.get_saved_msg_id(context)
.await?
.map(|id| id.to_u32()),
reactions, reactions,
vcard_contact: vcard_contacts.first().cloned(), vcard_contact: vcard_contacts.first().cloned(),
@@ -287,7 +273,6 @@ pub enum MessageViewtype {
Gif, Gif,
/// Message containing a sticker, similar to image. /// Message containing a sticker, similar to image.
///
/// If possible, the ui should display the image without borders in a transparent way. /// 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. /// A click on a sticker will offer to install the sticker set in some future.
Sticker, Sticker,
@@ -305,8 +290,8 @@ pub enum MessageViewtype {
/// Message containing any file, eg. a PDF. /// Message containing any file, eg. a PDF.
File, File,
/// Message is a call. /// Message is an invitation to a videochat.
Call, VideochatInvitation,
/// Message is an webxdc instance. /// Message is an webxdc instance.
Webxdc, Webxdc,
@@ -329,7 +314,7 @@ impl From<Viewtype> for MessageViewtype {
Viewtype::Voice => MessageViewtype::Voice, Viewtype::Voice => MessageViewtype::Voice,
Viewtype::Video => MessageViewtype::Video, Viewtype::Video => MessageViewtype::Video,
Viewtype::File => MessageViewtype::File, Viewtype::File => MessageViewtype::File,
Viewtype::Call => MessageViewtype::Call, Viewtype::VideochatInvitation => MessageViewtype::VideochatInvitation,
Viewtype::Webxdc => MessageViewtype::Webxdc, Viewtype::Webxdc => MessageViewtype::Webxdc,
Viewtype::Vcard => MessageViewtype::Vcard, Viewtype::Vcard => MessageViewtype::Vcard,
} }
@@ -348,7 +333,7 @@ impl From<MessageViewtype> for Viewtype {
MessageViewtype::Voice => Viewtype::Voice, MessageViewtype::Voice => Viewtype::Voice,
MessageViewtype::Video => Viewtype::Video, MessageViewtype::Video => Viewtype::Video,
MessageViewtype::File => Viewtype::File, MessageViewtype::File => Viewtype::File,
MessageViewtype::Call => Viewtype::Call, MessageViewtype::VideochatInvitation => Viewtype::VideochatInvitation,
MessageViewtype::Webxdc => Viewtype::Webxdc, MessageViewtype::Webxdc => Viewtype::Webxdc,
MessageViewtype::Vcard => Viewtype::Vcard, MessageViewtype::Vcard => Viewtype::Vcard,
} }
@@ -380,7 +365,6 @@ impl From<download::DownloadState> for DownloadState {
pub enum SystemMessageType { pub enum SystemMessageType {
Unknown, Unknown,
GroupNameChanged, GroupNameChanged,
GroupDescriptionChanged,
GroupImageChanged, GroupImageChanged,
MemberAddedToGroup, MemberAddedToGroup,
MemberRemovedFromGroup, MemberRemovedFromGroup,
@@ -401,9 +385,6 @@ pub enum SystemMessageType {
/// Chat ephemeral message timer is changed. /// Chat ephemeral message timer is changed.
EphemeralTimerChanged, EphemeralTimerChanged,
// Chat is e2ee
ChatE2ee,
// Chat protection state changed // Chat protection state changed
ChatProtectionEnabled, ChatProtectionEnabled,
ChatProtectionDisabled, ChatProtectionDisabled,
@@ -422,9 +403,6 @@ pub enum SystemMessageType {
/// This message contains a users iroh node address. /// This message contains a users iroh node address.
IrohNodeAddr, IrohNodeAddr,
CallAccepted,
CallEnded,
} }
impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType { impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
@@ -433,7 +411,6 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
match system_message_type { match system_message_type {
SystemMessage::Unknown => SystemMessageType::Unknown, SystemMessage::Unknown => SystemMessageType::Unknown,
SystemMessage::GroupNameChanged => SystemMessageType::GroupNameChanged, SystemMessage::GroupNameChanged => SystemMessageType::GroupNameChanged,
SystemMessage::GroupDescriptionChanged => SystemMessageType::GroupDescriptionChanged,
SystemMessage::GroupImageChanged => SystemMessageType::GroupImageChanged, SystemMessage::GroupImageChanged => SystemMessageType::GroupImageChanged,
SystemMessage::MemberAddedToGroup => SystemMessageType::MemberAddedToGroup, SystemMessage::MemberAddedToGroup => SystemMessageType::MemberAddedToGroup,
SystemMessage::MemberRemovedFromGroup => SystemMessageType::MemberRemovedFromGroup, SystemMessage::MemberRemovedFromGroup => SystemMessageType::MemberRemovedFromGroup,
@@ -442,7 +419,6 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
SystemMessage::LocationStreamingEnabled => SystemMessageType::LocationStreamingEnabled, SystemMessage::LocationStreamingEnabled => SystemMessageType::LocationStreamingEnabled,
SystemMessage::LocationOnly => SystemMessageType::LocationOnly, SystemMessage::LocationOnly => SystemMessageType::LocationOnly,
SystemMessage::EphemeralTimerChanged => SystemMessageType::EphemeralTimerChanged, SystemMessage::EphemeralTimerChanged => SystemMessageType::EphemeralTimerChanged,
SystemMessage::ChatE2ee => SystemMessageType::ChatE2ee,
SystemMessage::ChatProtectionEnabled => SystemMessageType::ChatProtectionEnabled, SystemMessage::ChatProtectionEnabled => SystemMessageType::ChatProtectionEnabled,
SystemMessage::ChatProtectionDisabled => SystemMessageType::ChatProtectionDisabled, SystemMessage::ChatProtectionDisabled => SystemMessageType::ChatProtectionDisabled,
SystemMessage::MultiDeviceSync => SystemMessageType::MultiDeviceSync, SystemMessage::MultiDeviceSync => SystemMessageType::MultiDeviceSync,
@@ -452,8 +428,6 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
SystemMessage::IrohNodeAddr => SystemMessageType::IrohNodeAddr, SystemMessage::IrohNodeAddr => SystemMessageType::IrohNodeAddr,
SystemMessage::SecurejoinWait => SystemMessageType::SecurejoinWait, SystemMessage::SecurejoinWait => SystemMessageType::SecurejoinWait,
SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout, SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout,
SystemMessage::CallAccepted => SystemMessageType::CallAccepted,
SystemMessage::CallEnded => SystemMessageType::CallEnded,
} }
} }
} }
@@ -529,7 +503,8 @@ pub struct MessageSearchResult {
chat_profile_image: Option<String>, chat_profile_image: Option<String>,
chat_color: String, chat_color: String,
chat_name: String, chat_name: String,
chat_type: JsonrpcChatType, chat_type: u32,
is_chat_protected: bool,
is_chat_contact_request: bool, is_chat_contact_request: bool,
is_chat_archived: bool, is_chat_archived: bool,
message: String, message: String,
@@ -567,8 +542,9 @@ impl MessageSearchResult {
chat_id: chat.id.to_u32(), chat_id: chat.id.to_u32(),
chat_name: chat.get_name().to_owned(), chat_name: chat.get_name().to_owned(),
chat_color, chat_color,
chat_type: chat.get_type().into(), chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
chat_profile_image, chat_profile_image,
is_chat_protected: chat.is_protected(),
is_chat_contact_request: chat.is_contact_request(), is_chat_contact_request: chat.is_contact_request(),
is_chat_archived: chat.get_visibility() == ChatVisibility::Archived, is_chat_archived: chat.get_visibility() == ChatVisibility::Archived,
message: message.get_text(), message: message.get_text(),
@@ -579,7 +555,7 @@ impl MessageSearchResult {
#[derive(Serialize, TypeDef, schemars::JsonSchema)] #[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", rename = "MessageListItem", tag = "kind")] #[serde(rename_all = "camelCase", rename = "MessageListItem", tag = "kind")]
pub enum JsonrpcMessageListItem { pub enum JSONRPCMessageListItem {
Message { Message {
msg_id: u32, msg_id: u32,
}, },
@@ -592,13 +568,13 @@ pub enum JsonrpcMessageListItem {
}, },
} }
impl From<ChatItem> for JsonrpcMessageListItem { impl From<ChatItem> for JSONRPCMessageListItem {
fn from(item: ChatItem) -> Self { fn from(item: ChatItem) -> Self {
match item { match item {
ChatItem::Message { msg_id } => JsonrpcMessageListItem::Message { ChatItem::Message { msg_id } => JSONRPCMessageListItem::Message {
msg_id: msg_id.to_u32(), msg_id: msg_id.to_u32(),
}, },
ChatItem::DayMarker { timestamp } => JsonrpcMessageListItem::DayMarker { timestamp }, ChatItem::DayMarker { timestamp } => JSONRPCMessageListItem::DayMarker { timestamp },
} }
} }
} }
@@ -610,7 +586,6 @@ pub struct MessageData {
pub html: Option<String>, pub html: Option<String>,
pub viewtype: Option<MessageViewtype>, pub viewtype: Option<MessageViewtype>,
pub file: Option<String>, pub file: Option<String>,
pub filename: Option<String>,
pub location: Option<(f64, f64)>, pub location: Option<(f64, f64)>,
pub override_sender_name: Option<String>, pub override_sender_name: Option<String>,
/// Quoted message id. Takes preference over `quoted_text` (see below). /// Quoted message id. Takes preference over `quoted_text` (see below).
@@ -635,12 +610,7 @@ impl MessageData {
message.set_override_sender_name(self.override_sender_name); message.set_override_sender_name(self.override_sender_name);
} }
if let Some(file) = self.file { if let Some(file) = self.file {
message.set_file_and_deduplicate( message.set_file(file, None);
context,
Path::new(&file),
self.filename.as_deref(),
None,
)?;
} }
if let Some((latitude, longitude)) = self.location { if let Some((latitude, longitude)) = self.location {
message.set_location(latitude, longitude); message.set_location(latitude, longitude);
@@ -671,6 +641,7 @@ pub struct MessageReadReceipt {
#[derive(Serialize, TypeDef, schemars::JsonSchema)] #[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MessageInfo { pub struct MessageInfo {
rawtext: String,
ephemeral_timer: EphemeralTimer, ephemeral_timer: EphemeralTimer,
/// When message is ephemeral this contains the timestamp of the message expiry /// When message is ephemeral this contains the timestamp of the message expiry
ephemeral_timestamp: Option<i64>, ephemeral_timestamp: Option<i64>,
@@ -683,6 +654,7 @@ pub struct MessageInfo {
impl MessageInfo { impl MessageInfo {
pub async fn from_msg_id(context: &Context, msg_id: MsgId) -> Result<Self> { pub async fn from_msg_id(context: &Context, msg_id: MsgId) -> Result<Self> {
let message = Message::load_from_db(context, msg_id).await?; let message = Message::load_from_db(context, msg_id).await?;
let rawtext = msg_id.rawtext(context).await?;
let ephemeral_timer = message.get_ephemeral_timer().into(); let ephemeral_timer = message.get_ephemeral_timer().into();
let ephemeral_timestamp = match message.get_ephemeral_timer() { let ephemeral_timestamp = match message.get_ephemeral_timer() {
deltachat::ephemeral::Timer::Disabled => None, deltachat::ephemeral::Timer::Disabled => None,
@@ -695,6 +667,7 @@ impl MessageInfo {
let hop_info = msg_id.hop_info(context).await?; let hop_info = msg_id.hop_info(context).await?;
Ok(Self { Ok(Self {
rawtext,
ephemeral_timer, ephemeral_timer,
ephemeral_timestamp, ephemeral_timestamp,
error: message.error(), error: message.error(),

View File

@@ -1,14 +1,11 @@
pub mod account; pub mod account;
pub mod calls;
pub mod chat; pub mod chat;
pub mod chat_list; pub mod chat_list;
pub mod contact; pub mod contact;
pub mod events; pub mod events;
pub mod http; pub mod http;
pub mod location; pub mod location;
pub mod login_param;
pub mod message; pub mod message;
pub mod notify_state;
pub mod provider_info; pub mod provider_info;
pub mod qr; pub mod qr;
pub mod reactions; pub mod reactions;

View File

@@ -1,26 +0,0 @@
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

@@ -1,5 +1,4 @@
use deltachat::qr::Qr; use deltachat::qr::Qr;
use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use typescript_type_def::TypeDef; use typescript_type_def::TypeDef;
@@ -19,8 +18,6 @@ pub enum QrObject {
invitenumber: String, invitenumber: String,
/// Authentication code. /// Authentication code.
authcode: String, authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
}, },
/// Ask the user whether to join the group. /// Ask the user whether to join the group.
AskVerifyGroup { AskVerifyGroup {
@@ -36,30 +33,6 @@ pub enum QrObject {
invitenumber: String, invitenumber: String,
/// Authentication code. /// Authentication code.
authcode: String, authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
},
/// Ask the user whether to join the broadcast channel.
AskJoinBroadcast {
/// The user-visible name of this broadcast channel
name: String,
/// A string of random characters,
/// uniquely identifying this broadcast channel across all databases/clients.
/// Called `grpid` for historic reasons:
/// The id of multi-user chats is always called `grpid` in the database
/// because groups were once the only multi-user chats.
grpid: String,
/// ID of the contact who owns the broadcast channel and created the QR code.
contact_id: u32,
/// Fingerprint of the broadcast channel owner's key as scanned from the QR code.
fingerprint: String,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
/// Whether the inviter supports the new Securejoin v3 protocol
is_v3: bool,
}, },
/// Contact fingerprint is verified. /// Contact fingerprint is verified.
/// ///
@@ -90,7 +63,6 @@ pub enum QrObject {
/// Iroh node address. /// Iroh node address.
node_addr: String, node_addr: String,
}, },
BackupTooNew {},
/// Ask the user if they want to use the given service for video chats. /// Ask the user if they want to use the given service for video chats.
WebrtcInstance { WebrtcInstance {
domain: String, domain: String,
@@ -128,15 +100,11 @@ pub enum QrObject {
/// URL scanned. /// URL scanned.
/// ///
/// Ask the user if they want to open a browser or copy the URL to clipboard. /// Ask the user if they want to open a browser or copy the URL to clipboard.
Url { Url { url: String },
url: String,
},
/// Text scanned. /// Text scanned.
/// ///
/// Ask the user if they want to copy the text to clipboard. /// Ask the user if they want to copy the text to clipboard.
Text { Text { text: String },
text: String,
},
/// Ask the user if they want to withdraw their own QR code. /// Ask the user if they want to withdraw their own QR code.
WithdrawVerifyContact { WithdrawVerifyContact {
/// Contact ID. /// Contact ID.
@@ -163,21 +131,6 @@ pub enum QrObject {
/// Authentication code. /// Authentication code.
authcode: String, authcode: String,
}, },
/// Ask the user if they want to withdraw their own broadcast channel invite QR code.
WithdrawJoinBroadcast {
/// Broadcast name.
name: String,
/// ID, uniquely identifying this chat. Called grpid for historic reasons.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: u32,
/// Fingerprint of the contact key as scanned from the QR code.
fingerprint: String,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// Ask the user if they want to revive their own QR code. /// Ask the user if they want to revive their own QR code.
ReviveVerifyContact { ReviveVerifyContact {
/// Contact ID. /// Contact ID.
@@ -204,27 +157,10 @@ pub enum QrObject {
/// Authentication code. /// Authentication code.
authcode: String, authcode: String,
}, },
/// Ask the user if they want to revive their own broadcast channel invite QR code.
ReviveJoinBroadcast {
/// Broadcast name.
name: String,
/// Globally unique chat ID. Called grpid for historic reasons.
grpid: String,
/// Contact ID. Always `ContactId::SELF`.
contact_id: u32,
/// Fingerprint of the contact key as scanned from the QR code.
fingerprint: String,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// `dclogin:` scheme parameters. /// `dclogin:` scheme parameters.
/// ///
/// Ask the user if they want to login with the email address. /// Ask the user if they want to login with the email address.
Login { Login { address: String },
address: String,
},
} }
impl From<Qr> for QrObject { impl From<Qr> for QrObject {
@@ -235,16 +171,14 @@ impl From<Qr> for QrObject {
fingerprint, fingerprint,
invitenumber, invitenumber,
authcode, authcode,
is_v3,
} => { } => {
let contact_id = contact_id.to_u32(); let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable(); let fingerprint = fingerprint.to_string();
QrObject::AskVerifyContact { QrObject::AskVerifyContact {
contact_id, contact_id,
fingerprint, fingerprint,
invitenumber, invitenumber,
authcode, authcode,
is_v3,
} }
} }
Qr::AskVerifyGroup { Qr::AskVerifyGroup {
@@ -254,10 +188,9 @@ impl From<Qr> for QrObject {
fingerprint, fingerprint,
invitenumber, invitenumber,
authcode, authcode,
is_v3,
} => { } => {
let contact_id = contact_id.to_u32(); let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable(); let fingerprint = fingerprint.to_string();
QrObject::AskVerifyGroup { QrObject::AskVerifyGroup {
grpname, grpname,
grpid, grpid,
@@ -265,28 +198,6 @@ impl From<Qr> for QrObject {
fingerprint, fingerprint,
invitenumber, invitenumber,
authcode, authcode,
is_v3,
}
}
Qr::AskJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
authcode,
invitenumber,
is_v3,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
QrObject::AskJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
authcode,
invitenumber,
is_v3,
} }
} }
Qr::FprOk { contact_id } => { Qr::FprOk { contact_id } => {
@@ -306,7 +217,13 @@ impl From<Qr> for QrObject {
node_addr: serde_json::to_string(node_addr).unwrap_or_default(), node_addr: serde_json::to_string(node_addr).unwrap_or_default(),
auth_token, auth_token,
}, },
Qr::BackupTooNew {} => QrObject::BackupTooNew {}, Qr::WebrtcInstance {
domain,
instance_pattern,
} => QrObject::WebrtcInstance {
domain,
instance_pattern,
},
Qr::Proxy { url, host, port } => QrObject::Proxy { url, host, port }, Qr::Proxy { url, host, port } => QrObject::Proxy { url, host, port },
Qr::Addr { contact_id, draft } => { Qr::Addr { contact_id, draft } => {
let contact_id = contact_id.to_u32(); let contact_id = contact_id.to_u32();
@@ -321,7 +238,7 @@ impl From<Qr> for QrObject {
authcode, authcode,
} => { } => {
let contact_id = contact_id.to_u32(); let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable(); let fingerprint = fingerprint.to_string();
QrObject::WithdrawVerifyContact { QrObject::WithdrawVerifyContact {
contact_id, contact_id,
fingerprint, fingerprint,
@@ -338,7 +255,7 @@ impl From<Qr> for QrObject {
authcode, authcode,
} => { } => {
let contact_id = contact_id.to_u32(); let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable(); let fingerprint = fingerprint.to_string();
QrObject::WithdrawVerifyGroup { QrObject::WithdrawVerifyGroup {
grpname, grpname,
grpid, grpid,
@@ -348,25 +265,6 @@ impl From<Qr> for QrObject {
authcode, authcode,
} }
} }
Qr::WithdrawJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
QrObject::WithdrawJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
}
}
Qr::ReviveVerifyContact { Qr::ReviveVerifyContact {
contact_id, contact_id,
fingerprint, fingerprint,
@@ -374,7 +272,7 @@ impl From<Qr> for QrObject {
authcode, authcode,
} => { } => {
let contact_id = contact_id.to_u32(); let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable(); let fingerprint = fingerprint.to_string();
QrObject::ReviveVerifyContact { QrObject::ReviveVerifyContact {
contact_id, contact_id,
fingerprint, fingerprint,
@@ -391,7 +289,7 @@ impl From<Qr> for QrObject {
authcode, authcode,
} => { } => {
let contact_id = contact_id.to_u32(); let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable(); let fingerprint = fingerprint.to_string();
QrObject::ReviveVerifyGroup { QrObject::ReviveVerifyGroup {
grpname, grpname,
grpid, grpid,
@@ -401,76 +299,7 @@ impl From<Qr> for QrObject {
authcode, authcode,
} }
} }
Qr::ReviveJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
} => {
let contact_id = contact_id.to_u32();
let fingerprint = fingerprint.human_readable();
QrObject::ReviveJoinBroadcast {
name,
grpid,
contact_id,
fingerprint,
invitenumber,
authcode,
}
}
Qr::Login { address, .. } => QrObject::Login { address }, Qr::Login { address, .. } => QrObject::Login { address },
} }
} }
} }
#[derive(Deserialize, TypeDef, schemars::JsonSchema)]
pub enum SecurejoinSource {
/// Because of some problem, it is unknown where the QR code came from.
Unknown,
/// The user opened a link somewhere outside Delta Chat
ExternalLink,
/// The user clicked on a link in a message inside Delta Chat
InternalLink,
/// The user clicked "Paste from Clipboard" in the QR scan activity
Clipboard,
/// The user clicked "Load QR code as image" in the QR scan activity
ImageLoaded,
/// The user scanned a QR code
Scan,
}
#[derive(Deserialize, TypeDef, schemars::JsonSchema)]
pub enum SecurejoinUiPath {
/// The UI path is unknown, or the user didn't open the QR code screen at all.
Unknown,
/// The user directly clicked on the QR icon in the main screen
QrIcon,
/// The user first clicked on the `+` button in the main screen,
/// and then on "New Contact"
NewContact,
}
impl From<SecurejoinSource> for deltachat::SecurejoinSource {
fn from(value: SecurejoinSource) -> Self {
match value {
SecurejoinSource::Unknown => deltachat::SecurejoinSource::Unknown,
SecurejoinSource::ExternalLink => deltachat::SecurejoinSource::ExternalLink,
SecurejoinSource::InternalLink => deltachat::SecurejoinSource::InternalLink,
SecurejoinSource::Clipboard => deltachat::SecurejoinSource::Clipboard,
SecurejoinSource::ImageLoaded => deltachat::SecurejoinSource::ImageLoaded,
SecurejoinSource::Scan => deltachat::SecurejoinSource::Scan,
}
}
}
impl From<SecurejoinUiPath> for deltachat::SecurejoinUiPath {
fn from(value: SecurejoinUiPath) -> Self {
match value {
SecurejoinUiPath::Unknown => deltachat::SecurejoinUiPath::Unknown,
SecurejoinUiPath::QrIcon => deltachat::SecurejoinUiPath::QrIcon,
SecurejoinUiPath::NewContact => deltachat::SecurejoinUiPath::NewContact,
}
}
}

View File

@@ -8,7 +8,7 @@ use typescript_type_def::TypeDef;
/// A single reaction emoji. /// A single reaction emoji.
#[derive(Serialize, TypeDef, schemars::JsonSchema)] #[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "Reaction", rename_all = "camelCase")] #[serde(rename = "Reaction", rename_all = "camelCase")]
pub struct JsonrpcReaction { pub struct JSONRPCReaction {
/// Emoji. /// Emoji.
emoji: String, emoji: String,
@@ -22,32 +22,41 @@ pub struct JsonrpcReaction {
/// Structure representing all reactions to a particular message. /// Structure representing all reactions to a particular message.
#[derive(Serialize, TypeDef, schemars::JsonSchema)] #[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename = "Reactions", rename_all = "camelCase")] #[serde(rename = "Reactions", rename_all = "camelCase")]
pub struct JsonrpcReactions { pub struct JSONRPCReactions {
/// Map from a contact to it's reaction to message. /// Map from a contact to it's reaction to message.
/// There is only a single reaction per contact,
/// but this contains a list of reactions for historical reasons.
reactions_by_contact: BTreeMap<u32, Vec<String>>, reactions_by_contact: BTreeMap<u32, Vec<String>>,
/// Unique reactions and their count, sorted in descending order. /// Unique reactions and their count, sorted in descending order.
reactions: Vec<JsonrpcReaction>, reactions: Vec<JSONRPCReaction>,
} }
impl From<Reactions> for JsonrpcReactions { impl From<Reactions> for JSONRPCReactions {
fn from(reactions: Reactions) -> Self { fn from(reactions: Reactions) -> Self {
let reactions_by_contact: BTreeMap<u32, Vec<String>> = reactions let mut reactions_by_contact: BTreeMap<u32, Vec<String>> = BTreeMap::new();
.iter()
.map(|(key, value)| (key.to_u32(), vec![value.as_str().to_string()])) for contact_id in reactions.contacts() {
.collect(); let reaction = reactions.get(contact_id);
let self_reaction = reactions_by_contact.get(&ContactId::SELF.to_u32()); 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(); let mut reactions_v = Vec::new();
for (emoji, count) in reactions.emoji_sorted_by_frequency() { for (emoji, count) in reactions.emoji_sorted_by_frequency() {
let is_from_self = if let Some(self_reaction) = self_reaction { let is_from_self = if let Some(self_reactions) = self_reactions {
self_reaction.contains(&emoji) self_reactions.contains(&emoji)
} else { } else {
false false
}; };
let reaction = JsonrpcReaction { let reaction = JSONRPCReaction {
emoji, emoji,
count, count,
is_from_self, is_from_self,
@@ -55,7 +64,7 @@ impl From<Reactions> for JsonrpcReactions {
reactions_v.push(reaction) reactions_v.push(reaction)
} }
JsonrpcReactions { JSONRPCReactions {
reactions_by_contact, reactions_by_contact,
reactions: reactions_v, reactions: reactions_v,
} }

View File

@@ -37,10 +37,6 @@ pub struct WebxdcMessageInfo {
internet_access: bool, internet_access: bool,
/// Address to be used for `window.webxdc.selfAddr` in JS land. /// Address to be used for `window.webxdc.selfAddr` in JS land.
self_addr: String, 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. /// Milliseconds to wait before calling `sendUpdate()` again since the last call.
/// Should be exposed to `window.sendUpdateInterval` in JS land. /// Should be exposed to `window.sendUpdateInterval` in JS land.
send_update_interval: usize, send_update_interval: usize,
@@ -64,8 +60,6 @@ impl WebxdcMessageInfo {
request_integration: _, request_integration: _,
internet_access, internet_access,
self_addr, self_addr,
is_app_sender,
is_broadcast,
send_update_interval, send_update_interval,
send_update_max_size, send_update_max_size,
} = message.get_webxdc_info(context).await?; } = message.get_webxdc_info(context).await?;
@@ -78,8 +72,6 @@ impl WebxdcMessageInfo {
source_code_url: maybe_empty_string_to_option(source_code_url), source_code_url: maybe_empty_string_to_option(source_code_url),
internet_access, internet_access,
self_addr, self_addr,
is_app_sender,
is_broadcast,
send_update_interval, send_update_interval,
send_update_max_size, send_update_max_size,
}) })

View File

@@ -85,7 +85,7 @@ mod tests {
assert_eq!(result, response.to_owned()); 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}"#; let response = r#"{"jsonrpc":"2.0","id":2,"result":null}"#;
session.handle_incoming(request).await; session.handle_incoming(request).await;
let result = receiver.recv().await?; let result = receiver.recv().await?;

View File

@@ -0,0 +1,47 @@
#![recursion_limit = "256"]
use std::net::SocketAddr;
use std::path::PathBuf;
use axum::{extract::ws::WebSocketUpgrade, response::Response, routing::get, Extension, Router};
use yerpc::axum::handle_ws_rpc;
use yerpc::{RpcClient, RpcSession};
mod api;
use api::{Accounts, CommandApi};
const DEFAULT_PORT: u16 = 20808;
#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<(), std::io::Error> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let path = std::env::var("DC_ACCOUNTS_PATH").unwrap_or_else(|_| "./accounts".to_string());
let port = std::env::var("DC_PORT")
.map(|port| port.parse::<u16>().expect("DC_PORT must be a number"))
.unwrap_or(DEFAULT_PORT);
log::info!("Starting with accounts directory `{path}`.");
let writable = true;
let accounts = Accounts::new(PathBuf::from(&path), writable).await.unwrap();
let state = CommandApi::new(accounts);
let app = Router::new()
.route("/ws", get(handler))
.layer(Extension(state.clone()));
tokio::spawn(async move {
state.accounts.write().await.start_io().await;
});
let addr = SocketAddr::from(([127, 0, 0, 1], port));
log::info!("JSON-RPC WebSocket server listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
Ok(())
}
async fn handler(ws: WebSocketUpgrade, Extension(api): Extension<CommandApi>) -> Response {
let (client, out_receiver) = RpcClient::new();
let session = RpcSession::new(client.clone(), api.clone());
handle_ws_rpc(ws, out_receiver, session).await
}

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>DeltaChat JSON-RPC example</title>
<style>
body {
font-family: monospace;
background: black;
color: grey;
}
.grid {
display: grid;
grid-template-columns: 3fr 1fr;
grid-template-areas: "a a" "b c";
}
.message {
color: red;
}
#header {
grid-area: a;
color: white;
font-size: 1.2rem;
}
#header a {
color: white;
font-weight: bold;
}
#main {
grid-area: b;
color: green;
}
#main h2,
#main h3 {
color: blue;
}
#side {
grid-area: c;
color: #777;
overflow-y: auto;
}
</style>
<script type="module" src="dist/example.bundle.js"></script>
</head>
<body>
<h1>DeltaChat JSON-RPC example</h1>
<div class="grid">
<div id="header"></div>
<div id="main"></div>
<div id="side"><h2>log</h2></div>
</div>
<p>
Tip: open the dev console and use the client with
<code>window.client</code>
</p>
</body>
</html>

View File

@@ -0,0 +1,109 @@
import { DcEvent, DeltaChat } from "../deltachat.js";
var SELECTED_ACCOUNT = 0;
window.addEventListener("DOMContentLoaded", (_event) => {
(window as any).selectDeltaAccount = (id: string) => {
SELECTED_ACCOUNT = Number(id);
window.dispatchEvent(new Event("account-changed"));
};
console.log("launch run script...");
run().catch((err) => console.error("run failed", err));
});
async function run() {
const $main = document.getElementById("main")!;
const $side = document.getElementById("side")!;
const $head = document.getElementById("header")!;
const client = new DeltaChat("ws://localhost:20808/ws");
(window as any).client = client.rpc;
client.on("ALL", (accountId, event) => {
onIncomingEvent(accountId, event);
});
window.addEventListener("account-changed", async (_event: Event) => {
listChatsForSelectedAccount();
});
await Promise.all([loadAccountsInHeader(), listChatsForSelectedAccount()]);
async function loadAccountsInHeader() {
console.log("load accounts");
const accounts = await client.rpc.getAllAccounts();
console.log("accounts loaded", accounts);
for (const account of accounts) {
if (account.kind === "Configured") {
write(
$head,
`<a href="#" onclick="selectDeltaAccount(${account.id})">
${account.id}: ${account.addr!}
</a>&nbsp;`
);
} else {
write(
$head,
`<a href="#">
${account.id}: (unconfigured)
</a>&nbsp;`
);
}
}
}
async function listChatsForSelectedAccount() {
clear($main);
const selectedAccount = SELECTED_ACCOUNT;
const info = await client.rpc.getAccountInfo(selectedAccount);
if (info.kind !== "Configured") {
return write($main, "Account is not configured");
}
write($main, `<h2>${info.addr!}</h2>`);
const chats = await client.rpc.getChatlistEntries(
selectedAccount,
0,
null,
null
);
for (const chatId of chats) {
const chat = await client.rpc.getFullChatById(selectedAccount, chatId);
write($main, `<h3>${chat.name}</h3>`);
const messageIds = await client.rpc.getMessageIds(
selectedAccount,
chatId,
false,
false
);
const messages = await client.rpc.getMessages(
selectedAccount,
messageIds
);
for (const [_messageId, message] of Object.entries(messages)) {
if (message.kind === "message") write($main, `<p>${message.text}</p>`);
else write($main, `<p>loading error: ${message.error}</p>`);
}
}
}
function onIncomingEvent(accountId: number, event: DcEvent) {
write(
$side,
`
<p class="message">
[<strong>${event.kind}</strong> on account ${accountId}]<br>
<em>f1:</em> ${JSON.stringify(
Object.assign({}, event, { kind: undefined })
)}
</p>`
);
}
}
function write(el: HTMLElement, html: string) {
el.innerHTML += html;
}
function clear(el: HTMLElement) {
el.innerHTML = "";
}

View File

@@ -0,0 +1,29 @@
import { DeltaChat } from "../dist/deltachat.js";
run().catch(console.error);
async function run() {
const delta = new DeltaChat("ws://localhost:20808/ws");
delta.on("event", (event) => {
console.log("event", event.data);
});
const email = process.argv[2];
const password = process.argv[3];
if (!email || !password)
throw new Error(
"USAGE: node node-add-account.js <EMAILADDRESS> <PASSWORD>"
);
console.log(`creating account for ${email}`);
const id = await delta.rpc.addAccount();
console.log(`created account id ${id}`);
await delta.rpc.setConfig(id, "addr", email);
await delta.rpc.setConfig(id, "mail_pw", password);
console.log("configuration updated");
await delta.rpc.configure(id);
console.log("account configured!");
const accounts = await delta.rpc.getAllAccounts();
console.log("accounts", accounts);
console.log("waiting for events...");
}

View File

@@ -0,0 +1,14 @@
import { DeltaChat } from "../dist/deltachat.js";
run().catch(console.error);
async function run() {
const delta = new DeltaChat();
delta.on("event", (event) => {
console.log("event", event.data);
});
const accounts = await delta.rpc.getAllAccounts();
console.log("accounts", accounts);
console.log("waiting for events...");
}

View File

@@ -2,24 +2,24 @@
"author": "Delta Chat Developers (ML) <delta@codespeak.net>", "author": "Delta Chat Developers (ML) <delta@codespeak.net>",
"dependencies": { "dependencies": {
"@deltachat/tiny-emitter": "3.0.0", "@deltachat/tiny-emitter": "3.0.0",
"isomorphic-ws": "^5.0.0", "isomorphic-ws": "^4.0.1",
"yerpc": "^0.6.2" "yerpc": "^0.6.2"
}, },
"devDependencies": { "devDependencies": {
"@types/chai": "^4.3.10", "@types/chai": "^4.2.21",
"@types/chai-as-promised": "^7.1.8", "@types/chai-as-promised": "^7.1.5",
"@types/mocha": "^10.0.4", "@types/mocha": "^9.0.0",
"@types/ws": "^8.5.9", "@types/ws": "^7.2.4",
"c8": "^8.0.1", "c8": "^7.10.0",
"chai": "^4.3.4", "chai": "^4.3.4",
"chai-as-promised": "^7.1.1", "chai-as-promised": "^7.1.1",
"esbuild": "^0.25.5", "esbuild": "^0.17.9",
"http-server": "^14.1.1", "http-server": "^14.1.1",
"mocha": "^10.2.0", "mocha": "^9.1.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^3.5.3", "prettier": "^2.6.2",
"typedoc": "^0.28.5", "typedoc": "^0.23.2",
"typescript": "^5.8.3", "typescript": "^4.5.5",
"ws": "^8.5.0" "ws": "^8.5.0"
}, },
"exports": { "exports": {
@@ -34,7 +34,7 @@
"name": "@deltachat/jsonrpc-client", "name": "@deltachat/jsonrpc-client",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/chatmail/core.git" "url": "https://github.com/deltachat/deltachat-core-rust.git"
}, },
"scripts": { "scripts": {
"build": "run-s generate-bindings extract-constants build:tsc build:bundle build:cjs", "build": "run-s generate-bindings extract-constants build:tsc build:bundle build:cjs",
@@ -42,6 +42,10 @@
"build:cjs": "esbuild --format=cjs --bundle --packages=external dist/deltachat.js --outfile=dist/deltachat.cjs", "build:cjs": "esbuild --format=cjs --bundle --packages=external dist/deltachat.js --outfile=dist/deltachat.cjs",
"build:tsc": "tsc", "build:tsc": "tsc",
"docs": "typedoc --out docs deltachat.ts", "docs": "typedoc --out docs deltachat.ts",
"example": "run-s build example:build example:start",
"example:build": "esbuild --bundle dist/example/example.js --outfile=dist/example.bundle.js",
"example:dev": "esbuild example/example.ts --bundle --outfile=dist/example.bundle.js --servedir=.",
"example:start": "http-server .",
"extract-constants": "node ./scripts/generate-constants.js", "extract-constants": "node ./scripts/generate-constants.js",
"generate-bindings": "cargo test", "generate-bindings": "cargo test",
"prettier:check": "prettier --check .", "prettier:check": "prettier --check .",
@@ -54,5 +58,5 @@
}, },
"type": "module", "type": "module",
"types": "dist/deltachat.d.ts", "types": "dist/deltachat.d.ts",
"version": "2.50.0-dev" "version": "1.152.0"
} }

View File

@@ -5,24 +5,24 @@ const json = JSON.parse(readFileSync("./coverage/coverage-final.json"));
const jsonCoverage = const jsonCoverage =
json[Object.keys(json).find((k) => k.includes(generatedFile))]; json[Object.keys(json).find((k) => k.includes(generatedFile))];
const fnMap = Object.keys(jsonCoverage.fnMap).map( const fnMap = Object.keys(jsonCoverage.fnMap).map(
(key) => jsonCoverage.fnMap[key], (key) => jsonCoverage.fnMap[key]
); );
const htmlCoverage = readFileSync( const htmlCoverage = readFileSync(
"./coverage/" + generatedFile + ".html", "./coverage/" + generatedFile + ".html",
"utf8", "utf8"
); );
const uncoveredLines = htmlCoverage const uncoveredLines = htmlCoverage
.split("\n") .split("\n")
.filter((line) => line.includes(`"function not covered"`)); .filter((line) => line.includes(`"function not covered"`));
const uncoveredFunctions = uncoveredLines.map( const uncoveredFunctions = uncoveredLines.map(
(line) => />([\w_]+)\(/.exec(line)[1], (line) => />([\w_]+)\(/.exec(line)[1]
); );
console.log( console.log(
"\nUncovered api functions:\n" + "\nUncovered api functions:\n" +
uncoveredFunctions uncoveredFunctions
.map((uF) => fnMap.find(({ name }) => name === uF)) .map((uF) => fnMap.find(({ name }) => name === uF))
.map( .map(
({ name, line }) => `.${name.padEnd(40)} (${generatedFile}:${line})`, ({ name, line }) => `.${name.padEnd(40)} (${generatedFile}:${line})`
) )
.join("\n"), .join("\n")
); );

View File

@@ -24,7 +24,7 @@ while (null != (match = regex.exec(header_data))) {
const constants = data const constants = data
.filter( .filter(
({ key }) => key.toUpperCase()[0] === key[0], // check if define name is uppercase ({ key }) => key.toUpperCase()[0] === key[0] // check if define name is uppercase
) )
.sort((lhs, rhs) => { .sort((lhs, rhs) => {
if (lhs.key < rhs.key) return -1; if (lhs.key < rhs.key) return -1;
@@ -40,35 +40,15 @@ const constants = data
key.startsWith("DC_DOWNLOAD") || key.startsWith("DC_DOWNLOAD") ||
key.startsWith("DC_INFO_") || key.startsWith("DC_INFO_") ||
(key.startsWith("DC_MSG") && !key.startsWith("DC_MSG_ID")) || (key.startsWith("DC_MSG") && !key.startsWith("DC_MSG_ID")) ||
key.startsWith("DC_QR_") || key.startsWith("DC_QR_")
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")
); );
}) })
.map((row) => { .map((row) => {
return ` export const ${row.key} = ${row.value};`; return ` ${row.key}: ${row.value}`;
}) })
.join("\n"); .join(",\n");
writeFileSync( writeFileSync(
resolve(__dirname, "../generated/constants.ts"), resolve(__dirname, "../generated/constants.ts"),
`// Generated! `// Generated!\n\nexport enum C {\n${constants.replace(/:/g, " =")},\n}\n`
export namespace C {
${constants}
/** @deprecated 10-8-2025 compare string directly with \`== "Group"\` */
export const DC_CHAT_TYPE_GROUP = "Group";
/** @deprecated 10-8-2025 compare string directly with \`== "InBroadcast"\`*/
export const DC_CHAT_TYPE_IN_BROADCAST = "InBroadcast";
/** @deprecated 10-8-2025 compare string directly with \`== "Mailinglist"\` */
export const DC_CHAT_TYPE_MAILINGLIST = "Mailinglist";
/** @deprecated 10-8-2025 compare string directly with \`== "OutBroadcast"\` */
export const DC_CHAT_TYPE_OUT_BROADCAST = "OutBroadcast";
/** @deprecated 10-8-2025 compare string directly with \`== "Single"\` */
export const DC_CHAT_TYPE_SINGLE = "Single";
}\n`,
); );

View File

@@ -2,19 +2,19 @@ import * as T from "../generated/types.js";
import { EventType } from "../generated/types.js"; import { EventType } from "../generated/types.js";
import * as RPC from "../generated/jsonrpc.js"; import * as RPC from "../generated/jsonrpc.js";
import { RawClient } from "../generated/client.js"; import { RawClient } from "../generated/client.js";
import { BaseTransport, Request } from "yerpc"; import { WebsocketTransport, BaseTransport, Request } from "yerpc";
import { TinyEmitter } from "@deltachat/tiny-emitter"; import { TinyEmitter } from "@deltachat/tiny-emitter";
type Events = { ALL: (accountId: number, event: EventType) => void } & { type Events = { ALL: (accountId: number, event: EventType) => void } & {
[Property in EventType["kind"]]: ( [Property in EventType["kind"]]: (
accountId: number, accountId: number,
event: Extract<EventType, { kind: Property }>, event: Extract<EventType, { kind: Property }>
) => void; ) => void;
}; };
type ContextEvents = { ALL: (event: EventType) => void } & { type ContextEvents = { ALL: (event: EventType) => void } & {
[Property in EventType["kind"]]: ( [Property in EventType["kind"]]: (
event: Extract<EventType, { kind: Property }>, event: Extract<EventType, { kind: Property }>
) => void; ) => void;
}; };
@@ -25,22 +25,16 @@ export type DcEventType<T extends EventType["kind"]> = Extract<
>; >;
export class BaseDeltaChat< export class BaseDeltaChat<
Transport extends BaseTransport<any>, Transport extends BaseTransport<any>
> extends TinyEmitter<Events> { > extends TinyEmitter<Events> {
rpc: RawClient; rpc: RawClient;
account?: T.Account;
private contextEmitters: { [key: number]: TinyEmitter<ContextEvents> } = {}; private contextEmitters: { [key: number]: TinyEmitter<ContextEvents> } = {};
//@ts-ignore //@ts-ignore
private eventTask: Promise<void>; private eventTask: Promise<void>;
constructor( constructor(public transport: Transport, startEventLoop: boolean) {
public transport: Transport,
/**
* Whether to start calling {@linkcode RawClient.getNextEvent}
* and emitting the respective events on this class.
*/
startEventLoop: boolean,
) {
super(); super();
this.rpc = new RawClient(this.transport); this.rpc = new RawClient(this.transport);
if (startEventLoop) { if (startEventLoop) {
@@ -48,39 +42,28 @@ export class BaseDeltaChat<
} }
} }
/**
* @see the constructor's `startEventLoop`
*/
async eventLoop(): Promise<void> { async eventLoop(): Promise<void> {
while (true) { while (true) {
for (const event of await this.rpc.getNextEventBatch()) { const event = await this.rpc.getNextEvent();
//@ts-ignore //@ts-ignore
this.emit(event.event.kind, event.contextId, event.event); this.emit(event.event.kind, event.contextId, event.event);
this.emit("ALL", event.contextId, event.event); this.emit("ALL", event.contextId, event.event);
if (this.contextEmitters[event.contextId]) { if (this.contextEmitters[event.contextId]) {
this.contextEmitters[event.contextId].emit( this.contextEmitters[event.contextId].emit(
event.event.kind, event.event.kind,
//@ts-ignore //@ts-ignore
event.event as any, event.event as any
); );
this.contextEmitters[event.contextId].emit("ALL", event.event as any); this.contextEmitters[event.contextId].emit("ALL", event.event as any);
}
} }
} }
} }
/**
* @deprecated use {@linkcode BaseDeltaChat.rpc.getAllAccounts} instead.
*/
async listAccounts(): Promise<T.Account[]> { async listAccounts(): Promise<T.Account[]> {
return await this.rpc.getAllAccounts(); return await this.rpc.getAllAccounts();
} }
/**
* A convenience function to listen on events binned by `account_id`
* (see {@linkcode RawClient.getAllAccounts}).
*/
getContextEvents(account_id: number) { getContextEvents(account_id: number) {
if (this.contextEmitters[account_id]) { if (this.contextEmitters[account_id]) {
return this.contextEmitters[account_id]; return this.contextEmitters[account_id];
@@ -91,6 +74,34 @@ export class BaseDeltaChat<
} }
} }
export type Opts = {
url: string;
startEventLoop: boolean;
};
export const DEFAULT_OPTS: Opts = {
url: "ws://localhost:20808/ws",
startEventLoop: true,
};
export class DeltaChat extends BaseDeltaChat<WebsocketTransport> {
opts: Opts;
close() {
this.transport.close();
}
constructor(opts?: Opts | string) {
if (typeof opts === "string") {
opts = { ...DEFAULT_OPTS, url: opts };
} else if (opts) {
opts = { ...DEFAULT_OPTS, ...opts };
} else {
opts = { ...DEFAULT_OPTS };
}
const transport = new WebsocketTransport(opts.url);
super(transport, opts.startEventLoop);
this.opts = opts;
}
}
export class StdioDeltaChat extends BaseDeltaChat<StdioTransport> { export class StdioDeltaChat extends BaseDeltaChat<StdioTransport> {
close() {} close() {}
constructor(input: any, output: any, startEventLoop: boolean) { constructor(input: any, output: any, startEventLoop: boolean) {
@@ -100,10 +111,7 @@ export class StdioDeltaChat extends BaseDeltaChat<StdioTransport> {
} }
export class StdioTransport extends BaseTransport { export class StdioTransport extends BaseTransport {
constructor( constructor(public input: any, public output: any) {
public input: any,
public output: any,
) {
super(); super();
var buffer = ""; var buffer = "";

View File

@@ -1,3 +1,4 @@
import { strictEqual } from "assert";
import chai, { assert, expect } from "chai"; import chai, { assert, expect } from "chai";
import chaiAsPromised from "chai-as-promised"; import chaiAsPromised from "chai-as-promised";
chai.use(chaiAsPromised); chai.use(chaiAsPromised);
@@ -31,14 +32,14 @@ describe("basic tests", () => {
expect( expect(
await Promise.all( await Promise.all(
validAddresses.map((email) => dc.rpc.checkEmailValidity(email)), validAddresses.map((email) => dc.rpc.checkEmailValidity(email))
), )
).to.not.contain(false); ).to.not.contain(false);
expect( expect(
await Promise.all( await Promise.all(
invalidAddresses.map((email) => dc.rpc.checkEmailValidity(email)), invalidAddresses.map((email) => dc.rpc.checkEmailValidity(email))
), )
).to.not.contain(true); ).to.not.contain(true);
}); });
@@ -84,7 +85,7 @@ describe("basic tests", () => {
const contactId = await dc.rpc.createContact( const contactId = await dc.rpc.createContact(
accountId, accountId,
"example@delta.chat", "example@delta.chat",
null, null
); );
expect((await dc.rpc.getContact(accountId, contactId)).isBlocked).to.be expect((await dc.rpc.getContact(accountId, contactId)).isBlocked).to.be
.false; .false;
@@ -126,7 +127,7 @@ describe("basic tests", () => {
await dc.rpc.batchSetConfig(accountId, config); await dc.rpc.batchSetConfig(accountId, config);
const retrieved = await dc.rpc.batchGetConfig( const retrieved = await dc.rpc.batchGetConfig(
accountId, accountId,
Object.keys(config), Object.keys(config)
); );
expect(retrieved).to.deep.equal(config); expect(retrieved).to.deep.equal(config);
}); });
@@ -138,7 +139,7 @@ describe("basic tests", () => {
await dc.rpc.batchSetConfig(accountId, config); await dc.rpc.batchSetConfig(accountId, config);
const retrieved = await dc.rpc.batchGetConfig( const retrieved = await dc.rpc.batchGetConfig(
accountId, accountId,
Object.keys(config), Object.keys(config)
); );
expect(retrieved).to.deep.equal(config); expect(retrieved).to.deep.equal(config);
}); });
@@ -152,7 +153,7 @@ describe("basic tests", () => {
await dc.rpc.batchSetConfig(accountId, config); await dc.rpc.batchSetConfig(accountId, config);
const retrieved = await dc.rpc.batchGetConfig( const retrieved = await dc.rpc.batchGetConfig(
accountId, accountId,
Object.keys(config), Object.keys(config)
); );
expect(retrieved).to.deep.equal(config); expect(retrieved).to.deep.equal(config);
}); });

View File

@@ -1,5 +1,5 @@
import { assert, expect } from "chai"; import { assert, expect } from "chai";
import { StdioDeltaChat as DeltaChat, DcEvent, C } from "../deltachat.js"; import { StdioDeltaChat as DeltaChat, DcEvent } from "../deltachat.js";
import { RpcServerHandle, createTempUser, startServer } from "./test_base.js"; import { RpcServerHandle, createTempUser, startServer } from "./test_base.js";
const EVENT_TIMEOUT = 20000; const EVENT_TIMEOUT = 20000;
@@ -17,12 +17,12 @@ describe("online tests", function () {
if (process.env.COVERAGE && !process.env.COVERAGE_OFFLINE) { if (process.env.COVERAGE && !process.env.COVERAGE_OFFLINE) {
console.error( console.error(
"CAN NOT RUN COVERAGE correctly: Missing CHATMAIL_DOMAIN environment variable!\n\n", "CAN NOT RUN COVERAGE correctly: Missing CHATMAIL_DOMAIN environment variable!\n\n",
"You can set COVERAGE_OFFLINE=1 to circumvent this check and skip the online tests, but those coverage results will be wrong, because some functions can only be tested in the online test", "You can set COVERAGE_OFFLINE=1 to circumvent this check and skip the online tests, but those coverage results will be wrong, because some functions can only be tested in the online test"
); );
process.exit(1); process.exit(1);
} }
console.log( console.log(
"Missing CHATMAIL_DOMAIN environment variable!, skip integration tests", "Missing CHATMAIL_DOMAIN environment variable!, skip integration tests"
); );
this.skip(); this.skip();
} }
@@ -36,7 +36,7 @@ describe("online tests", function () {
account1 = createTempUser(process.env.CHATMAIL_DOMAIN); account1 = createTempUser(process.env.CHATMAIL_DOMAIN);
if (!account1 || !account1.email || !account1.password) { if (!account1 || !account1.email || !account1.password) {
console.log( console.log(
"We didn't got back an account from the api, skip integration tests", "We didn't got back an account from the api, skip integration tests"
); );
this.skip(); this.skip();
} }
@@ -44,7 +44,7 @@ describe("online tests", function () {
account2 = createTempUser(process.env.CHATMAIL_DOMAIN); account2 = createTempUser(process.env.CHATMAIL_DOMAIN);
if (!account2 || !account2.email || !account2.password) { if (!account2 || !account2.email || !account2.password) {
console.log( console.log(
"We didn't got back an account2 from the api, skip integration tests", "We didn't got back an account2 from the api, skip integration tests"
); );
this.skip(); this.skip();
} }
@@ -64,7 +64,6 @@ describe("online tests", function () {
await dc.rpc.setConfig(accountId1, "addr", account1.email); await dc.rpc.setConfig(accountId1, "addr", account1.email);
await dc.rpc.setConfig(accountId1, "mail_pw", account1.password); await dc.rpc.setConfig(accountId1, "mail_pw", account1.password);
await dc.rpc.configure(accountId1); await dc.rpc.configure(accountId1);
await waitForEvent(dc, "ImapInboxIdle", accountId1);
accountId2 = await dc.rpc.addAccount(); accountId2 = await dc.rpc.addAccount();
await dc.rpc.batchSetConfig(accountId2, { await dc.rpc.batchSetConfig(accountId2, {
@@ -72,7 +71,6 @@ describe("online tests", function () {
mail_pw: account2.password, mail_pw: account2.password,
}); });
await dc.rpc.configure(accountId2); await dc.rpc.configure(accountId2);
await waitForEvent(dc, "ImapInboxIdle", accountId2);
accountsConfigured = true; accountsConfigured = true;
}); });
@@ -82,8 +80,11 @@ describe("online tests", function () {
} }
this.timeout(15000); this.timeout(15000);
const vcard = await dc.rpc.makeVcard(accountId2, [C.DC_CONTACT_ID_SELF]); const contactId = await dc.rpc.createContact(
const contactId = (await dc.rpc.importVcardContents(accountId1, vcard))[0]; accountId1,
account2.email,
null
);
const chatId = await dc.rpc.createChatByContactId(accountId1, contactId); const chatId = await dc.rpc.createChatByContactId(accountId1, contactId);
const eventPromise = waitForEvent(dc, "IncomingMsg", accountId2); const eventPromise = waitForEvent(dc, "IncomingMsg", accountId2);
@@ -94,26 +95,26 @@ describe("online tests", function () {
accountId2, accountId2,
chatIdOnAccountB, chatIdOnAccountB,
false, false,
false, false
); );
// There are 2 messages in the chat: expect(messageList).have.length(1);
// 'Messages are end-to-end encrypted' (info message) and 'Hello' const message = await dc.rpc.getMessage(accountId2, messageList[0]);
expect(messageList).have.length(2);
const message = await dc.rpc.getMessage(accountId2, messageList[1]);
expect(message.text).equal("Hello"); expect(message.text).equal("Hello");
expect(message.showPadlock).equal(true);
}); });
it("send and receive text message roundtrip", async function () { it("send and receive text message roundtrip, encrypted on answer onwards", async function () {
if (!accountsConfigured) { if (!accountsConfigured) {
this.skip(); this.skip();
} }
this.timeout(10000); this.timeout(10000);
// send message from A to B // send message from A to B
const vcard = await dc.rpc.makeVcard(accountId2, [C.DC_CONTACT_ID_SELF]); const contactId = await dc.rpc.createContact(
const contactId = (await dc.rpc.importVcardContents(accountId1, vcard))[0]; accountId1,
account2.email,
null
);
const chatId = await dc.rpc.createChatByContactId(accountId1, contactId); const chatId = await dc.rpc.createChatByContactId(accountId1, contactId);
const eventPromise = waitForEvent(dc, "IncomingMsg", accountId2); const eventPromise = waitForEvent(dc, "IncomingMsg", accountId2);
dc.rpc.miscSendTextMessage(accountId1, chatId, "Hello2"); dc.rpc.miscSendTextMessage(accountId1, chatId, "Hello2");
@@ -128,11 +129,11 @@ describe("online tests", function () {
accountId2, accountId2,
chatIdOnAccountB, chatIdOnAccountB,
false, false,
false, false
); );
const message = await dc.rpc.getMessage( const message = await dc.rpc.getMessage(
accountId2, accountId2,
messageList.reverse()[0], messageList.reverse()[0]
); );
expect(message.text).equal("Hello2"); expect(message.text).equal("Hello2");
// Send message back from B to A // Send message back from B to A
@@ -154,7 +155,7 @@ describe("online tests", function () {
const info = await dc.rpc.getProviderInfo(acc, "example.com"); const info = await dc.rpc.getProviderInfo(acc, "example.com");
expect(info).to.be.not.null; expect(info).to.be.not.null;
expect(info?.overviewPage).to.equal( expect(info?.overviewPage).to.equal(
"https://providers.delta.chat/example-com", "https://providers.delta.chat/example-com"
); );
expect(info?.status).to.equal(3); expect(info?.status).to.equal(3);
}); });
@@ -171,12 +172,12 @@ async function waitForEvent<T extends DcEvent["kind"]>(
dc: DeltaChat, dc: DeltaChat,
eventType: T, eventType: T,
accountId: number, accountId: number,
timeout: number = EVENT_TIMEOUT, timeout: number = EVENT_TIMEOUT
): Promise<Extract<DcEvent, { kind: T }>> { ): Promise<Extract<DcEvent, { kind: T }>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const rejectTimeout = setTimeout( const rejectTimeout = setTimeout(
() => reject(new Error("Timeout reached before event came in")), () => reject(new Error("Timeout reached before event came in")),
timeout, timeout
); );
const callback = (contextId: number, event: DcEvent) => { const callback = (contextId: number, event: DcEvent) => {
if (contextId == accountId) { if (contextId == accountId) {

View File

@@ -14,7 +14,7 @@ export async function startServer(): Promise<RpcServerHandle> {
const tmpDir = await mkdtemp(join(tmpdir(), "deltachat-jsonrpc-test")); const tmpDir = await mkdtemp(join(tmpdir(), "deltachat-jsonrpc-test"));
const pathToServerBinary = resolve( const pathToServerBinary = resolve(
join(await getTargetDir(), "debug/deltachat-rpc-server"), join(await getTargetDir(), "debug/deltachat-rpc-server")
); );
const server = spawn(pathToServerBinary, { const server = spawn(pathToServerBinary, {
@@ -29,7 +29,7 @@ export async function startServer(): Promise<RpcServerHandle> {
throw new Error( throw new Error(
"Failed to start server executable " + "Failed to start server executable " +
pathToServerBinary + pathToServerBinary +
", make sure you built it first.", ", make sure you built it first."
); );
}); });
let shouldClose = false; let shouldClose = false;
@@ -83,7 +83,7 @@ function getTargetDir(): Promise<string> {
reject(error); reject(error);
} }
} }
}, }
); );
}); });
} }

View File

@@ -15,6 +15,6 @@
"noImplicitAny": true, "noImplicitAny": true,
"isolatedModules": true "isolatedModules": true
}, },
"include": ["*.ts", "test/*.ts"], "include": ["*.ts", "example/*.ts", "test/*.ts"],
"compileOnSave": false "compileOnSave": false
} }

View File

@@ -1,19 +1,19 @@
[package] [package]
name = "deltachat-repl" name = "deltachat-repl"
version = "2.50.0-dev" version = "1.152.0"
license = "MPL-2.0" license = "MPL-2.0"
edition = "2021" edition = "2021"
repository = "https://github.com/chatmail/core" repository = "https://github.com/deltachat/deltachat-core-rust"
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = { workspace = true }
deltachat = { workspace = true, features = ["internals"]} deltachat = { workspace = true, features = ["internals"]}
dirs = "6" dirs = "5"
log = { workspace = true } log = { workspace = true }
nu-ansi-term = { workspace = true } nu-ansi-term = { workspace = true }
qr2term = "0.3.3" qr2term = "0.3.3"
rusqlite = { workspace = true } rusqlite = { workspace = true }
rustyline = "16" rustyline = "15"
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] } tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
tracing-subscriber = { workspace = true, features = ["env-filter"] } tracing-subscriber = { workspace = true, features = ["env-filter"] }

View File

@@ -6,7 +6,9 @@ use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
use anyhow::{bail, ensure, Result}; use anyhow::{bail, ensure, Result};
use deltachat::chat::{self, Chat, ChatId, ChatItem, ChatVisibility, MuteDuration}; use deltachat::chat::{
self, Chat, ChatId, ChatItem, ChatVisibility, MuteDuration, ProtectionStatus,
};
use deltachat::chatlist::*; use deltachat::chatlist::*;
use deltachat::constants::*; use deltachat::constants::*;
use deltachat::contact::*; use deltachat::contact::*;
@@ -18,6 +20,7 @@ use deltachat::log::LogExt;
use deltachat::message::{self, Message, MessageState, MsgId, Viewtype}; use deltachat::message::{self, Message, MessageState, MsgId, Viewtype};
use deltachat::mimeparser::SystemMessage; use deltachat::mimeparser::SystemMessage;
use deltachat::peer_channels::{send_webxdc_realtime_advertisement, send_webxdc_realtime_data}; use deltachat::peer_channels::{send_webxdc_realtime_advertisement, send_webxdc_realtime_data};
use deltachat::peerstate::*;
use deltachat::qr::*; use deltachat::qr::*;
use deltachat::qr_code_generator::create_qr_svg; use deltachat::qr_code_generator::create_qr_svg;
use deltachat::reaction::send_reaction; use deltachat::reaction::send_reaction;
@@ -32,6 +35,14 @@ use tokio::fs;
/// e.g. bitmask 7 triggers actions defined with bits 1, 2 and 4. /// e.g. bitmask 7 triggers actions defined with bits 1, 2 and 4.
async fn reset_tables(context: &Context, bits: i32) { async fn reset_tables(context: &Context, bits: i32) {
println!("Resetting tables ({bits})..."); println!("Resetting tables ({bits})...");
if 0 != bits & 2 {
context
.sql()
.execute("DELETE FROM acpeerstates;", ())
.await
.unwrap();
println!("(2) Peerstates reset.");
}
if 0 != bits & 4 { if 0 != bits & 4 {
context context
.sql() .sql()
@@ -70,17 +81,22 @@ async fn reset_tables(context: &Context, bits: i32) {
.await .await
.unwrap(); .unwrap();
context.sql().config_cache().write().await.clear(); context.sql().config_cache().write().await.clear();
context
.sql()
.execute("DELETE FROM leftgrps;", ())
.await
.unwrap();
println!("(8) Rest but server config reset."); println!("(8) Rest but server config reset.");
} }
context.emit_msgs_changed_without_ids(); context.emit_msgs_changed_without_ids();
} }
async fn poke_eml_file(context: &Context, filename: &Path) -> Result<()> { async fn poke_eml_file(context: &Context, filename: impl AsRef<Path>) -> Result<()> {
let data = read_file(context, filename).await?; let data = read_file(context, filename).await?;
if let Err(err) = receive_imf(context, &data, false).await { if let Err(err) = receive_imf(context, &data, false).await {
eprintln!("receive_imf errored: {err:?}"); println!("receive_imf errored: {err:?}");
} }
Ok(()) Ok(())
} }
@@ -104,13 +120,13 @@ async fn poke_spec(context: &Context, spec: Option<&str>) -> bool {
} else { } else {
let rs = context.sql().get_raw_config("import_spec").await.unwrap(); let rs = context.sql().get_raw_config("import_spec").await.unwrap();
if rs.is_none() { if rs.is_none() {
eprintln!("Import: No file or folder given."); error!(context, "Import: No file or folder given.");
return false; return false;
} }
real_spec = rs.unwrap(); real_spec = rs.unwrap();
} }
if let Some(suffix) = get_filesuffix_lc(&real_spec) { if let Some(suffix) = get_filesuffix_lc(&real_spec) {
if suffix == "eml" && poke_eml_file(context, Path::new(&real_spec)).await.is_ok() { if suffix == "eml" && poke_eml_file(context, &real_spec).await.is_ok() {
read_cnt += 1 read_cnt += 1
} }
} else { } else {
@@ -124,16 +140,13 @@ async fn poke_spec(context: &Context, spec: Option<&str>) -> bool {
if name.ends_with(".eml") { 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}"); println!("Import: {path_plus_name}");
if poke_eml_file(context, Path::new(&path_plus_name)) if poke_eml_file(context, path_plus_name).await.is_ok() {
.await
.is_ok()
{
read_cnt += 1 read_cnt += 1
} }
} }
} }
} else { } else {
eprintln!("Import: Cannot open directory \"{}\".", &real_spec); error!(context, "Import: Cannot open directory \"{}\".", &real_spec);
return false; return false;
} }
} }
@@ -203,7 +216,13 @@ async fn log_msg(context: &Context, prefix: impl AsRef<str>, msg: &Message) {
} else { } else {
"" ""
}, },
if msg.get_viewtype() == Viewtype::Webxdc { if msg.get_viewtype() == Viewtype::VideochatInvitation {
format!(
"[VIDEOCHAT-INVITATION: {}, type={}]",
msg.get_videochat_url().unwrap_or_default(),
msg.get_videochat_type().unwrap_or_default()
)
} else if msg.get_viewtype() == Viewtype::Webxdc {
match msg.get_webxdc_info(context).await { match msg.get_webxdc_info(context).await {
Ok(info) => format!( Ok(info) => format!(
"[WEBXDC: {}, icon={}, document={}, summary={}, source_code_url={}]", "[WEBXDC: {}, icon={}, document={}, summary={}, source_code_url={}]",
@@ -255,7 +274,7 @@ async fn log_msglist(context: &Context, msglist: &[MsgId]) -> Result<()> {
async fn log_contactlist(context: &Context, contacts: &[ContactId]) -> Result<()> { async fn log_contactlist(context: &Context, contacts: &[ContactId]) -> Result<()> {
for contact_id in contacts { for contact_id in contacts {
let line2 = "".to_string(); let mut line2 = "".to_string();
let contact = Contact::get_by_id(context, *contact_id).await?; let contact = Contact::get_by_id(context, *contact_id).await?;
let name = contact.get_display_name(); let name = contact.get_display_name();
let addr = contact.get_addr(); let addr = contact.get_addr();
@@ -274,6 +293,15 @@ async fn log_contactlist(context: &Context, contacts: &[ContactId]) -> Result<()
verified_str, verified_str,
if !addr.is_empty() { addr } else { "addr unset" } if !addr.is_empty() { addr } else { "addr unset" }
); );
let peerstate = Peerstate::from_addr(context, addr)
.await
.expect("peerstate error");
if peerstate.is_some() && *contact_id != ContactId::SELF {
line2 = format!(
", prefer-encrypt={}",
peerstate.as_ref().unwrap().prefer_encrypt
);
}
println!("Contact#{}: {}{}", *contact_id, line, line2); println!("Contact#{}: {}{}", *contact_id, line, line2);
} }
@@ -302,13 +330,16 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
// TODO: reuse commands definition in main.rs. // TODO: reuse commands definition in main.rs.
"imex" => println!( "imex" => println!(
"====================Import/Export commands==\n\ "====================Import/Export commands==\n\
initiate-key-transfer\n\
get-setupcodebegin <msg-id>\n\
continue-key-transfer <msg-id> <setup-code>\n\
has-backup\n\ has-backup\n\
export-backup\n\ export-backup\n\
import-backup <backup-file>\n\ import-backup <backup-file>\n\
send-backup\n\ send-backup\n\
receive-backup <qr>\n\ receive-backup <qr>\n\
export-keys\n\ export-keys\n\
import-keys <key-file>\n\ import-keys\n\
poke [<eml-file>|<folder>|<addr> <key-file>]\n\ poke [<eml-file>|<folder>|<addr> <key-file>]\n\
reset <flags>\n\ reset <flags>\n\
stop\n\ stop\n\
@@ -317,6 +348,8 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
_ => println!( _ => println!(
"==========================Database commands==\n\ "==========================Database commands==\n\
info\n\ info\n\
open <file to open or create>\n\
close\n\
set <configuration-key> [<value>]\n\ set <configuration-key> [<value>]\n\
get <configuration-key>\n\ get <configuration-key>\n\
oauth2\n\ oauth2\n\
@@ -331,30 +364,28 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
==============================Chat commands==\n\ ==============================Chat commands==\n\
listchats [<query>]\n\ listchats [<query>]\n\
listarchived\n\ listarchived\n\
start-realtime <msg-id>\n\
send-realtime <msg-id> <data>\n\
chat [<chat-id>|0]\n\ chat [<chat-id>|0]\n\
createchat <contact-id>\n\ createchat <contact-id>\n\
creategroup <name>\n\ creategroup <name>\n\
createbroadcast <name>\n\ createbroadcast\n\
createprotected <name>\n\
addmember <contact-id>\n\ addmember <contact-id>\n\
removemember <contact-id>\n\ removemember <contact-id>\n\
groupname <name>\n\ groupname <name>\n\
groupdescription <description>\n\ groupimage [<file>]\n\
groupimage <image>\n\
chatinfo\n\ chatinfo\n\
sendlocations <seconds>\n\ sendlocations <seconds>\n\
setlocation <lat> <lng>\n\ setlocation <lat> <lng>\n\
dellocations\n\
getlocations [<contact-id>]\n\ getlocations [<contact-id>]\n\
send <text>\n\ send <text>\n\
send-sync <text>\n\
sendempty\n\
sendimage <file> [<text>]\n\ sendimage <file> [<text>]\n\
sendsticker <file> [<text>]\n\ sendsticker <file> [<text>]\n\
sendfile <file> [<text>]\n\ sendfile <file> [<text>]\n\
sendhtml <file for html-part> [<text for plain-part>]\n\ sendhtml <file for html-part> [<text for plain-part>]\n\
sendsyncmsg\n\ sendsyncmsg\n\
sendupdate <msg-id> <json status update>\n\ sendupdate <msg-id> <json status update>\n\
videochat\n\
draft [<text>]\n\ draft [<text>]\n\
devicemsg <text>\n\ devicemsg <text>\n\
listmedia\n\ listmedia\n\
@@ -366,7 +397,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
unmute <chat-id>\n\ unmute <chat-id>\n\
delchat <chat-id>\n\ delchat <chat-id>\n\
accept <chat-id>\n\ accept <chat-id>\n\
blockchat <chat-id>\n\ decline <chat-id>\n\
===========================Message commands==\n\ ===========================Message commands==\n\
listmsgs <query>\n\ listmsgs <query>\n\
msginfo <msg-id>\n\ msginfo <msg-id>\n\
@@ -380,14 +411,14 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
react <msg-id> [<reaction>]\n\ react <msg-id> [<reaction>]\n\
===========================Contact commands==\n\ ===========================Contact commands==\n\
listcontacts [<query>]\n\ listcontacts [<query>]\n\
listverified [<query>]\n\
addcontact [<name>] <addr>\n\ addcontact [<name>] <addr>\n\
contactinfo <contact-id>\n\ contactinfo <contact-id>\n\
delcontact <contact-id>\n\ delcontact <contact-id>\n\
cleanupcontacts\n\
block <contact-id>\n\ block <contact-id>\n\
unblock <contact-id>\n\ unblock <contact-id>\n\
listblocked\n\ listblocked\n\
import-vcard <file>\n\
make-vcard <file> <contact-id> [contact-id ...]\n\
======================================Misc.==\n\ ======================================Misc.==\n\
getqr [<chat-id>]\n\ getqr [<chat-id>]\n\
getqrsvg [<chat-id>]\n\ getqrsvg [<chat-id>]\n\
@@ -404,6 +435,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!("{} is no setup message.", msg_id,);
}
}
"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" => {
has_backup(&context, blobdir).await?; has_backup(&context, blobdir).await?;
} }
@@ -431,7 +490,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"send-backup" => { "send-backup" => {
let provider = BackupProvider::prepare(&context).await?; let provider = BackupProvider::prepare(&context).await?;
let qr = format_backup(&provider.qr())?; let qr = format_backup(&provider.qr())?;
println!("QR code: {qr}"); println!("QR code: {}", qr);
qr2term::print_qr(qr.as_str())?; qr2term::print_qr(qr.as_str())?;
provider.await?; provider.await?;
} }
@@ -446,17 +505,13 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Exported to {}.", dir.to_string_lossy()); println!("Exported to {}.", dir.to_string_lossy());
} }
"import-keys" => { "import-keys" => {
ensure!(!arg1.is_empty(), "Argument <key-file> missing.");
imex(&context, ImexMode::ImportSelfKeys, arg1.as_ref(), None).await?; imex(&context, ImexMode::ImportSelfKeys, arg1.as_ref(), None).await?;
} }
"poke" => { "poke" => {
ensure!(poke_spec(&context, Some(arg1)).await, "Poke failed"); ensure!(poke_spec(&context, Some(arg1)).await, "Poke failed");
} }
"reset" => { "reset" => {
ensure!( ensure!(!arg1.is_empty(), "Argument <bits> missing: 1=jobs, 2=peerstates, 4=private keys, 8=rest but server config");
!arg1.is_empty(),
"Argument <bits> missing: 4=private keys, 8=rest but server config"
);
let bits: i32 = arg1.parse()?; let bits: i32 = arg1.parse()?;
ensure!(bits < 16, "<bits> must be lower than 16."); ensure!(bits < 16, "<bits> must be lower than 16.");
reset_tables(&context, bits).await; reset_tables(&context, bits).await;
@@ -489,7 +544,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Report written to: {file:#?}"); println!("Report written to: {file:#?}");
} }
Err(err) => { Err(err) => {
bail!("Failed to get connectivity html: {err}"); bail!("Failed to get connectivity html: {}", err);
} }
} }
} }
@@ -524,7 +579,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
for i in (0..cnt).rev() { for i in (0..cnt).rev() {
let chat = Chat::load_from_db(&context, chatlist.get_chat_id(i)?).await?; let chat = Chat::load_from_db(&context, chatlist.get_chat_id(i)?).await?;
println!( println!(
"{}#{}: {} [{} fresh] {}{}{}", "{}#{}: {} [{} fresh] {}{}{}{}",
chat_prefix(&chat), chat_prefix(&chat),
chat.get_id(), chat.get_id(),
chat.get_name(), chat.get_name(),
@@ -535,6 +590,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
ChatVisibility::Archived => "📦", ChatVisibility::Archived => "📦",
ChatVisibility::Pinned => "📌", ChatVisibility::Pinned => "📌",
}, },
if chat.is_protected() { "🛡️" } else { "" },
if chat.is_contact_request() { if chat.is_contact_request() {
"🆕" "🆕"
} else { } else {
@@ -573,11 +629,11 @@ 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!("Location streaming enabled.");
} }
println!("{cnt} chats"); println!("{cnt} chats");
eprintln!("{time_needed:?} to create this list"); println!("{time_needed:?} to create this list");
} }
"start-realtime" => { "start-realtime" => {
if arg1.is_empty() { if arg1.is_empty() {
@@ -622,6 +678,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
&context, &context,
sel_chat.get_id(), sel_chat.get_id(),
chat::MessageListOptions { chat::MessageListOptions {
info_only: false,
add_daymarker: true, add_daymarker: true,
}, },
) )
@@ -648,7 +705,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
format!("{} member(s)", members.len()) format!("{} member(s)", members.len())
}; };
println!( println!(
"{}#{}: {} [{}]{}{}{}", "{}#{}: {} [{}]{}{}{} {}",
chat_prefix(sel_chat), chat_prefix(sel_chat),
sel_chat.get_id(), sel_chat.get_id(),
sel_chat.get_name(), sel_chat.get_name(),
@@ -666,6 +723,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
}, },
_ => "".to_string(), _ => "".to_string(),
}, },
if sel_chat.is_protected() {
"🛡️"
} else {
""
},
); );
log_msglist(&context, &msglist).await?; log_msglist(&context, &msglist).await?;
if let Some(draft) = sel_chat.get_id().get_draft(&context).await? { if let Some(draft) = sel_chat.get_id().get_draft(&context).await? {
@@ -681,7 +743,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
chat::marknoticed_chat(&context, sel_chat.get_id()).await?; chat::marknoticed_chat(&context, sel_chat.get_id()).await?;
let time_noticed_needed = time_noticed_start.elapsed().unwrap_or_default(); let time_noticed_needed = time_noticed_start.elapsed().unwrap_or_default();
eprintln!( println!(
"{time_needed:?} to create this list, {time_noticed_needed:?} to mark all messages as noticed." "{time_needed:?} to create this list, {time_noticed_needed:?} to mark all messages as noticed."
); );
} }
@@ -694,16 +756,23 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
} }
"creategroup" => { "creategroup" => {
ensure!(!arg1.is_empty(), "Argument <name> missing."); ensure!(!arg1.is_empty(), "Argument <name> missing.");
let chat_id = chat::create_group(&context, arg1).await?; let chat_id =
chat::create_group_chat(&context, ProtectionStatus::Unprotected, arg1).await?;
println!("Group#{chat_id} created successfully."); println!("Group#{chat_id} created successfully.");
} }
"createbroadcast" => { "createbroadcast" => {
ensure!(!arg1.is_empty(), "Argument <name> missing."); let chat_id = chat::create_broadcast_list(&context).await?;
let chat_id = chat::create_broadcast(&context, arg1.to_string()).await?;
println!("Broadcast#{chat_id} created successfully."); println!("Broadcast#{chat_id} created successfully.");
} }
"createprotected" => {
ensure!(!arg1.is_empty(), "Argument <name> missing.");
let chat_id =
chat::create_group_chat(&context, ProtectionStatus::Protected, arg1).await?;
println!("Group#{chat_id} created and protected successfully.");
}
"addmember" => { "addmember" => {
ensure!(sel_chat.is_some(), "No chat selected"); ensure!(sel_chat.is_some(), "No chat selected");
ensure!(!arg1.is_empty(), "Argument <contact-id> missing."); ensure!(!arg1.is_empty(), "Argument <contact-id> missing.");
@@ -738,13 +807,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Chat name set"); 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" => { "groupimage" => {
ensure!(sel_chat.is_some(), "No chat selected."); ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "Argument <image> missing."); ensure!(!arg1.is_empty(), "Argument <image> missing.");
@@ -780,7 +842,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!( println!(
"Location streaming: {}", "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" => { "getlocations" => {
@@ -820,7 +886,12 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
ensure!(!arg1.is_empty(), "No timeout given."); ensure!(!arg1.is_empty(), "No timeout given.");
let seconds = arg1.parse()?; 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!( println!(
"Locations will be sent to Chat#{} for {} seconds. Use 'setlocation <lat> <lng>' to play around.", "Locations will be sent to Chat#{} for {} seconds. Use 'setlocation <lat> <lng>' to play around.",
sel_chat.as_ref().unwrap().get_id(), sel_chat.as_ref().unwrap().get_id(),
@@ -842,6 +913,9 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Success, streaming can be stopped."); println!("Success, streaming can be stopped.");
} }
} }
"dellocations" => {
location::delete_all(&context).await?;
}
"send" => { "send" => {
ensure!(sel_chat.is_some(), "No chat selected."); ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "No message text given."); ensure!(!arg1.is_empty(), "No message text given.");
@@ -850,23 +924,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
chat::send_text_msg(&context, sel_chat.as_ref().unwrap().get_id(), msg).await?; chat::send_text_msg(&context, sel_chat.as_ref().unwrap().get_id(), msg).await?;
} }
"send-sync" => {
ensure!(sel_chat.is_some(), "No chat selected.");
ensure!(!arg1.is_empty(), "No message text given.");
// Send message over a dedicated SMTP connection
// and measure time.
//
// This can be used to benchmark SMTP connection establishment.
let time_start = std::time::Instant::now();
let msg = format!("{arg1} {arg2}");
let mut msg = Message::new_text(msg);
chat::send_msg_sync(&context, sel_chat.as_ref().unwrap().get_id(), &mut msg).await?;
let time_needed = time_start.elapsed();
println!("Sent message in {time_needed:?}.");
}
"sendempty" => { "sendempty" => {
ensure!(sel_chat.is_some(), "No chat selected."); ensure!(sel_chat.is_some(), "No chat selected.");
chat::send_text_msg(&context, sel_chat.as_ref().unwrap().get_id(), "".into()).await?; chat::send_text_msg(&context, sel_chat.as_ref().unwrap().get_id(), "".into()).await?;
@@ -882,7 +939,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
} else { } else {
Viewtype::File Viewtype::File
}); });
msg.set_file_and_deduplicate(&context, Path::new(arg1), None, None)?; msg.set_file(arg1, None);
msg.set_text(arg2.to_string()); msg.set_text(arg2.to_string());
chat::send_msg(&context, sel_chat.as_ref().unwrap().get_id(), &mut msg).await?; chat::send_msg(&context, sel_chat.as_ref().unwrap().get_id(), &mut msg).await?;
} }
@@ -914,6 +971,10 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
let msg_id = MsgId::new(arg1.parse()?); let msg_id = MsgId::new(arg1.parse()?);
context.send_webxdc_status_update(msg_id, arg2).await?; context.send_webxdc_status_update(msg_id, arg2).await?;
} }
"videochat" => {
ensure!(sel_chat.is_some(), "No chat selected.");
chat::send_videochat_invitation(&context, sel_chat.as_ref().unwrap().get_id()).await?;
}
"listmsgs" => { "listmsgs" => {
ensure!(!arg1.is_empty(), "Argument <query> missing."); ensure!(!arg1.is_empty(), "Argument <query> missing.");
@@ -935,7 +996,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
}, },
query, query,
); );
eprintln!("{time_needed:?} to create this list"); println!("{time_needed:?} to create this list");
} }
"draft" => { "draft" => {
ensure!(sel_chat.is_some(), "No chat selected."); ensure!(sel_chat.is_some(), "No chat selected.");
@@ -1098,13 +1159,19 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
let reaction = arg2; let reaction = arg2;
send_reaction(&context, msg_id, reaction).await?; send_reaction(&context, msg_id, reaction).await?;
} }
"listcontacts" | "contacts" => { "listcontacts" | "contacts" | "listverified" => {
let contacts = Contact::get_all(&context, DC_GCL_ADD_SELF, Some(arg1)).await?; let contacts = Contact::get_all(
&context,
if arg0 == "listverified" {
DC_GCL_VERIFIED_ONLY | DC_GCL_ADD_SELF
} else {
DC_GCL_ADD_SELF
},
Some(arg1),
)
.await?;
log_contactlist(&context, &contacts).await?; log_contactlist(&context, &contacts).await?;
println!("{} key contacts.", contacts.len()); println!("{} contacts.", contacts.len());
let addrcontacts = Contact::get_all(&context, DC_GCL_ADDRESS, Some(arg1)).await?;
log_contactlist(&context, &addrcontacts).await?;
println!("{} address contacts.", addrcontacts.len());
} }
"addcontact" => { "addcontact" => {
ensure!(!arg1.is_empty(), "Arguments [<name>] <addr> expected."); ensure!(!arg1.is_empty(), "Arguments [<name>] <addr> expected.");
@@ -1168,24 +1235,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
log_contactlist(&context, &contacts).await?; log_contactlist(&context, &contacts).await?;
println!("{} blocked contacts.", contacts.len()); println!("{} blocked contacts.", contacts.len());
} }
"import-vcard" => {
ensure!(!arg1.is_empty(), "Argument <file> missing.");
let vcard_content = fs::read_to_string(&arg1.to_string()).await?;
let contacts = import_vcard(&context, &vcard_content).await?;
println!("vCard contacts imported:");
log_contactlist(&context, &contacts).await?;
}
"make-vcard" => {
ensure!(!arg1.is_empty(), "Argument <file> missing.");
ensure!(!arg2.is_empty(), "Argument <contact-id> missing.");
let mut contact_ids = vec![];
for x in arg2.split_whitespace() {
contact_ids.push(ContactId::new(x.parse()?))
}
let vcard_content = make_vcard(&context, &contact_ids).await?;
fs::write(&arg1.to_string(), vcard_content).await?;
println!("vCard written to: {arg1}");
}
"checkqr" => { "checkqr" => {
ensure!(!arg1.is_empty(), "Argument <qr-content> missing."); ensure!(!arg1.is_empty(), "Argument <qr-content> missing.");
let qr = check_qr(&context, arg1).await?; let qr = check_qr(&context, arg1).await?;
@@ -1194,8 +1243,8 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"setqr" => { "setqr" => {
ensure!(!arg1.is_empty(), "Argument <qr-content> missing."); ensure!(!arg1.is_empty(), "Argument <qr-content> missing.");
match set_config_from_qr(&context, arg1).await { match set_config_from_qr(&context, arg1).await {
Ok(()) => eprintln!("Config set from the QR code."), Ok(()) => println!("Config set from QR code, you can now call 'configure'"),
Err(err) => eprintln!("Cannot set config from QR code: {err:?}"), Err(err) => println!("Cannot set config from QR code: {err:?}"),
} }
} }
"createqrsvg" => { "createqrsvg" => {
@@ -1207,7 +1256,10 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
} }
"providerinfo" => { "providerinfo" => {
ensure!(!arg1.is_empty(), "Argument <addr> missing."); ensure!(!arg1.is_empty(), "Argument <addr> missing.");
match provider::get_provider_info(arg1) { let proxy_enabled = context
.get_config_bool(config::Config::ProxyEnabled)
.await?;
match provider::get_provider_info(&context, arg1, proxy_enabled).await {
Some(info) => { Some(info) => {
println!("Information for provider belonging to {arg1}:"); println!("Information for provider belonging to {arg1}:");
println!("status: {}", info.status as u32); println!("status: {}", info.status as u32);
@@ -1226,7 +1278,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"fileinfo" => { "fileinfo" => {
ensure!(!arg1.is_empty(), "Argument <file> missing."); ensure!(!arg1.is_empty(), "Argument <file> missing.");
if let Ok(buf) = read_file(&context, Path::new(arg1)).await { if let Ok(buf) = read_file(&context, &arg1).await {
let (width, height) = get_filemeta(&buf)?; let (width, height) = get_filemeta(&buf)?;
println!("width={width}, height={height}"); println!("width={width}, height={height}");
} else { } else {
@@ -1243,7 +1295,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
); );
} }
"" => (), "" => (),
_ => bail!("Unknown command: \"{arg0}\" type ? for help."), _ => bail!("Unknown command: \"{}\" type ? for help.", arg0),
} }
Ok(()) Ok(())

View File

@@ -5,6 +5,7 @@
//! Usage: cargo run --example repl --release -- <databasefile> //! Usage: cargo run --example repl --release -- <databasefile>
//! All further options can be set using the set-command (type ? for help). //! All further options can be set using the set-command (type ? for help).
#[macro_use]
extern crate deltachat; extern crate deltachat;
use std::borrow::Cow::{self, Borrowed, Owned}; use std::borrow::Cow::{self, Borrowed, Owned};
@@ -40,25 +41,25 @@ fn receive_event(event: EventType) {
match event { match event {
EventType::Info(msg) => { EventType::Info(msg) => {
/* do not show the event as this would fill the screen */ /* do not show the event as this would fill the screen */
info!("{msg}"); info!("{}", msg);
} }
EventType::SmtpConnected(msg) => { EventType::SmtpConnected(msg) => {
info!("[SMTP_CONNECTED] {msg}"); info!("[SMTP_CONNECTED] {}", msg);
} }
EventType::ImapConnected(msg) => { EventType::ImapConnected(msg) => {
info!("[IMAP_CONNECTED] {msg}"); info!("[IMAP_CONNECTED] {}", msg);
} }
EventType::SmtpMessageSent(msg) => { EventType::SmtpMessageSent(msg) => {
info!("[SMTP_MESSAGE_SENT] {msg}"); info!("[SMTP_MESSAGE_SENT] {}", msg);
} }
EventType::Warning(msg) => { EventType::Warning(msg) => {
warn!("{msg}"); warn!("{}", msg);
} }
EventType::Error(msg) => { EventType::Error(msg) => {
error!("{msg}"); error!("{}", msg);
} }
EventType::ErrorSelfNotInGroup(msg) => { EventType::ErrorSelfNotInGroup(msg) => {
error!("[SELF_NOT_IN_GROUP] {msg}"); error!("[SELF_NOT_IN_GROUP] {}", msg);
} }
EventType::MsgsChanged { chat_id, msg_id } => { EventType::MsgsChanged { chat_id, msg_id } => {
info!( info!(
@@ -123,7 +124,7 @@ fn receive_event(event: EventType) {
); );
} }
_ => { _ => {
info!("Received {event:?}"); info!("Received {:?}", event);
} }
} }
} }
@@ -149,7 +150,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", "has-backup",
"export-backup", "export-backup",
"import-backup", "import-backup",
@@ -176,11 +180,9 @@ const DB_COMMANDS: [&str; 11] = [
"housekeeping", "housekeeping",
]; ];
const CHAT_COMMANDS: [&str; 39] = [ const CHAT_COMMANDS: [&str; 36] = [
"listchats", "listchats",
"listarchived", "listarchived",
"start-realtime",
"send-realtime",
"chat", "chat",
"createchat", "createchat",
"creategroup", "creategroup",
@@ -189,23 +191,20 @@ const CHAT_COMMANDS: [&str; 39] = [
"addmember", "addmember",
"removemember", "removemember",
"groupname", "groupname",
"groupdescription",
"groupimage", "groupimage",
"chatinfo", "chatinfo",
"sendlocations", "sendlocations",
"setlocation", "setlocation",
"dellocations",
"getlocations", "getlocations",
"send", "send",
"send-sync",
"sendempty",
"sendimage", "sendimage",
"sendsticker",
"sendfile", "sendfile",
"sendhtml", "sendhtml",
"sendsyncmsg", "sendsyncmsg",
"sendupdate", "sendupdate",
"videochat",
"draft", "draft",
"devicemsg",
"listmedia", "listmedia",
"archive", "archive",
"unarchive", "unarchive",
@@ -213,48 +212,47 @@ const CHAT_COMMANDS: [&str; 39] = [
"unpin", "unpin",
"mute", "mute",
"unmute", "unmute",
"protect",
"unprotect",
"delchat", "delchat",
"accept", "accept",
"blockchat", "blockchat",
]; ];
const MESSAGE_COMMANDS: [&str; 10] = [ const MESSAGE_COMMANDS: [&str; 9] = [
"listmsgs", "listmsgs",
"msginfo", "msginfo",
"download",
"html",
"listfresh", "listfresh",
"forward", "forward",
"resend", "resend",
"markseen", "markseen",
"delmsg", "delmsg",
"download",
"react", "react",
]; ];
const CONTACT_COMMANDS: [&str; 9] = [ const CONTACT_COMMANDS: [&str; 9] = [
"listcontacts", "listcontacts",
"listverified",
"addcontact", "addcontact",
"contactinfo", "contactinfo",
"delcontact", "delcontact",
"cleanupcontacts",
"block", "block",
"unblock", "unblock",
"listblocked", "listblocked",
"import-vcard",
"make-vcard",
]; ];
const MISC_COMMANDS: [&str; 14] = [ const MISC_COMMANDS: [&str; 12] = [
"getqr", "getqr",
"getqrsvg", "getqrsvg",
"getbadqr", "getbadqr",
"checkqr", "checkqr",
"joinqr", "joinqr",
"setqr",
"createqrsvg", "createqrsvg",
"providerinfo",
"fileinfo", "fileinfo",
"estimatedeletion",
"clear", "clear",
"exit", "exit",
"quit", "quit",
"help", "help",
"estimatedeletion",
]; ];
impl Hinter for DcHelper { impl Hinter for DcHelper {
@@ -310,7 +308,7 @@ impl Validator for DcHelper {}
async fn start(args: Vec<String>) -> Result<(), Error> { async fn start(args: Vec<String>) -> Result<(), Error> {
if args.len() < 2 { if args.len() < 2 {
eprintln!("Error: Bad arguments, expected [db-name]."); println!("Error: Bad arguments, expected [db-name].");
bail!("No db-name specified"); bail!("No db-name specified");
} }
let context = ContextBuilder::new(args[1].clone().into()) let context = ContextBuilder::new(args[1].clone().into())
@@ -325,7 +323,7 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
} }
}); });
println!("Chatmail is awaiting your commands."); println!("Delta Chat Core is awaiting your commands.");
let config = Config::builder() let config = Config::builder()
.history_ignore_space(true) .history_ignore_space(true)
@@ -365,7 +363,7 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
false false
} }
Err(err) => { Err(err) => {
eprintln!("Error: {err:#}"); println!("Error: {err:#}");
true true
} }
} }
@@ -380,7 +378,7 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
break; break;
} }
Err(err) => { Err(err) => {
eprintln!("Error: {err:#}"); println!("Error: {err:#}");
break; break;
} }
} }
@@ -427,12 +425,12 @@ async fn handle_cmd(
} }
"oauth2" => { "oauth2" => {
if let Some(addr) = ctx.get_config(config::Config::Addr).await? { if let Some(addr) = ctx.get_config(config::Config::Addr).await? {
if let Some(oauth2_url) = let oauth2_url =
get_oauth2_url(&ctx, &addr, "chat.delta:/com.b44t.messenger").await? get_oauth2_url(&ctx, &addr, "chat.delta:/com.b44t.messenger").await?;
{ if oauth2_url.is_none() {
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); println!("OAuth2 not available for {}.", &addr);
} else {
println!("Open the following url, set mail_pw to the generated token and server_flags to 2:\n{}", oauth2_url.unwrap());
} }
} else { } else {
println!("oauth2: set addr first."); println!("oauth2: set addr first.");
@@ -464,7 +462,7 @@ async fn handle_cmd(
println!("QR code svg written to: {file:#?}"); println!("QR code svg written to: {file:#?}");
} }
Err(err) => { Err(err) => {
bail!("Failed to get QR code svg: {err}"); bail!("Failed to get QR code svg: {}", err);
} }
} }
} }

View File

@@ -2,9 +2,6 @@
RPC client connects to standalone Delta Chat RPC server `deltachat-rpc-server` RPC client connects to standalone Delta Chat RPC server `deltachat-rpc-server`
and provides asynchronous interface to it. 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 ## Getting started
@@ -33,15 +30,6 @@ $ pip install .
Additional arguments to `tox` are passed to pytest, e.g. `tox -- -s` does not capture test output. Additional arguments to `tox` are passed to pytest, e.g. `tox -- -s` does not capture test output.
## Activating current checkout of deltachat-rpc-client and -server for development
Go to root repository directory and run:
```
$ scripts/make-rpc-testenv.sh
$ source venv/bin/activate
```
## Using in REPL ## Using in REPL
Setup a development environment: Setup a development environment:

View File

@@ -13,7 +13,7 @@ def main():
with Rpc() as rpc: with Rpc() as rpc:
deltachat = DeltaChat(rpc) deltachat = DeltaChat(rpc)
system_info = deltachat.get_system_info() 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() accounts = deltachat.get_all_accounts()
account = accounts[0] if accounts else deltachat.add_account() account = accounts[0] if accounts else deltachat.add_account()
@@ -21,30 +21,36 @@ def main():
account.set_config("bot", "1") account.set_config("bot", "1")
if not account.is_configured(): if not account.is_configured():
logging.info("Account is not configured, configuring") 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") logging.info("Configured")
else: else:
logging.info("Account is already configured") logging.info("Account is already configured")
deltachat.start_io() deltachat.start_io()
qr = account.get_qr_code() def process_messages():
logging.info(f"Invite link: {qr}") for message in account.get_next_messages():
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)
snapshot = message.get_snapshot() snapshot = message.get_snapshot()
if snapshot.from_id != SpecialContactId.SELF and not snapshot.is_bot and not snapshot.is_info: if snapshot.from_id != SpecialContactId.SELF and not snapshot.is_bot and not snapshot.is_info:
snapshot.chat.send_text(snapshot.text) snapshot.chat.send_text(snapshot.text)
snapshot.message.mark_seen() 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__": if __name__ == "__main__":
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)

View File

@@ -1,28 +1,29 @@
[build-system] [build-system]
requires = ["setuptools>=77"] requires = ["setuptools>=45"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "deltachat-rpc-client" name = "deltachat-rpc-client"
version = "2.50.0-dev" version = "1.152.0"
license = "MPL-2.0"
description = "Python client for Delta Chat core JSON-RPC interface" description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
"Operating System :: POSIX :: Linux", "Operating System :: POSIX :: Linux",
"Operating System :: MacOS :: MacOS X", "Operating System :: MacOS :: MacOS X",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Communications :: Chat", "Topic :: Communications :: Chat",
"Topic :: Communications :: Email" "Topic :: Communications :: Email"
] ]
readme = "README.md" readme = "README.md"
requires-python = ">=3.10"
[tool.setuptools.package-data] [tool.setuptools.package-data]
deltachat_rpc_client = [ deltachat_rpc_client = [
@@ -65,18 +66,7 @@ lint.select = [
"RUF006" # asyncio-dangling-task "RUF006" # asyncio-dangling-task
] ]
lint.ignore = [
"PLC0415" # `import` should be at the top-level of a file
]
line-length = 120 line-length = 120
[tool.isort] [tool.isort]
profile = "black" profile = "black"
[dependency-groups]
dev = [
"imap-tools",
"pytest",
"pytest-timeout",
"pytest-xdist",
]

View File

@@ -1,4 +1,4 @@
"""Delta Chat JSON-RPC high-level API.""" """Delta Chat JSON-RPC high-level API"""
from ._utils import AttrDict, run_bot_cli, run_client_cli from ._utils import AttrDict, run_bot_cli, run_client_cli
from .account import Account from .account import Account
@@ -8,7 +8,7 @@ from .const import EventType, SpecialContactId
from .contact import Contact from .contact import Contact
from .deltachat import DeltaChat from .deltachat import DeltaChat
from .message import Message from .message import Message
from .rpc import JsonRpcError, Rpc from .rpc import Rpc
__all__ = [ __all__ = [
"Account", "Account",
@@ -19,7 +19,6 @@ __all__ = [
"Contact", "Contact",
"DeltaChat", "DeltaChat",
"EventType", "EventType",
"JsonRpcError",
"Message", "Message",
"SpecialContactId", "SpecialContactId",
"Rpc", "Rpc",

View File

@@ -1,6 +1,4 @@
import argparse import argparse
import functools
import os
import re import re
import sys import sys
from threading import Thread from threading import Thread
@@ -45,13 +43,8 @@ class AttrDict(dict):
super().__setattr__(attr, val) super().__setattr__(attr, val)
def _forever(_event: AttrDict) -> bool:
return False
def run_client_cli( def run_client_cli(
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None, hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
until: Callable[[AttrDict], bool] = _forever,
argv: Optional[list] = None, argv: Optional[list] = None,
**kwargs, **kwargs,
) -> None: ) -> None:
@@ -61,11 +54,10 @@ def run_client_cli(
""" """
from .client import Client from .client import Client
_run_cli(Client, until, hooks, argv, **kwargs) _run_cli(Client, hooks, argv, **kwargs)
def run_bot_cli( def run_bot_cli(
until: Callable[[AttrDict], bool] = _forever,
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None, hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
argv: Optional[list] = None, argv: Optional[list] = None,
**kwargs, **kwargs,
@@ -76,12 +68,11 @@ def run_bot_cli(
""" """
from .client import Bot from .client import Bot
_run_cli(Bot, until, hooks, argv, **kwargs) _run_cli(Bot, hooks, argv, **kwargs)
def _run_cli( def _run_cli(
client_type: Type["Client"], client_type: Type["Client"],
until: Callable[[AttrDict], bool] = _forever,
hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None, hooks: Optional[Iterable[Tuple[Callable, Union[type, "EventFilter"]]]] = None,
argv: Optional[list] = None, argv: Optional[list] = None,
**kwargs, **kwargs,
@@ -98,8 +89,8 @@ def _run_cli(
help="accounts folder (default: current working directory)", help="accounts folder (default: current working directory)",
nargs="?", nargs="?",
) )
parser.add_argument("--email", action="store", help="email address", default=os.getenv("DELTACHAT_EMAIL")) parser.add_argument("--email", action="store", help="email address")
parser.add_argument("--password", action="store", help="password", default=os.getenv("DELTACHAT_PASSWORD")) parser.add_argument("--password", action="store", help="password")
args = parser.parse_args(argv[1:]) args = parser.parse_args(argv[1:])
with Rpc(accounts_dir=args.accounts_dir, **kwargs) as rpc: with Rpc(accounts_dir=args.accounts_dir, **kwargs) as rpc:
@@ -119,11 +110,11 @@ def _run_cli(
kwargs={"email": args.email, "password": args.password}, kwargs={"email": args.email, "password": args.password},
) )
configure_thread.start() configure_thread.start()
client.run_until(until) client.run_forever()
def extract_addr(text: str) -> str: def extract_addr(text: str) -> str:
"""Extract email address from the given text.""" """extract email address from the given text."""
match = re.match(r".*\((.+@.+)\)", text) match = re.match(r".*\((.+@.+)\)", text)
if match: if match:
text = match.group(1) text = match.group(1)
@@ -132,7 +123,7 @@ def extract_addr(text: str) -> str:
def parse_system_image_changed(text: str) -> Optional[Tuple[str, bool]]: def parse_system_image_changed(text: str) -> Optional[Tuple[str, bool]]:
"""Return image changed/deleted info from parsing the given system message text.""" """return image changed/deleted info from parsing the given system message text."""
text = text.lower() text = text.lower()
match = re.match(r"group image (changed|deleted) by (.+).", text) match = re.match(r"group image (changed|deleted) by (.+).", text)
if match: if match:
@@ -151,7 +142,7 @@ def parse_system_title_changed(text: str) -> Optional[Tuple[str, str]]:
def parse_system_add_remove(text: str) -> Optional[Tuple[str, str, str]]: def parse_system_add_remove(text: str) -> Optional[Tuple[str, str, str]]:
"""Return add/remove info from parsing the given system message text. """return add/remove info from parsing the given system message text.
returns a (action, affected, actor) tuple. returns a (action, affected, actor) tuple.
""" """
@@ -190,6 +181,9 @@ class futuremethod: # noqa: N801
self._func = func self._func = func
def __get__(self, instance, owner=None): def __get__(self, instance, owner=None):
if instance is None:
return self
def future(*args): def future(*args):
generator = self._func(instance, *args) generator = self._func(instance, *args)
res = next(generator) res = next(generator)
@@ -202,7 +196,6 @@ class futuremethod: # noqa: N801
return f return f
@functools.wraps(self._func)
def wrapper(*args): def wrapper(*args):
f = future(*args) f = future(*args)
return f() return f()

View File

@@ -1,10 +1,8 @@
"""Account module."""
from __future__ import annotations from __future__ import annotations
import json
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, Union from typing import TYPE_CHECKING, Optional, Union
from warnings import warn
from ._utils import AttrDict, futuremethod from ._utils import AttrDict, futuremethod
from .chat import Chat from .chat import Chat
@@ -28,36 +26,18 @@ class Account:
def _rpc(self) -> "Rpc": def _rpc(self) -> "Rpc":
return self.manager.rpc return self.manager.rpc
def wait_for_event(self, event_type=None) -> AttrDict: def wait_for_event(self) -> AttrDict:
"""Wait until the next event and return it.""" """Wait until the next event and return it."""
while True: return AttrDict(self._rpc.wait_for_event(self.id))
next_event = AttrDict(self._rpc.wait_for_event(self.id))
if event_type is None or next_event.kind == event_type:
return next_event
def clear_all_events(self): def clear_all_events(self):
"""Remove all queued-up events for a given account. """Removes all queued-up events for a given account. Useful for tests."""
Useful for tests.
"""
self._rpc.clear_all_events(self.id) self._rpc.clear_all_events(self.id)
def remove(self) -> None: def remove(self) -> None:
"""Remove the account.""" """Remove the account."""
self._rpc.remove_account(self.id) self._rpc.remove_account(self.id)
def clone(self) -> "Account":
"""Clone given account.
This uses backup-transfer via iroh, i.e. the 'Add second device' feature.
"""
future = self._rpc.provide_backup.future(self.id)
qr = self._rpc.get_backup_qr(self.id)
new_account = self.manager.add_account()
new_account._rpc.get_backup(new_account.id, qr)
future()
return new_account
def start_io(self) -> None: def start_io(self) -> None:
"""Start the account I/O.""" """Start the account I/O."""
self._rpc.start_io(self.id) self._rpc.start_io(self.id)
@@ -87,7 +67,7 @@ class Account:
return self._rpc.get_config(self.id, key) return self._rpc.get_config(self.id, key)
def update_config(self, **kwargs) -> None: def update_config(self, **kwargs) -> None:
"""Update config values.""" """update config values."""
for key, value in kwargs.items(): for key, value in kwargs.items():
self.set_config(key, value) self.set_config(key, value)
@@ -103,15 +83,9 @@ class Account:
return self.get_config("selfavatar") return self.get_config("selfavatar")
def check_qr(self, qr): def check_qr(self, qr):
"""Parse QR code contents.
This function takes the raw text scanned
and checks what can be done with it.
"""
return self._rpc.check_qr(self.id, qr) return self._rpc.check_qr(self.id, qr)
def set_config_from_qr(self, qr: str): def set_config_from_qr(self, qr: str):
"""Set configuration values from a QR code."""
self._rpc.set_config_from_qr(self.id, qr) self._rpc.set_config_from_qr(self.id, qr)
@futuremethod @futuremethod
@@ -119,32 +93,15 @@ class Account:
"""Configure an account.""" """Configure an account."""
yield self._rpc.configure.future(self.id) yield self._rpc.configure.future(self.id)
@futuremethod
def add_or_update_transport(self, params):
"""Add a new transport."""
yield self._rpc.add_or_update_transport.future(self.id, params)
@futuremethod
def add_transport_from_qr(self, qr: str):
"""Add a new transport using a QR code."""
yield self._rpc.add_transport_from_qr.future(self.id, qr)
def delete_transport(self, addr: str):
"""Delete a transport."""
self._rpc.delete_transport(self.id, addr)
@futuremethod
def list_transports(self):
"""Return the list of all email accounts that are used as a transport in the current profile."""
transports = yield self._rpc.list_transports.future(self.id)
return transports
def bring_online(self): def bring_online(self):
"""Start I/O and wait until IMAP becomes IDLE.""" """Start I/O and wait until IMAP becomes IDLE."""
self.start_io() self.start_io()
self.wait_for_event(EventType.IMAP_INBOX_IDLE) while True:
event = self.wait_for_event()
if event.kind == EventType.IMAP_INBOX_IDLE:
break
def create_contact(self, obj: Union[int, str, Contact, "Account"], name: Optional[str] = None) -> Contact: def create_contact(self, obj: Union[int, str, Contact], name: Optional[str] = None) -> Contact:
"""Create a new Contact or return an existing one. """Create a new Contact or return an existing one.
Calling this method will always result in the same Calling this method will always result in the same
@@ -152,63 +109,26 @@ class Account:
with that e-mail address, it is unblocked and its display with that e-mail address, it is unblocked and its display
name is updated if specified. name is updated if specified.
:param obj: email-address, contact id or account. :param obj: email-address or contact id.
:param name: (optional) display name for this contact. :param name: (optional) display name for this contact.
""" """
if isinstance(obj, Account):
vcard = obj.self_contact.make_vcard()
[contact] = self.import_vcard(vcard)
if name:
contact.set_name(name)
return contact
if isinstance(obj, int): if isinstance(obj, int):
obj = Contact(self, obj) obj = Contact(self, obj)
if isinstance(obj, Contact): if isinstance(obj, Contact):
obj = obj.get_snapshot().address obj = obj.get_snapshot().address
return Contact(self, self._rpc.create_contact(self.id, obj, name)) return Contact(self, self._rpc.create_contact(self.id, obj, name))
def make_vcard(self, contacts: list[Contact]) -> str:
"""Create vCard with the given contacts."""
assert all(contact.account == self for contact in contacts)
contact_ids = [contact.id for contact in contacts]
return self._rpc.make_vcard(self.id, contact_ids)
def import_vcard(self, vcard: str) -> list[Contact]:
"""Import vCard.
Return created or modified contacts in the order they appear in vCard.
"""
contact_ids = self._rpc.import_vcard_contents(self.id, vcard)
return [Contact(self, contact_id) for contact_id in contact_ids]
def create_chat(self, account: "Account") -> Chat: def create_chat(self, account: "Account") -> Chat:
"""Create a 1:1 chat with another account.""" addr = account.get_config("addr")
return self.create_contact(account).create_chat() contact = self.create_contact(addr)
return contact.create_chat()
def get_device_chat(self) -> Chat:
"""Return device chat."""
return self.device_contact.create_chat()
def get_contact_by_id(self, contact_id: int) -> Contact: def get_contact_by_id(self, contact_id: int) -> Contact:
"""Return Contact instance for the given contact ID.""" """Return Contact instance for the given contact ID."""
return Contact(self, contact_id) return Contact(self, contact_id)
def get_contact_by_addr(self, address: str) -> Optional[Contact]: def get_contact_by_addr(self, address: str) -> Optional[Contact]:
"""Looks up a known and unblocked contact with a given e-mail address. """Check if an e-mail address belongs to a known and unblocked contact."""
To get a list of all known and unblocked contacts, use contacts_get_contacts().
**POTENTIAL SECURITY ISSUE**: If there are multiple contacts with this address
(e.g. an address-contact and a key-contact),
this looks up the most recently seen contact,
i.e. which contact is returned depends on which contact last sent a message.
If the user just clicked on a mailto: link, then this is the best thing you can do.
But **DO NOT** internally represent contacts by their email address
and do not use this function to look them up;
otherwise this function will sometimes look up the wrong contact.
Instead, you should internally represent contacts by their ids.
To validate an e-mail address independently of the contact database
use check_email_validity()."""
contact_id = self._rpc.lookup_contact_id_by_addr(self.id, address) contact_id = self._rpc.lookup_contact_id_by_addr(self.id, address)
return contact_id and Contact(self, contact_id) return contact_id and Contact(self, contact_id)
@@ -234,8 +154,8 @@ class Account:
def get_contacts( def get_contacts(
self, self,
query: Optional[str] = None, query: Optional[str] = None,
*,
with_self: bool = False, with_self: bool = False,
verified_only: bool = False,
snapshot: bool = False, snapshot: bool = False,
) -> Union[list[Contact], list[AttrDict]]: ) -> Union[list[Contact], list[AttrDict]]:
"""Get a filtered list of contacts. """Get a filtered list of contacts.
@@ -243,9 +163,12 @@ class Account:
:param query: if a string is specified, only return contacts :param query: if a string is specified, only return contacts
whose name or e-mail matches query. whose name or e-mail matches query.
:param with_self: if True the self-contact is also included if it matches the query. :param with_self: if True the self-contact is also included if it matches the query.
:param only_verified: if True only return verified contacts.
:param snapshot: If True return a list of contact snapshots instead of Contact instances. :param snapshot: If True return a list of contact snapshots instead of Contact instances.
""" """
flags = 0 flags = 0
if verified_only:
flags |= ContactFlag.VERIFIED_ONLY
if with_self: if with_self:
flags |= ContactFlag.ADD_SELF flags |= ContactFlag.ADD_SELF
@@ -257,14 +180,9 @@ class Account:
@property @property
def self_contact(self) -> Contact: def self_contact(self) -> Contact:
"""Account's identity as a Contact.""" """This account's identity as a Contact."""
return Contact(self, SpecialContactId.SELF) return Contact(self, SpecialContactId.SELF)
@property
def device_contact(self) -> Chat:
"""Account's device contact."""
return Contact(self, SpecialContactId.DEVICE)
def get_chatlist( def get_chatlist(
self, self,
query: Optional[str] = None, query: Optional[str] = None,
@@ -308,51 +226,20 @@ class Account:
chats.append(AttrDict(item)) chats.append(AttrDict(item))
return chats return chats
def create_group(self, name: str) -> Chat: def create_group(self, name: str, protect: bool = False) -> Chat:
"""Create a new group chat. """Create a new group chat.
After creation, After creation, the group has only self-contact as member and is in unpromoted state.
the group has only self-contact as member one member (see `SpecialContactId.SELF`)
and is in _unpromoted_ state.
This means, you can add or remove members, change the name,
the group image and so on without messages being sent to all group members.
This changes as soon as the first message is sent to the group members
and the group becomes _promoted_.
After that, all changes are synced with all group members
by sending status message.
To check, if a chat is still unpromoted, you can look at the `is_unpromoted` property of a chat
(see `get_full_snapshot()` / `get_basic_snapshot()`).
This may be useful if you want to show some help for just created groups.
""" """
return Chat(self, self._rpc.create_group_chat(self.id, name, False)) return Chat(self, self._rpc.create_group_chat(self.id, name, protect))
def create_broadcast(self, name: str) -> Chat:
"""Create a new, outgoing **broadcast channel**
(called "Channel" in the UI).
Broadcast channels are similar to groups on the sending device,
however, recipients get the messages in a read-only chat
and will not see who the other members are.
Called `broadcast` here rather than `channel`,
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))
def get_chat_by_id(self, chat_id: int) -> Chat: def get_chat_by_id(self, chat_id: int) -> Chat:
"""Return the Chat instance with the given ID.""" """Return the Chat instance with the given ID."""
return Chat(self, chat_id) return Chat(self, chat_id)
def secure_join(self, qrdata: str) -> Chat: def secure_join(self, qrdata: str) -> Chat:
"""Continue a Setup-Contact or Verified-Group-Invite protocol started on another device. """Continue a Setup-Contact or Verified-Group-Invite protocol started on
another device.
The function returns immediately and the handshake runs in background, sending The function returns immediately and the handshake runs in background, sending
and receiving several messages. and receiving several messages.
@@ -391,7 +278,8 @@ class Account:
"""Return the list of fresh messages, newest messages first. """Return the list of fresh messages, newest messages first.
This call is intended for displaying notifications. 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) fresh_msg_ids = self._rpc.get_fresh_msgs(self.id)
return [Message(self, msg_id) for msg_id in fresh_msg_ids] return [Message(self, msg_id) for msg_id in fresh_msg_ids]
@@ -401,65 +289,51 @@ class Account:
next_msg_ids = self._rpc.get_next_msgs(self.id) next_msg_ids = self._rpc.get_next_msgs(self.id)
return [Message(self, msg_id) for msg_id in next_msg_ids] return [Message(self, msg_id) for msg_id in next_msg_ids]
@futuremethod
def wait_next_messages(self) -> list[Message]: def wait_next_messages(self) -> list[Message]:
"""(deprecated) Wait for new messages and return a list of them. Meant for bots. """Wait for new messages and return a list of them."""
next_msg_ids = self._rpc.wait_next_msgs(self.id)
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.
"""
next_msg_ids = yield self._rpc.wait_next_msgs.future(self.id)
return [Message(self, msg_id) for msg_id in next_msg_ids] return [Message(self, msg_id) for msg_id in next_msg_ids]
def wait_for_incoming_msg_event(self): def wait_for_incoming_msg_event(self):
"""Wait for incoming message event and return it.""" """Wait for incoming message event and return it."""
return self.wait_for_event(EventType.INCOMING_MSG) while True:
event = self.wait_for_event()
def wait_for_msgs_changed_event(self): if event.kind == EventType.INCOMING_MSG:
"""Wait for messages changed event and return it.""" return event
return self.wait_for_event(EventType.MSGS_CHANGED)
def wait_for_msgs_noticed_event(self):
"""Wait for messages noticed event and return it."""
return self.wait_for_event(EventType.MSGS_NOTICED)
def wait_for_msg(self, event_type) -> Message:
"""Wait for an event about the message.
Consumes all events before the matching event.
Returns a message corresponding to the msg_id field of the event.
"""
event = self.wait_for_event(event_type)
return self.get_message_by_id(event.msg_id)
def wait_for_incoming_msg(self): def wait_for_incoming_msg(self):
"""Wait for incoming message and return it. """Wait for incoming message and return it.
Consumes all events before the next incoming message event. Consumes all events before the next incoming message event."""
""" return self.get_message_by_id(self.wait_for_incoming_msg_event().msg_id)
return self.wait_for_msg(EventType.INCOMING_MSG)
def wait_for_securejoin_inviter_success(self): def wait_for_securejoin_inviter_success(self):
"""Wait until SecureJoin process finishes successfully on the inviter side."""
while True: while True:
event = self.wait_for_event() event = self.wait_for_event()
if event["kind"] == "SecurejoinInviterProgress" and event["progress"] == 1000: if event["kind"] == "SecurejoinInviterProgress" and event["progress"] == 1000:
break break
def wait_for_securejoin_joiner_success(self): def wait_for_securejoin_joiner_success(self):
"""Wait until SecureJoin process finishes successfully on the joiner side."""
while True: while True:
event = self.wait_for_event() event = self.wait_for_event()
if event["kind"] == "SecurejoinJoinerProgress" and event["progress"] == 1000: if event["kind"] == "SecurejoinJoinerProgress" and event["progress"] == 1000:
break break
def wait_for_reactions_changed(self): def wait_for_reactions_changed(self):
"""Wait for reaction change event.""" while True:
return self.wait_for_event(EventType.REACTIONS_CHANGED) event = self.wait_for_event()
if event.kind == EventType.REACTIONS_CHANGED:
return event
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: def export_backup(self, path, passphrase: str = "") -> None:
"""Export backup.""" """Export backup."""
@@ -478,12 +352,3 @@ class Account:
"""Import keys.""" """Import keys."""
passphrase = "" # Importing passphrase-protected keys is currently not supported. passphrase = "" # Importing passphrase-protected keys is currently not supported.
self._rpc.import_self_keys(self.id, str(path), passphrase) self._rpc.import_self_keys(self.id, str(path), passphrase)
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

@@ -1,5 +1,3 @@
"""Chat module."""
from __future__ import annotations from __future__ import annotations
import calendar import calendar
@@ -91,8 +89,7 @@ class Chat:
def set_ephemeral_timer(self, timer: int) -> None: def set_ephemeral_timer(self, timer: int) -> None:
"""Set ephemeral timer of this chat in seconds. """Set ephemeral timer of this chat in seconds.
0 means the timer is disabled, use 1 for immediate deletion. 0 means the timer is disabled, use 1 for immediate deletion."""
"""
self._rpc.set_chat_ephemeral_timer(self.account.id, self.id, timer) self._rpc.set_chat_ephemeral_timer(self.account.id, self.id, timer)
def get_encryption_info(self) -> str: def get_encryption_info(self) -> str:
@@ -127,7 +124,6 @@ class Chat:
html: Optional[str] = None, html: Optional[str] = None,
viewtype: Optional[ViewType] = None, viewtype: Optional[ViewType] = None,
file: Optional[str] = None, file: Optional[str] = None,
filename: Optional[str] = None,
location: Optional[tuple[float, float]] = None, location: Optional[tuple[float, float]] = None,
override_sender_name: Optional[str] = None, override_sender_name: Optional[str] = None,
quoted_msg: Optional[Union[int, Message]] = None, quoted_msg: Optional[Union[int, Message]] = None,
@@ -141,7 +137,6 @@ class Chat:
"html": html, "html": html,
"viewtype": viewtype, "viewtype": viewtype,
"file": file, "file": file,
"filename": filename,
"location": location, "location": location,
"overrideSenderName": override_sender_name, "overrideSenderName": override_sender_name,
"quotedMessageId": quoted_msg, "quotedMessageId": quoted_msg,
@@ -164,15 +159,10 @@ class Chat:
return Message(self.account, msg_id) return Message(self.account, msg_id)
def send_sticker(self, path: str) -> Message: 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) msg_id = self._rpc.send_sticker(self.account.id, self.id, path)
return Message(self.account, msg_id) return Message(self.account, msg_id)
def resend_messages(self, messages: list[Message]) -> None:
"""Resend a list of messages to this chat."""
msg_ids = [msg.id for msg in messages]
self._rpc.resend_messages(self.account.id, msg_ids)
def forward_messages(self, messages: list[Message]) -> None: def forward_messages(self, messages: list[Message]) -> None:
"""Forward a list of messages to this chat.""" """Forward a list of messages to this chat."""
msg_ids = [msg.id for msg in messages] msg_ids = [msg.id for msg in messages]
@@ -182,14 +172,13 @@ class Chat:
self, self,
text: Optional[str] = None, text: Optional[str] = None,
file: Optional[str] = None, file: Optional[str] = None,
filename: Optional[str] = None,
quoted_msg: Optional[int] = None, quoted_msg: Optional[int] = None,
viewtype: Optional[str] = None, viewtype: Optional[str] = None,
) -> None: ) -> None:
"""Set draft message.""" """Set draft message."""
if isinstance(quoted_msg, Message): if isinstance(quoted_msg, Message):
quoted_msg = quoted_msg.id quoted_msg = quoted_msg.id
self._rpc.misc_set_draft(self.account.id, self.id, text, file, filename, quoted_msg, viewtype) self._rpc.misc_set_draft(self.account.id, self.id, text, file, quoted_msg, viewtype)
def remove_draft(self) -> None: def remove_draft(self) -> None:
"""Remove draft message.""" """Remove draft message."""
@@ -206,29 +195,23 @@ class Chat:
snapshot["message"] = Message(self.account, snapshot.id) snapshot["message"] = Message(self.account, snapshot.id)
return snapshot 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.""" """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] return [Message(self.account, msg_id) for msg_id in msgs]
def get_fresh_message_count(self) -> int: def get_fresh_message_count(self) -> int:
"""Get number of fresh messages in this chat.""" """Get number of fresh messages in this chat"""
return self._rpc.get_fresh_msg_cnt(self.account.id, self.id) return self._rpc.get_fresh_msg_cnt(self.account.id, self.id)
def mark_noticed(self) -> None: def mark_noticed(self) -> None:
"""Mark all messages in this chat as noticed.""" """Mark all messages in this chat as noticed."""
self._rpc.marknoticed_chat(self.account.id, self.id) self._rpc.marknoticed_chat(self.account.id, self.id)
def mark_fresh(self) -> None: def add_contact(self, *contact: Union[int, str, Contact]) -> 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.""" """Add contacts to this group."""
from .account import Account
for cnt in contact: for cnt in contact:
if isinstance(cnt, (str, Account)): if isinstance(cnt, str):
contact_id = self.account.create_contact(cnt).id contact_id = self.account.create_contact(cnt).id
elif not isinstance(cnt, int): elif not isinstance(cnt, int):
contact_id = cnt.id contact_id = cnt.id
@@ -236,12 +219,10 @@ class Chat:
contact_id = cnt contact_id = cnt
self._rpc.add_contact_to_chat(self.account.id, self.id, contact_id) self._rpc.add_contact_to_chat(self.account.id, self.id, contact_id)
def remove_contact(self, *contact: Union[int, str, Contact, "Account"]) -> None: def remove_contact(self, *contact: Union[int, str, Contact]) -> None:
"""Remove members from this group.""" """Remove members from this group."""
from .account import Account
for cnt in contact: for cnt in contact:
if isinstance(cnt, (str, Account)): if isinstance(cnt, str):
contact_id = self.account.create_contact(cnt).id contact_id = self.account.create_contact(cnt).id
elif not isinstance(cnt, int): elif not isinstance(cnt, int):
contact_id = cnt.id contact_id = cnt.id
@@ -257,15 +238,6 @@ class Chat:
contacts = self._rpc.get_chat_contacts(self.account.id, self.id) contacts = self._rpc.get_chat_contacts(self.account.id, self.id)
return [Contact(self.account, contact_id) for contact_id in contacts] return [Contact(self.account, contact_id) for contact_id in contacts]
def num_contacts(self) -> int:
"""Return number of contacts in this chat."""
return len(self.get_contacts())
def get_past_contacts(self) -> list[Contact]:
"""Get past contacts for this chat."""
past_contacts = self._rpc.get_past_chat_contacts(self.account.id, self.id)
return [Contact(self.account, contact_id) for contact_id in past_contacts]
def set_image(self, path: str) -> None: def set_image(self, path: str) -> None:
"""Set profile image of this chat. """Set profile image of this chat.
@@ -277,16 +249,6 @@ class Chat:
"""Remove profile image of this chat.""" """Remove profile image of this chat."""
self._rpc.set_chat_profile_image(self.account.id, self.id, None) 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( def get_locations(
self, self,
contact: Optional[Contact] = None, contact: Optional[Contact] = None,
@@ -316,8 +278,3 @@ class Chat:
f.write(vcard.encode()) f.write(vcard.encode())
f.flush() f.flush()
self._rpc.send_msg(self.account.id, self.id, {"viewtype": ViewType.VCARD, "file": f.name}) 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:
"""Starts an outgoing call."""
msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info, has_video_initially)
return Message(self.account, msg_id)

View File

@@ -14,7 +14,6 @@ from typing import (
from ._utils import ( from ._utils import (
AttrDict, AttrDict,
_forever,
parse_system_add_remove, parse_system_add_remove,
parse_system_image_changed, parse_system_image_changed,
parse_system_title_changed, parse_system_title_changed,
@@ -49,7 +48,6 @@ class Client:
self.add_hooks(hooks or []) self.add_hooks(hooks or [])
def add_hooks(self, hooks: Iterable[tuple[Callable, Union[type, EventFilter]]]) -> None: def add_hooks(self, hooks: Iterable[tuple[Callable, Union[type, EventFilter]]]) -> None:
"""Register multiple hooks."""
for hook, event in hooks: for hook, event in hooks:
self.add_hook(hook, event) self.add_hook(hook, event)
@@ -79,41 +77,31 @@ class Client:
self._hooks.get(type(event), set()).remove((hook, event)) self._hooks.get(type(event), set()).remove((hook, event))
def is_configured(self) -> bool: def is_configured(self) -> bool:
"""Return True if the client is configured."""
return self.account.is_configured() return self.account.is_configured()
def configure(self, email: str, password: str, **kwargs) -> None: def configure(self, email: str, password: str, **kwargs) -> None:
"""Configure the client.""" self.account.set_config("addr", email)
self.account.set_config("mail_pw", password)
for key, value in kwargs.items(): for key, value in kwargs.items():
self.account.set_config(key, value) self.account.set_config(key, value)
params = {"addr": email, "password": password} self.account.configure()
self.account.add_or_update_transport(params)
self.logger.debug("Account configured") self.logger.debug("Account configured")
def run_forever(self) -> None: def run_forever(self) -> None:
"""Process events forever.""" """Process events forever."""
self.run_until(_forever) self.run_until(lambda _: False)
def run_until(self, func: Callable[[AttrDict], bool]) -> AttrDict: 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...") self.logger.debug("Listening to incoming events...")
if self.is_configured(): if self.is_configured():
self.account.start_io() self.account.start_io()
self._process_messages() # Process old messages. 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: while True:
event = self.account.wait_for_event() event = self.account.wait_for_event()
event["kind"] = EventType(event.kind) event["kind"] = EventType(event.kind)
@@ -122,13 +110,10 @@ class Client:
if event.kind == EventType.INCOMING_MSG: if event.kind == EventType.INCOMING_MSG:
self._process_messages() self._process_messages()
stop = until_func(event) stop = func(event)
if stop: if stop:
return event return event
if event.kind == until_event:
return event
def _on_event(self, event: AttrDict, filter_type: Type[EventFilter] = RawEvent) -> None: def _on_event(self, event: AttrDict, filter_type: Type[EventFilter] = RawEvent) -> None:
for hook, evfilter in self._hooks.get(filter_type, []): for hook, evfilter in self._hooks.get(filter_type, []):
if evfilter.filter(event): if evfilter.filter(event):
@@ -213,6 +198,5 @@ class Bot(Client):
"""Simple bot implementation that listens to events of a single account.""" """Simple bot implementation that listens to events of a single account."""
def configure(self, email: str, password: str, **kwargs) -> None: def configure(self, email: str, password: str, **kwargs) -> None:
"""Configure the bot."""
kwargs.setdefault("bot", "1") kwargs.setdefault("bot", "1")
super().configure(email, password, **kwargs) super().configure(email, password, **kwargs)

View File

@@ -1,20 +1,14 @@
"""Constants module."""
from enum import Enum, IntEnum from enum import Enum, IntEnum
COMMAND_PREFIX = "/" COMMAND_PREFIX = "/"
class ContactFlag(IntEnum): class ContactFlag(IntEnum):
"""Bit flags for get_contacts() method.""" VERIFIED_ONLY = 0x01
ADD_SELF = 0x02 ADD_SELF = 0x02
ADDRESS = 0x04
class ChatlistFlag(IntEnum): class ChatlistFlag(IntEnum):
"""Bit flags for get_chatlist() method."""
ARCHIVED_ONLY = 0x01 ARCHIVED_ONLY = 0x01
NO_SPECIALS = 0x02 NO_SPECIALS = 0x02
ADD_ALLDONE_HINT = 0x04 ADD_ALLDONE_HINT = 0x04
@@ -22,8 +16,6 @@ class ChatlistFlag(IntEnum):
class SpecialContactId(IntEnum): class SpecialContactId(IntEnum):
"""Special contact IDs."""
SELF = 1 SELF = 1
INFO = 2 # centered messages as "member added", used in all chats INFO = 2 # centered messages as "member added", used in all chats
DEVICE = 5 # messages "update info" in the device-chat DEVICE = 5 # messages "update info" in the device-chat
@@ -31,7 +23,7 @@ class SpecialContactId(IntEnum):
class EventType(str, Enum): class EventType(str, Enum):
"""Core event types.""" """Core event types"""
INFO = "Info" INFO = "Info"
SMTP_CONNECTED = "SmtpConnected" SMTP_CONNECTED = "SmtpConnected"
@@ -49,14 +41,12 @@ class EventType(str, Enum):
REACTIONS_CHANGED = "ReactionsChanged" REACTIONS_CHANGED = "ReactionsChanged"
INCOMING_MSG = "IncomingMsg" INCOMING_MSG = "IncomingMsg"
INCOMING_MSG_BUNCH = "IncomingMsgBunch" INCOMING_MSG_BUNCH = "IncomingMsgBunch"
INCOMING_REACTION = "IncomingReaction"
MSGS_NOTICED = "MsgsNoticed" MSGS_NOTICED = "MsgsNoticed"
MSG_DELIVERED = "MsgDelivered" MSG_DELIVERED = "MsgDelivered"
MSG_FAILED = "MsgFailed" MSG_FAILED = "MsgFailed"
MSG_READ = "MsgRead" MSG_READ = "MsgRead"
MSG_DELETED = "MsgDeleted" MSG_DELETED = "MsgDeleted"
CHAT_MODIFIED = "ChatModified" CHAT_MODIFIED = "ChatModified"
CHAT_DELETED = "ChatDeleted"
CHAT_EPHEMERAL_TIMER_MODIFIED = "ChatEphemeralTimerModified" CHAT_EPHEMERAL_TIMER_MODIFIED = "ChatEphemeralTimerModified"
CONTACTS_CHANGED = "ContactsChanged" CONTACTS_CHANGED = "ContactsChanged"
LOCATION_CHANGED = "LocationChanged" LOCATION_CHANGED = "LocationChanged"
@@ -73,18 +63,13 @@ class EventType(str, Enum):
CHATLIST_ITEM_CHANGED = "ChatlistItemChanged" CHATLIST_ITEM_CHANGED = "ChatlistItemChanged"
ACCOUNTS_CHANGED = "AccountsChanged" ACCOUNTS_CHANGED = "AccountsChanged"
ACCOUNTS_ITEM_CHANGED = "AccountsItemChanged" ACCOUNTS_ITEM_CHANGED = "AccountsItemChanged"
INCOMING_CALL = "IncomingCall"
INCOMING_CALL_ACCEPTED = "IncomingCallAccepted"
OUTGOING_CALL_ACCEPTED = "OutgoingCallAccepted"
CALL_ENDED = "CallEnded"
CONFIG_SYNCED = "ConfigSynced" CONFIG_SYNCED = "ConfigSynced"
WEBXDC_REALTIME_DATA = "WebxdcRealtimeData" WEBXDC_REALTIME_DATA = "WebxdcRealtimeData"
WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED = "WebxdcRealtimeAdvertisementReceived" WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED = "WebxdcRealtimeAdvertisementReceived"
TRANSPORTS_MODIFIED = "TransportsModified"
class ChatId(IntEnum): class ChatId(IntEnum):
"""Special chat IDs.""" """Special chat ids"""
TRASH = 3 TRASH = 3
ARCHIVED_LINK = 6 ARCHIVED_LINK = 6
@@ -92,46 +77,18 @@ class ChatId(IntEnum):
LAST_SPECIAL = 9 LAST_SPECIAL = 9
class ChatType(str, Enum): class ChatType(IntEnum):
"""Chat type.""" """Chat types"""
SINGLE = "Single" UNDEFINED = 0
"""1:1 chat, i.e. a direct chat with a single contact""" SINGLE = 100
GROUP = 120
GROUP = "Group" MAILINGLIST = 140
BROADCAST = 160
MAILINGLIST = "Mailinglist"
OUT_BROADCAST = "OutBroadcast"
"""Outgoing broadcast channel, called "Channel" in the UI.
The user can send into this channel,
and all recipients will receive messages
in an `IN_BROADCAST`.
Called `broadcast` here rather than `channel`,
because the word "channel" already appears a lot in the code,
which would make it hard to grep for it.
"""
IN_BROADCAST = "InBroadcast"
"""Incoming broadcast channel, called "Channel" in the UI.
This channel is read-only,
and we do not know who the other recipients are.
This is similar to a `MAILINGLIST`,
with the main difference being that
`IN_BROADCAST`s are encrypted.
Called `broadcast` here rather than `channel`,
because the word "channel" already appears a lot in the code,
which would make it hard to grep for it.
"""
class ChatVisibility(str, Enum): class ChatVisibility(str, Enum):
"""Chat visibility types.""" """Chat visibility types"""
NORMAL = "Normal" NORMAL = "Normal"
ARCHIVED = "Archived" ARCHIVED = "Archived"
@@ -139,7 +96,7 @@ class ChatVisibility(str, Enum):
class DownloadState(str, Enum): class DownloadState(str, Enum):
"""Message download state.""" """Message download state"""
DONE = "Done" DONE = "Done"
AVAILABLE = "Available" AVAILABLE = "Available"
@@ -159,6 +116,7 @@ class ViewType(str, Enum):
VOICE = "Voice" VOICE = "Voice"
VIDEO = "Video" VIDEO = "Video"
FILE = "File" FILE = "File"
VIDEOCHAT_INVITATION = "VideochatInvitation"
WEBXDC = "Webxdc" WEBXDC = "Webxdc"
VCARD = "Vcard" VCARD = "Vcard"
@@ -199,14 +157,14 @@ class MessageState(IntEnum):
class MessageId(IntEnum): class MessageId(IntEnum):
"""Special message IDs.""" """Special message ids"""
DAYMARKER = 9 DAYMARKER = 9
LAST_SPECIAL = 9 LAST_SPECIAL = 9
class CertificateChecks(IntEnum): class CertificateChecks(IntEnum):
"""Certificate checks mode.""" """Certificate checks mode"""
AUTOMATIC = 0 AUTOMATIC = 0
STRICT = 1 STRICT = 1
@@ -214,7 +172,7 @@ class CertificateChecks(IntEnum):
class Connectivity(IntEnum): class Connectivity(IntEnum):
"""Connectivity states.""" """Connectivity states"""
NOT_CONNECTED = 1000 NOT_CONNECTED = 1000
CONNECTING = 2000 CONNECTING = 2000
@@ -223,7 +181,7 @@ class Connectivity(IntEnum):
class KeyGenType(IntEnum): class KeyGenType(IntEnum):
"""Type of the key to generate.""" """Type of the key to generate"""
DEFAULT = 0 DEFAULT = 0
RSA2048 = 1 RSA2048 = 1
@@ -233,21 +191,21 @@ class KeyGenType(IntEnum):
# "Lp" means "login parameters" # "Lp" means "login parameters"
class LpAuthFlag(IntEnum): class LpAuthFlag(IntEnum):
"""Authorization flags.""" """Authorization flags"""
OAUTH2 = 0x2 OAUTH2 = 0x2
NORMAL = 0x4 NORMAL = 0x4
class MediaQuality(IntEnum): class MediaQuality(IntEnum):
"""Media quality setting.""" """Media quality setting"""
BALANCED = 0 BALANCED = 0
WORSE = 1 WORSE = 1
class ProviderStatus(IntEnum): class ProviderStatus(IntEnum):
"""Provider status according to manual testing.""" """Provider status according to manual testing"""
OK = 1 OK = 1
PREPARATION = 2 PREPARATION = 2
@@ -255,7 +213,7 @@ class ProviderStatus(IntEnum):
class PushNotifyState(IntEnum): class PushNotifyState(IntEnum):
"""Push notifications state.""" """Push notifications state"""
NOT_CONNECTED = 0 NOT_CONNECTED = 0
HEARTBEAT = 1 HEARTBEAT = 1
@@ -263,7 +221,7 @@ class PushNotifyState(IntEnum):
class ShowEmails(IntEnum): class ShowEmails(IntEnum):
"""Show emails mode.""" """Show emails mode"""
OFF = 0 OFF = 0
ACCEPTED_CONTACTS = 1 ACCEPTED_CONTACTS = 1
@@ -271,9 +229,17 @@ class ShowEmails(IntEnum):
class SocketSecurity(IntEnum): class SocketSecurity(IntEnum):
"""Socket security.""" """Socket security"""
AUTOMATIC = 0 AUTOMATIC = 0
SSL = 1 SSL = 1
STARTTLS = 2 STARTTLS = 2
PLAIN = 3 PLAIN = 3
class VideochatType(IntEnum):
"""Video chat URL type"""
UNKNOWN = 0
BASICWEBRTC = 1
JITSI = 2

View File

@@ -1,5 +1,3 @@
"""Contact module."""
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -13,7 +11,8 @@ if TYPE_CHECKING:
@dataclass @dataclass
class Contact: class Contact:
"""Contact API. """
Contact API.
Essentially a wrapper for RPC, account ID and a contact ID. Essentially a wrapper for RPC, account ID and a contact ID.
""" """
@@ -37,14 +36,17 @@ class Contact:
"""Delete contact.""" """Delete contact."""
self._rpc.delete_contact(self.account.id, self.id) self._rpc.delete_contact(self.account.id, self.id)
def reset_encryption(self) -> None:
"""Reset contact encryption."""
self._rpc.reset_contact_encryption(self.account.id, self.id)
def set_name(self, name: str) -> None: def set_name(self, name: str) -> None:
"""Change the name of this contact.""" """Change the name of this contact."""
self._rpc.change_contact_name(self.account.id, self.id, name) self._rpc.change_contact_name(self.account.id, self.id, name)
def get_encryption_info(self) -> str: def get_encryption_info(self) -> str:
"""Get a multi-line encryption info. """Get a multi-line encryption info, containing your fingerprint and
the fingerprint of the contact.
Encryption info contains your fingerprint and the fingerprint of the contact.
""" """
return self._rpc.get_contact_encryption_info(self.account.id, self.id) return self._rpc.get_contact_encryption_info(self.account.id, self.id)
@@ -64,5 +66,4 @@ class Contact:
) )
def make_vcard(self) -> str: def make_vcard(self) -> str:
"""Make a vCard for the contact.""" return self._rpc.make_vcard(self.account.id, [self.id])
return self.account.make_vcard([self])

View File

@@ -1,10 +1,8 @@
"""Account manager module."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ._utils import AttrDict, futuremethod from ._utils import AttrDict
from .account import Account from .account import Account
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -12,13 +10,12 @@ if TYPE_CHECKING:
class DeltaChat: class DeltaChat:
"""Delta Chat accounts manager. """
Delta Chat accounts manager.
This is the root of the object oriented API. This is the root of the object oriented API.
""" """
def __init__(self, rpc: "Rpc") -> None: def __init__(self, rpc: "Rpc") -> None:
"""Initialize account manager."""
self.rpc = rpc self.rpc = rpc
def add_account(self) -> Account: def add_account(self) -> Account:
@@ -39,17 +36,10 @@ class DeltaChat:
"""Stop the I/O of all accounts.""" """Stop the I/O of all accounts."""
self.rpc.stop_io_for_all_accounts() self.rpc.stop_io_for_all_accounts()
@futuremethod
def background_fetch(self, timeout_in_seconds: int) -> None:
"""Run background fetch for all accounts."""
yield self.rpc.background_fetch.future(timeout_in_seconds)
def stop_background_fetch(self) -> None:
"""Stop ongoing background fetch."""
self.rpc.stop_background_fetch()
def maybe_network(self) -> None: def maybe_network(self) -> None:
"""Indicate that the network conditions might have changed.""" """Indicate that the network likely has come back or just that the network
conditions might have changed.
"""
self.rpc.maybe_network() self.rpc.maybe_network()
def get_system_info(self) -> AttrDict: def get_system_info(self) -> AttrDict:
@@ -59,11 +49,3 @@ class DeltaChat:
def set_translations(self, translations: dict[str, str]) -> None: def set_translations(self, translations: dict[str, str]) -> None:
"""Set stock translation strings.""" """Set stock translation strings."""
self.rpc.set_stock_strings(translations) 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()

View File

@@ -36,7 +36,7 @@ class EventFilter(ABC):
@abstractmethod @abstractmethod
def __hash__(self) -> int: def __hash__(self) -> int:
"""Object's unique hash.""" """Object's unique hash"""
@abstractmethod @abstractmethod
def __eq__(self, other) -> bool: def __eq__(self, other) -> bool:
@@ -52,7 +52,9 @@ class EventFilter(ABC):
@abstractmethod @abstractmethod
def filter(self, event): def filter(self, event):
"""Return True-like value if the event passed the filter.""" """Return True-like value if the event passed the filter and should be
used, or False-like value otherwise.
"""
class RawEvent(EventFilter): class RawEvent(EventFilter):
@@ -80,17 +82,31 @@ class RawEvent(EventFilter):
return False return False
def filter(self, event: "AttrDict") -> bool: def filter(self, event: "AttrDict") -> bool:
"""Filter an event.
Return true if the event should be processed.
"""
if self.types and event.kind not in self.types: if self.types and event.kind not in self.types:
return False return False
return self._call_func(event) return self._call_func(event)
class NewMessage(EventFilter): class NewMessage(EventFilter):
"""Matches whenever a new message arrives.""" """Matches whenever a new message arrives.
Warning: registering a handler for this event will cause the messages
to be marked as read. Its usage is mainly intended for bots.
:param pattern: if set, this Pattern will be used to filter the message by its text
content.
:param command: If set, only match messages with the given command (ex. /help).
Setting this property implies `is_info==False`.
:param is_bot: If set to True only match messages sent by bots, if set to None
match messages from bots and users. If omitted or set to False
only messages from users will be matched.
:param is_info: If set to True only match info/system messages, if set to False
only match messages that are not info/system messages. If omitted
info/system messages as well as normal messages will be matched.
:param func: A Callable function that should accept the event as input
parameter, and return a bool value indicating whether the event
should be dispatched or not.
"""
def __init__( def __init__(
self, self,
@@ -105,25 +121,6 @@ class NewMessage(EventFilter):
is_info: Optional[bool] = None, is_info: Optional[bool] = None,
func: Optional[Callable[["AttrDict"], bool]] = None, func: Optional[Callable[["AttrDict"], bool]] = None,
) -> None: ) -> None:
"""Initialize a new message filter.
Warning: registering a handler for this event will cause the messages
to be marked as read. Its usage is mainly intended for bots.
:param pattern: if set, this Pattern will be used to filter the message by its text
content.
:param command: If set, only match messages with the given command (ex. /help).
Setting this property implies `is_info==False`.
:param is_bot: If set to True only match messages sent by bots, if set to None
match messages from bots and users. If omitted or set to False
only messages from users will be matched.
:param is_info: If set to True only match info/system messages, if set to False
only match messages that are not info/system messages. If omitted
info/system messages as well as normal messages will be matched.
:param func: A Callable function that should accept the event as input
parameter, and return a bool value indicating whether the event
should be dispatched or not.
"""
super().__init__(func=func) super().__init__(func=func)
self.is_bot = is_bot self.is_bot = is_bot
self.is_info = is_info self.is_info = is_info
@@ -162,7 +159,6 @@ class NewMessage(EventFilter):
return False return False
def filter(self, event: "AttrDict") -> bool: def filter(self, event: "AttrDict") -> bool:
"""Return true if if the event is a new message event."""
if self.is_bot is not None and self.is_bot != event.message_snapshot.is_bot: if self.is_bot is not None and self.is_bot != event.message_snapshot.is_bot:
return False return False
if self.is_info is not None and self.is_info != event.message_snapshot.is_info: if self.is_info is not None and self.is_info != event.message_snapshot.is_info:
@@ -203,7 +199,6 @@ class MemberListChanged(EventFilter):
return False return False
def filter(self, event: "AttrDict") -> bool: def filter(self, event: "AttrDict") -> bool:
"""Return true if if the event is a member addition event."""
if self.added is not None and self.added != event.member_added: if self.added is not None and self.added != event.member_added:
return False return False
return self._call_func(event) return self._call_func(event)
@@ -236,7 +231,6 @@ class GroupImageChanged(EventFilter):
return False return False
def filter(self, event: "AttrDict") -> bool: def filter(self, event: "AttrDict") -> bool:
"""Return True if event is matched."""
if self.deleted is not None and self.deleted != event.image_deleted: if self.deleted is not None and self.deleted != event.image_deleted:
return False return False
return self._call_func(event) return self._call_func(event)
@@ -262,12 +256,13 @@ class GroupNameChanged(EventFilter):
return False return False
def filter(self, event: "AttrDict") -> bool: def filter(self, event: "AttrDict") -> bool:
"""Return True if event is matched."""
return self._call_func(event) return self._call_func(event)
class HookCollection: class HookCollection:
"""Helper class to collect event hooks that can later be added to a Delta Chat client.""" """
Helper class to collect event hooks that can later be added to a Delta Chat client.
"""
def __init__(self) -> None: def __init__(self) -> None:
self._hooks: set[tuple[Callable, Union[type, EventFilter]]] = set() self._hooks: set[tuple[Callable, Union[type, EventFilter]]] = set()

View File

@@ -1,8 +1,6 @@
"""Message module."""
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional, Union from typing import TYPE_CHECKING, Optional, Union
from ._utils import AttrDict, futuremethod from ._utils import AttrDict, futuremethod
from .const import EventType from .const import EventType
@@ -25,14 +23,7 @@ class Message:
return self.account._rpc return self.account._rpc
def send_reaction(self, *reaction: str) -> "Message": def send_reaction(self, *reaction: str) -> "Message":
""" """Send a reaction to this 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.
"""
msg_id = self._rpc.send_reaction(self.account.id, self.id, reaction) msg_id = self._rpc.send_reaction(self.account.id, self.id, reaction)
return Message(self.account, msg_id) return Message(self.account, msg_id)
@@ -46,19 +37,6 @@ class Message:
snapshot["message"] = self snapshot["message"] = self
return snapshot return snapshot
def get_read_receipts(self) -> List[AttrDict]:
"""Get message read receipts."""
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]: def get_reactions(self) -> Optional[AttrDict]:
"""Get message reactions.""" """Get message reactions."""
reactions = self._rpc.get_message_reactions(self.account.id, self.id) reactions = self._rpc.get_message_reactions(self.account.id, self.id)
@@ -67,7 +45,6 @@ class Message:
return None return None
def get_sender_contact(self) -> Contact: def get_sender_contact(self) -> Contact:
"""Return sender contact."""
from_id = self.get_snapshot().from_id from_id = self.get_snapshot().from_id
return self.account.get_contact_by_id(from_id) return self.account.get_contact_by_id(from_id)
@@ -75,10 +52,6 @@ class Message:
"""Mark the message as seen.""" """Mark the message as seen."""
self._rpc.markseen_msgs(self.account.id, [self.id]) self._rpc.markseen_msgs(self.account.id, [self.id])
def exists(self) -> bool:
"""Return True if the message exists."""
return bool(self._rpc.get_existing_msg_ids(self.account.id, [self.id]))
def send_webxdc_status_update(self, update: Union[dict, str], description: str) -> None: def send_webxdc_status_update(self, update: Union[dict, str], description: str) -> None:
"""Send a webxdc status update. This message must be a webxdc.""" """Send a webxdc status update. This message must be a webxdc."""
if not isinstance(update, str): if not isinstance(update, str):
@@ -86,15 +59,9 @@ class Message:
self._rpc.send_webxdc_status_update(self.account.id, self.id, update, description) self._rpc.send_webxdc_status_update(self.account.id, self.id, update, description)
def get_webxdc_status_updates(self, last_known_serial: int = 0) -> list: def get_webxdc_status_updates(self, last_known_serial: int = 0) -> list:
"""Return a list of Webxdc status updates for Webxdc instance message."""
return json.loads(self._rpc.get_webxdc_status_updates(self.account.id, self.id, last_known_serial)) return json.loads(self._rpc.get_webxdc_status_updates(self.account.id, self.id, last_known_serial))
def get_info(self) -> str:
"""Return message info."""
return self._rpc.get_message_info(self.account.id, self.id)
def get_webxdc_info(self) -> dict: def get_webxdc_info(self) -> dict:
"""Get info from a Webxdc message in JSON format."""
return self._rpc.get_webxdc_info(self.account.id, self.id) return self._rpc.get_webxdc_info(self.account.id, self.id)
def wait_until_delivered(self) -> None: def wait_until_delivered(self) -> None:
@@ -104,35 +71,10 @@ class Message:
if event.kind == EventType.MSG_DELIVERED and event.msg_id == self.id: if event.kind == EventType.MSG_DELIVERED and event.msg_id == self.id:
break break
def resend(self) -> None:
"""Resend messages and make information available for newly added chat members.
Resending sends out the original message, however, recipients and webxdc-status may differ.
Clients that already have the original message can still ignore the resent message as
they have tracked the state by dedicated updates.
Some messages cannot be resent, eg. info-messages, drafts, already pending messages,
or messages that are not sent by SELF.
"""
self._rpc.resend_messages(self.account.id, [self.id])
@futuremethod @futuremethod
def send_webxdc_realtime_advertisement(self): def send_webxdc_realtime_advertisement(self):
"""Send an advertisement to join the realtime channel."""
yield self._rpc.send_webxdc_realtime_advertisement.future(self.account.id, self.id) yield self._rpc.send_webxdc_realtime_advertisement.future(self.account.id, self.id)
@futuremethod @futuremethod
def send_webxdc_realtime_data(self, data) -> None: def send_webxdc_realtime_data(self, data) -> None:
"""Send data to the realtime channel."""
yield self._rpc.send_webxdc_realtime_data.future(self.account.id, self.id, list(data)) yield self._rpc.send_webxdc_realtime_data.future(self.account.id, self.id, list(data))
def accept_incoming_call(self, accept_call_info):
"""Accepts an incoming call."""
self._rpc.accept_incoming_call(self.account.id, self.id, accept_call_info)
def end_call(self):
"""Ends incoming or outgoing call."""
self._rpc.end_call(self.account.id, self.id)
def get_call_info(self) -> AttrDict:
"""Return information about the call."""
return AttrDict(self._rpc.call_info(self.account.id, self.id))

View File

@@ -1,113 +1,79 @@
"""Pytest plugin module."""
from __future__ import annotations from __future__ import annotations
import logging
import os import os
import pathlib
import platform
import random import random
import subprocess
import sys
from typing import AsyncGenerator, Optional from typing import AsyncGenerator, Optional
import execnet
import py
import pytest import pytest
from . import Account, AttrDict, Bot, Chat, Client, DeltaChat, EventType, Message from . import Account, AttrDict, Bot, Chat, Client, DeltaChat, EventType, Message
from ._utils import futuremethod from ._utils import futuremethod
from .rpc import Rpc 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."
"""
def get_temp_credentials() -> dict:
def pytest_report_header(): domain = os.getenv("CHATMAIL_DOMAIN")
for base in os.get_exec_path(): username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
fn = pathlib.Path(base).joinpath(base, "deltachat-rpc-server") password = f"{username}${username}"
if fn.exists(): addr = f"{username}@{domain}"
proc = subprocess.Popen([str(fn), "--version"], stderr=subprocess.PIPE) return {"email": addr, "password": password}
proc.wait()
version = proc.stderr.read().decode().strip()
return f"deltachat-rpc-server: {fn} [{version}]"
return None
class ACFactory: class ACFactory:
"""Test account factory."""
def __init__(self, deltachat: DeltaChat) -> None: def __init__(self, deltachat: DeltaChat) -> None:
self.deltachat = deltachat self.deltachat = deltachat
def get_unconfigured_account(self) -> Account: def get_unconfigured_account(self) -> Account:
"""Create a new unconfigured account.""" account = self.deltachat.add_account()
return self.deltachat.add_account() account.set_config("verified_one_on_one_chats", "1")
return account
def get_unconfigured_bot(self) -> Bot: def get_unconfigured_bot(self) -> Bot:
"""Create a new unconfigured bot."""
return Bot(self.get_unconfigured_account()) return Bot(self.get_unconfigured_account())
def get_credentials(self) -> (str, str): def new_preconfigured_account(self) -> Account:
"""Generate new credentials for chatmail account.""" """Make a new account with configuration options set, but configuration not started."""
domain = os.environ["CHATMAIL_DOMAIN"] credentials = get_temp_credentials()
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6)) account = self.get_unconfigured_account()
return f"{username}@{domain}", f"{username}${username}" account.set_config("addr", credentials["email"])
account.set_config("mail_pw", credentials["password"])
def get_account_qr(self): assert not account.is_configured()
"""Return "dcaccount:" QR code for testing chatmail relay.""" return account
domain = os.environ["CHATMAIL_DOMAIN"]
return f"dcaccount:{domain}"
@futuremethod @futuremethod
def new_configured_account(self): def new_configured_account(self):
"""Create a new configured account.""" account = self.new_preconfigured_account()
account = self.get_unconfigured_account() yield account.configure.future()
qr = self.get_account_qr()
yield account.add_transport_from_qr.future(qr)
assert account.is_configured() assert account.is_configured()
return account return account
def new_configured_bot(self) -> Bot: def new_configured_bot(self) -> Bot:
"""Create a new configured bot.""" credentials = get_temp_credentials()
addr, password = self.get_credentials()
bot = self.get_unconfigured_bot() bot = self.get_unconfigured_bot()
bot.configure(addr, password) bot.configure(credentials["email"], credentials["password"])
return bot return bot
@futuremethod @futuremethod
def get_online_account(self): def get_online_account(self):
"""Create a new account and start I/O."""
account = yield self.new_configured_account.future() account = yield self.new_configured_account.future()
account.bring_online() account.bring_online()
return account return account
def get_online_accounts(self, num: int) -> list[Account]: def get_online_accounts(self, num: int) -> list[Account]:
"""Create multiple online accounts."""
futures = [self.get_online_account.future() for _ in range(num)] futures = [self.get_online_account.future() for _ in range(num)]
return [f() for f in futures] return [f() for f in futures]
def resetup_account(self, ac: Account) -> Account: def resetup_account(self, ac: Account) -> Account:
"""Resetup account from scratch, losing the encryption key.""" """Resetup account from scratch, losing the encryption key."""
ac.stop_io() ac.stop_io()
transports = ac.list_transports()
ac.remove()
ac_clone = self.get_unconfigured_account() ac_clone = self.get_unconfigured_account()
for transport in transports: for i in ["addr", "mail_pw"]:
ac_clone.add_or_update_transport(transport) ac_clone.set_config(i, ac.get_config(i))
ac_clone.bring_online() ac.remove()
ac_clone.configure()
return ac_clone return ac_clone
def get_accepted_chat(self, ac1: Account, ac2: Account) -> Chat: def get_accepted_chat(self, ac1: Account, ac2: Account) -> Chat:
"""Create a new 1:1 chat between ac1 and ac2 accepted on both sides.
Returned chat is a chat with ac2 from ac1 point of view.
"""
ac2.create_chat(ac1) ac2.create_chat(ac1)
return ac1.create_chat(ac2) return ac1.create_chat(ac2)
@@ -119,10 +85,9 @@ class ACFactory:
file: Optional[str] = None, file: Optional[str] = None,
group: Optional[str] = None, group: Optional[str] = None,
) -> Message: ) -> Message:
"""Send a message."""
if not from_account: if not from_account:
from_account = (self.get_online_accounts(1))[0] from_account = (self.get_online_accounts(1))[0]
to_contact = from_account.create_contact(to_account) to_contact = from_account.create_contact(to_account.get_config("addr"))
if group: if group:
to_chat = from_account.create_group(group) to_chat = from_account.create_group(group)
to_chat.add_contact(to_contact) to_chat.add_contact(to_contact)
@@ -138,7 +103,6 @@ class ACFactory:
file: Optional[str] = None, file: Optional[str] = None,
group: Optional[str] = None, group: Optional[str] = None,
) -> AttrDict: ) -> AttrDict:
"""Send a message and wait until recipient processes it."""
self.send_message( self.send_message(
to_account=to_client.account, to_account=to_client.account,
from_account=from_account, from_account=from_account,
@@ -152,196 +116,11 @@ class ACFactory:
@pytest.fixture @pytest.fixture
def rpc(tmp_path) -> AsyncGenerator: def rpc(tmp_path) -> AsyncGenerator:
"""RPC client fixture."""
rpc_server = Rpc(accounts_dir=str(tmp_path / "accounts")) rpc_server = Rpc(accounts_dir=str(tmp_path / "accounts"))
with rpc_server: with rpc_server:
yield rpc_server yield rpc_server
@pytest.fixture @pytest.fixture
def dc(rpc) -> DeltaChat: def acfactory(rpc) -> AsyncGenerator:
"""Return account manager.""" return ACFactory(DeltaChat(rpc))
return DeltaChat(rpc)
@pytest.fixture
def acfactory(dc) -> AsyncGenerator:
"""Return account factory fixture."""
return ACFactory(dc)
@pytest.fixture
def data():
"""Test data."""
class Data:
def __init__(self) -> None:
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")
def get_path(self, bn):
"""Return path of file or None if it doesn't exist."""
fn = os.path.join(self.path, *bn.split("/"))
assert os.path.exists(fn)
return fn
def read_path(self, bn, mode="r"):
fn = self.get_path(bn)
if fn is not None:
with open(fn, mode) as f:
return f.read()
return None
return Data()
@pytest.fixture
def log():
"""Log printer fixture."""
class Printer:
def section(self, msg: str) -> None:
logging.info("\n%s %s %s", "=" * 10, msg, "=" * 10)
def step(self, msg: str) -> None:
logging.info("%s step %s %s", "-" * 5, msg, "-" * 5)
def indent(self, msg: str) -> None:
logging.info(" " + msg)
return Printer()
#
# support for testing against different deltachat-rpc-server/clients
# installed into a temporary virtualenv and connected via 'execnet' channels
#
def find_path(venv, name):
is_windows = platform.system() == "Windows"
bin = venv / ("bin" if not is_windows else "Scripts")
tryadd = [""]
if is_windows:
tryadd += os.environ["PATHEXT"].split(os.pathsep)
for ext in tryadd:
p = bin.joinpath(name + ext)
if p.exists():
return str(p)
return None
@pytest.fixture(scope="session")
def get_core_python_env(tmp_path_factory):
"""Return a factory to create virtualenv environments with rpc server/client packages
installed.
The factory takes a version and returns a (python_path, rpc_server_path) tuple
of the respective binaries in the virtualenv.
"""
envs = {}
def get_versioned_venv(core_version):
venv = envs.get(core_version)
if not venv:
venv = tmp_path_factory.mktemp(f"temp-{core_version}")
subprocess.check_call([sys.executable, "-m", "venv", venv])
python = find_path(venv, "python")
pkgs = [f"deltachat-rpc-server=={core_version}", f"deltachat-rpc-client=={core_version}", "pytest"]
subprocess.check_call([python, "-m", "pip", "install"] + pkgs)
envs[core_version] = venv
python = find_path(venv, "python")
rpc_server_path = find_path(venv, "deltachat-rpc-server")
logging.info(f"Paths:\npython={python}\nrpc_server={rpc_server_path}")
return python, rpc_server_path
return get_versioned_venv
@pytest.fixture
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`.
"""
def factory(core_version):
python, rpc_server_path = get_core_python_env(core_version)
gw = execnet.makegateway(f"popen//python={python}")
accounts_dir = str(tmp_path.joinpath("account1_venv1"))
channel = gw.remote_exec(remote_bob_loop)
cm = os.environ.get("CHATMAIL_DOMAIN")
# trigger getting an online account on bob's side
channel.send((accounts_dir, str(rpc_server_path), cm))
# meanwhile get a local alice account
alice = acfactory.get_online_account()
channel.send(alice.self_contact.make_vcard())
# wait for bob to have started
sysinfo = channel.receive()
assert sysinfo == f"v{core_version}"
bob_vcard = channel.receive()
[alice_contact_bob] = alice.import_vcard(bob_vcard)
def eval(eval_str):
channel.send(eval_str)
return channel.receive()
return alice, alice_contact_bob, eval
return factory
def remote_bob_loop(channel):
# This function executes with versioned
# deltachat-rpc-client/server packages
# installed into the virtualenv.
#
# The "channel" argument is a send/receive pipe
# to the process that runs the corresponding remote_exec(remote_bob_loop)
import os
from deltachat_rpc_client import DeltaChat, Rpc
from deltachat_rpc_client.pytestplugin import ACFactory
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
basepath = os.path.dirname(rpc_server_path)
os.environ["PATH"] = os.pathsep.join([basepath, os.environ["PATH"]])
rpc = Rpc(accounts_dir=accounts_dir)
with rpc:
dc = DeltaChat(rpc)
channel.send(dc.rpc.get_system_info()["deltachat_core_version"])
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}
channel.send(bob.self_contact.make_vcard())
while 1:
eval_str = channel.receive()
res = eval(eval_str, ns)
try:
channel.send(res)
except Exception:
# some unserializable result
channel.send(None)

View File

@@ -1,5 +1,3 @@
"""JSON-RPC client module."""
from __future__ import annotations from __future__ import annotations
import itertools import itertools
@@ -9,28 +7,42 @@ import os
import subprocess import subprocess
import sys import sys
from queue import Empty, Queue from queue import Empty, Queue
from threading import Thread from threading import Event, Thread
from typing import Any, Iterator, Optional from typing import Any, Iterator, Optional
class JsonRpcError(Exception): class JsonRpcError(Exception):
"""JSON-RPC error.""" pass
class RpcFuture:
def __init__(self, rpc: "Rpc", request_id: int, event: Event):
self.rpc = rpc
self.request_id = request_id
self.event = event
def __call__(self):
self.event.wait()
response = self.rpc.request_results.pop(self.request_id)
if "error" in response:
raise JsonRpcError(response["error"])
if "result" in response:
return response["result"]
return None
class RpcMethod: class RpcMethod:
"""RPC method."""
def __init__(self, rpc: "Rpc", name: str): def __init__(self, rpc: "Rpc", name: str):
self.rpc = rpc self.rpc = rpc
self.name = name self.name = name
def __call__(self, *args) -> Any: def __call__(self, *args) -> Any:
"""Call JSON-RPC method synchronously.""" """Synchronously calls JSON-RPC method."""
future = self.future(*args) future = self.future(*args)
return future() return future()
def future(self, *args) -> Any: def future(self, *args) -> Any:
"""Call JSON-RPC method asynchronously.""" """Asynchronously calls JSON-RPC method."""
request_id = next(self.rpc.id_iterator) request_id = next(self.rpc.id_iterator)
request = { request = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -38,32 +50,16 @@ class RpcMethod:
"params": args, "params": args,
"id": request_id, "id": request_id,
} }
self.rpc.request_results[request_id] = queue = Queue() event = Event()
self.rpc.request_events[request_id] = event
self.rpc.request_queue.put(request) self.rpc.request_queue.put(request)
def rpc_future(): return RpcFuture(self.rpc, request_id, event)
"""Wait for the request to receive a result."""
response = queue.get()
if "error" in response:
raise JsonRpcError(response["error"])
return response.get("result", None)
return rpc_future
class Rpc: class Rpc:
"""RPC client.""" def __init__(self, accounts_dir: Optional[str] = None, **kwargs):
"""The given arguments will be passed to subprocess.Popen()"""
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().
"""
if accounts_dir: if accounts_dir:
kwargs["env"] = { kwargs["env"] = {
**kwargs.get("env", os.environ), **kwargs.get("env", os.environ),
@@ -71,12 +67,13 @@ class Rpc:
} }
self._kwargs = kwargs self._kwargs = kwargs
self.rpc_server_path = rpc_server_path
self.process: subprocess.Popen self.process: subprocess.Popen
self.id_iterator: Iterator[int] self.id_iterator: Iterator[int]
self.event_queues: dict[int, Queue] self.event_queues: dict[int, Queue]
# Map from request ID to a Queue which provides a single result # Map from request ID to `threading.Event`.
self.request_results: dict[int, Queue] self.request_events: dict[int, Event]
# Map from request ID to the result.
self.request_results: dict[int, Any]
self.request_queue: Queue[Any] self.request_queue: Queue[Any]
self.closing: bool self.closing: bool
self.reader_thread: Thread self.reader_thread: Thread
@@ -84,27 +81,27 @@ class Rpc:
self.events_thread: Thread self.events_thread: Thread
def start(self) -> None: 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}
if sys.version_info >= (3, 11): if sys.version_info >= (3, 11):
# Prevent subprocess from capturing SIGINT. self.process = subprocess.Popen(
popen_kwargs["process_group"] = 0 "deltachat-rpc-server",
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
# Prevent subprocess from capturing SIGINT.
process_group=0,
**self._kwargs,
)
else: else:
# `process_group` is not supported before Python 3.11. self.process = subprocess.Popen(
popen_kwargs["preexec_fn"] = os.setpgrp # noqa: PLW1509 "deltachat-rpc-server",
stdin=subprocess.PIPE,
popen_kwargs.update(self._kwargs) stdout=subprocess.PIPE,
self.process = subprocess.Popen(self.rpc_server_path, **popen_kwargs) # `process_group` is not supported before Python 3.11.
preexec_fn=os.setpgrp, # noqa: PLW1509
**self._kwargs,
)
self.id_iterator = itertools.count(start=1) self.id_iterator = itertools.count(start=1)
self.event_queues = {} self.event_queues = {}
self.request_events = {}
self.request_results = {} self.request_results = {}
self.request_queue = Queue() self.request_queue = Queue()
self.closing = False self.closing = False
@@ -115,22 +112,6 @@ class Rpc:
self.events_thread = Thread(target=self.events_loop) self.events_thread = Thread(target=self.events_loop)
self.events_thread.start() 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()
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: def close(self) -> None:
"""Terminate RPC server process and wait until the reader loop finishes.""" """Terminate RPC server process and wait until the reader loop finishes."""
self.closing = True self.closing = True
@@ -149,63 +130,66 @@ class Rpc:
self.close() self.close()
def reader_loop(self) -> None: def reader_loop(self) -> None:
"""Process JSON-RPC responses from the RPC server process output."""
try: try:
while line := self.process.stdout.readline(): while True:
line = self.process.stdout.readline()
if not line: # EOF
break
response = json.loads(line) response = json.loads(line)
if "id" in response: if "id" in response:
response_id = response["id"] response_id = response["id"]
self.request_results.pop(response_id).put(response) event = self.request_events.pop(response_id)
self.request_results[response_id] = response
event.set()
else: else:
logging.warning("Got a response without ID: %s", response) logging.warning("Got a response without ID: %s", response)
except Exception: except Exception:
# Log an exception if the reader loop dies. # Log an exception if the reader loop dies.
logging.exception("Exception in the reader loop") logging.exception("Exception in the reader loop")
finally:
# Unblock any pending requests when the server closes stdout.
for _request_id, queue in self.request_results.items():
queue.put({"error": {"code": -32000, "message": "RPC server closed"}})
def writer_loop(self) -> None: def writer_loop(self) -> None:
"""Writer loop ensuring only a single thread writes requests.""" """Writer loop ensuring only a single thread writes requests."""
try: try:
while request := self.request_queue.get(): while True:
request = self.request_queue.get()
if not request:
break
data = (json.dumps(request) + "\n").encode() data = (json.dumps(request) + "\n").encode()
self.process.stdin.write(data) self.process.stdin.write(data)
self.process.stdin.flush() self.process.stdin.flush()
except Exception: except Exception:
# Log an exception if the writer loop dies. # Log an exception if the writer loop dies.
logging.exception("Exception in the writer loop") logging.exception("Exception in the writer loop")
def get_queue(self, account_id: int) -> Queue: def get_queue(self, account_id: int) -> Queue:
"""Get event queue corresponding to the given account ID."""
if account_id not in self.event_queues: if account_id not in self.event_queues:
self.event_queues[account_id] = Queue() self.event_queues[account_id] = Queue()
return self.event_queues[account_id] return self.event_queues[account_id]
def events_loop(self) -> None: def events_loop(self) -> None:
"""Request new events and distributes them between queues.""" """Requests new events and distributes them between queues."""
try: try:
while events := self.get_next_event_batch(): while True:
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)
if self.closing: if self.closing:
return 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: except Exception:
# Log an exception if the event loop dies. # Log an exception if the event loop dies.
logging.exception("Exception in the event loop") logging.exception("Exception in the event loop")
def wait_for_event(self, account_id: int) -> Optional[dict]: def wait_for_event(self, account_id: int) -> Optional[dict]:
"""Wait for the next event from the given account and returns it.""" """Waits for the next event from the given account and returns it."""
queue = self.get_queue(account_id) queue = self.get_queue(account_id)
return queue.get() return queue.get()
def clear_all_events(self, account_id: int): def clear_all_events(self, account_id: int):
"""Remove all queued-up events for a given account. Useful for tests.""" """Removes all queued-up events for a given account. Useful for tests."""
queue = self.get_queue(account_id) queue = self.get_queue(account_id)
try: try:
while True: while True:

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import imaplib import imaplib
import io import io
import logging
import pathlib import pathlib
import ssl import ssl
from contextlib import contextmanager from contextlib import contextmanager
@@ -46,13 +45,13 @@ class DirectImap:
try: try:
self.conn.logout() self.conn.logout()
except (OSError, imaplib.IMAP4.abort): except (OSError, imaplib.IMAP4.abort):
logging.warning("Could not logout direct_imap conn") print("Could not logout direct_imap conn")
def create_folder(self, foldername): def create_folder(self, foldername):
try: try:
self.conn.folder.create(foldername) self.conn.folder.create(foldername)
except errors.MailboxFolderCreateError as e: except errors.MailboxFolderCreateError as e:
logging.warning(f"Cannot create '{foldername}', probably it already exists: {str(e)}") print("Can't create", foldername, "probably it already exists:", str(e))
def select_folder(self, foldername: str) -> tuple: def select_folder(self, foldername: str) -> tuple:
assert not self._idling assert not self._idling
@@ -86,17 +85,17 @@ class DirectImap:
def get_all_messages(self) -> list[MailMessage]: def get_all_messages(self) -> list[MailMessage]:
assert not self._idling assert not self._idling
return list(self.conn.fetch(mark_seen=False)) return list(self.conn.fetch())
def get_unread_messages(self) -> list[str]: def get_unread_messages(self) -> list[str]:
assert not self._idling assert not self._idling
return [msg.uid for msg in self.conn.fetch(AND(seen=False), mark_seen=False)] return [msg.uid for msg in self.conn.fetch(AND(seen=False))]
def mark_all_read(self): def mark_all_read(self):
messages = self.get_unread_messages() messages = self.get_unread_messages()
if messages: if messages:
res = self.conn.flag(messages, MailMessageFlags.SEEN, True) res = self.conn.flag(messages, MailMessageFlags.SEEN, True)
logging.info(f"Marked seen: {messages} {res}") print("marked seen:", messages, res)
def get_unread_cnt(self) -> int: def get_unread_cnt(self) -> int:
return len(self.get_unread_messages()) return len(self.get_unread_messages())
@@ -174,6 +173,7 @@ class DirectImap:
class IdleManager: class IdleManager:
def __init__(self, direct_imap) -> None: def __init__(self, direct_imap) -> None:
self.direct_imap = direct_imap self.direct_imap = direct_imap
self.log = direct_imap.account.log
# fetch latest messages before starting idle so that it only # fetch latest messages before starting idle so that it only
# returns messages that arrive anew # returns messages that arrive anew
self.direct_imap.conn.fetch("1:*") self.direct_imap.conn.fetch("1:*")
@@ -181,11 +181,14 @@ class IdleManager:
def check(self, timeout=None) -> list[bytes]: def check(self, timeout=None) -> list[bytes]:
"""(blocking) wait for next idle message from server.""" """(blocking) wait for next idle message from server."""
return self.direct_imap.conn.idle.poll(timeout=timeout) self.log("imap-direct: calling idle_check")
res = self.direct_imap.conn.idle.poll(timeout=timeout)
self.log(f"imap-direct: idle_check returned {res!r}")
return res
def wait_for_new_message(self) -> bytes: def wait_for_new_message(self, timeout=None) -> bytes:
while True: while True:
for item in self.check(): for item in self.check(timeout=timeout):
if b"EXISTS" in item or b"RECENT" in item: if b"EXISTS" in item or b"RECENT" in item:
return item return item
@@ -193,8 +196,10 @@ class IdleManager:
"""Return first message with SEEN flag from a running idle-stream.""" """Return first message with SEEN flag from a running idle-stream."""
while True: while True:
for item in self.check(timeout=timeout): for item in self.check(timeout=timeout):
if FETCH in item and FLAGS in item and rb"\Seen" in item: if FETCH in item:
return int(item.split(b" ")[1]) self.log(str(item))
if FLAGS in item and rb"\Seen" in item:
return int(item.split(b" ")[1])
def done(self): def done(self):
"""send idle-done to server if we are currently in idle mode.""" """send idle-done to server if we are currently in idle mode."""

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