Compare commits

..

1 Commits

Author SHA1 Message Date
link2xt
a9dbf05d8d ci: update to Python 3.13 2024-06-04 16:56:16 +00:00
349 changed files with 52386 additions and 54082 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,10 +7,3 @@ updates:
commit-message:
prefix: "chore(cargo)"
open-pull-requests-limit: 50
# 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>
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -16,38 +16,41 @@ on:
branches:
- main
permissions: {}
env:
RUSTFLAGS: -Dwarnings
RUST_VERSION: 1.88.0
# Minimum Supported Rust Version
MSRV: 1.85.0
jobs:
lint_rust:
name: Lint Rust
runs-on: ubuntu-latest
env:
RUSTUP_TOOLCHAIN: 1.78.0
steps:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Install rustfmt and clippy
run: rustup toolchain install $RUST_VERSION --profile minimal --component rustfmt --component clippy
- run: rustup override set $RUST_VERSION
shell: bash
run: rustup toolchain install $RUSTUP_TOOLCHAIN --profile minimal --component rustfmt --component clippy
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@v2
- name: Run rustfmt
run: cargo fmt --all -- --check
- name: Run clippy
run: scripts/clippy.sh
- name: Check with all features
- name: Check
run: cargo check --workspace --all-targets --all-features
- name: Check with only default features
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:
name: cargo deny
@@ -56,8 +59,7 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: EmbarkStudios/cargo-deny-action@v2
- uses: EmbarkStudios/cargo-deny-action@v1
with:
arguments: --all-features --workspace
command: check
@@ -70,7 +72,6 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Check provider database
run: scripts/update-provider-database.sh
@@ -83,7 +84,6 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@v2
- name: Rustdoc
@@ -95,36 +95,24 @@ jobs:
matrix:
include:
- os: ubuntu-latest
rust: latest
rust: 1.78.0
- os: windows-latest
rust: latest
rust: 1.78.0
- os: macos-latest
rust: latest
rust: 1.78.0
# Minimum Supported Rust Version
# Minimum Supported Rust Version = 1.77.0
- os: ubuntu-latest
rust: minimum
rust: 1.77.0
runs-on: ${{ matrix.os }}
steps:
- run:
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@v4
with:
show-progress: false
persist-credentials: false
- name: Install Rust ${{ matrix.rust }}
run: rustup toolchain install --profile minimal $RUSTUP_TOOLCHAIN
shell: bash
- run: rustup override set $RUSTUP_TOOLCHAIN
shell: bash
run: rustup toolchain install --profile minimal ${{ matrix.rust }}
- run: rustup override set ${{ matrix.rust }}
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@v2
@@ -157,13 +145,12 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@v2
- name: Build C library
run: cargo build -p deltachat_ffi
run: cargo build -p deltachat_ffi --features jsonrpc
- name: Upload C library
uses: actions/upload-artifact@v4
@@ -182,7 +169,6 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Cache rust cargo artifacts
uses: swatinem/rust-cache@v2
@@ -204,7 +190,6 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Install tox
run: pip install tox
@@ -224,7 +209,7 @@ jobs:
fail-fast: false
matrix:
include:
# Currently used Rust version.
# Currently used Python version.
- os: ubuntu-latest
python: 3.13
- os: macos-latest
@@ -236,18 +221,17 @@ jobs:
- os: macos-latest
python: pypy3.10
# Minimum Supported Python Version = 3.8
# Minimum Supported Python Version = 3.7
# This is the minimum version for which manylinux Python wheels are
# built. Test it with minimum supported Rust version.
- os: ubuntu-latest
python: 3.8
python: 3.7
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Download libdeltachat.a
uses: actions/download-artifact@v4
@@ -259,13 +243,14 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
allow-prereleases: true
- name: Install tox
run: pip install tox
- name: Run python tests
env:
CHATMAIL_DOMAIN: ${{ vars.CHATMAIL_DOMAIN }}
CHATMAIL_DOMAIN: ${{ secrets.CHATMAIL_DOMAIN }}
DCC_RS_TARGET: debug
DCC_RS_DEV: ${{ github.workspace }}
working-directory: python
@@ -291,21 +276,21 @@ jobs:
- os: macos-latest
python: pypy3.10
# Minimum Supported Python Version = 3.8
# Minimum Supported Python Version = 3.7
- os: ubuntu-latest
python: 3.8
python: 3.7
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Install python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
allow-prereleases: true
- name: Install tox
run: pip install tox
@@ -331,6 +316,6 @@ jobs:
- name: Run deltachat-rpc-client tests
env:
CHATMAIL_DOMAIN: ${{ vars.CHATMAIL_DOMAIN }}
CHATMAIL_DOMAIN: ${{ secrets.CHATMAIL_DOMAIN }}
working-directory: deltachat-rpc-client
run: tox -e py

View File

@@ -17,8 +17,6 @@ on:
release:
types: [published]
permissions: {}
jobs:
# Build a version statically linked against musl libc
# to avoid problems with glibc version incompatibility.
@@ -33,8 +31,8 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-linux
@@ -57,8 +55,8 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}
@@ -82,7 +80,6 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Setup rust target
run: rustup target add ${{ matrix.arch }}-apple-darwin
@@ -108,8 +105,8 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build deltachat-rpc-server binaries
run: nix build .#deltachat-rpc-server-${{ matrix.arch }}-android
@@ -135,8 +132,8 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Download Linux aarch64 binary
uses: actions/download-artifact@v4
@@ -248,10 +245,6 @@ jobs:
cp result/*.whl dist/
nix build .#deltachat-rpc-server-win32-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-arm64-v8a-android-wheel
cp result/*.whl dist/
nix build .#deltachat-rpc-server-armeabi-v7a-android-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
@@ -265,9 +258,8 @@ jobs:
if: github.event_name == 'release'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
REF_NAME: ${{ github.ref_name }}
run: |
gh release upload "$REF_NAME" \
gh release upload ${{ github.ref_name }} \
--repo ${{ github.repository }} \
bin/* dist/*
@@ -288,7 +280,6 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: "3.11"
@@ -394,9 +385,8 @@ jobs:
if: github.event_name == 'release'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
REF_NAME: ${{ github.ref_name }}
run: |
gh release upload "$REF_NAME" \
gh release upload ${{ github.ref_name }} \
--repo ${{ github.repository }} \
deltachat-rpc-server/npm-package/*.tgz

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2.4.0
uses: dependabot/fetch-metadata@v1.1.1
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Approve a PR

View File

@@ -4,12 +4,10 @@ on:
release:
types: [published]
permissions: {}
jobs:
pack-module:
name: "Publish @deltachat/jsonrpc-client"
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
permissions:
id-token: write
contents: read
@@ -17,7 +15,6 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: actions/setup-node@v4
with:

View File

@@ -6,8 +6,6 @@ on:
pull_request:
branches: [main]
permissions: {}
env:
CARGO_TERM_COLOR: always
RUST_MIN_STACK: "8388608"
@@ -19,7 +17,6 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Use Node.js 18.x
uses: actions/setup-node@v4
with:
@@ -36,7 +33,10 @@ jobs:
working-directory: deltachat-jsonrpc/typescript
run: npm run test
env:
CHATMAIL_DOMAIN: ${{ vars.CHATMAIL_DOMAIN }}
CHATMAIL_DOMAIN: ${{ secrets.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
working-directory: deltachat-jsonrpc/typescript
run: npm run prettier:check

View File

@@ -1,109 +0,0 @@
name: Test Nix flake
on:
pull_request:
paths:
- flake.nix
- flake.lock
push:
paths:
- flake.nix
- flake.lock
branches:
- main
permissions: {}
jobs:
format:
name: check flake formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@main
- run: nix fmt
# Check that formatting does not change anything.
- run: git diff --exit-code
build:
name: nix build
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
installable:
# Ensure `nix develop` will work.
- devShells.x86_64-linux.default
- deltachat-python
- deltachat-repl
- deltachat-repl-aarch64-linux
- deltachat-repl-arm64-v8a-android
- deltachat-repl-armeabi-v7a-android
- deltachat-repl-armv6l-linux
- deltachat-repl-armv7l-linux
- deltachat-repl-i686-linux
- deltachat-repl-win32
- deltachat-repl-win64
- deltachat-repl-x86_64-linux
- deltachat-rpc-client
- deltachat-rpc-server
- deltachat-rpc-server-aarch64-linux
- deltachat-rpc-server-aarch64-linux-wheel
- 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-wheel
- deltachat-rpc-server-armv6l-linux
- deltachat-rpc-server-armv6l-linux-wheel
- deltachat-rpc-server-armv7l-linux
- deltachat-rpc-server-armv7l-linux-wheel
- deltachat-rpc-server-i686-linux
- deltachat-rpc-server-i686-linux-wheel
- deltachat-rpc-server-source
- deltachat-rpc-server-win32
- deltachat-rpc-server-win32-wheel
- deltachat-rpc-server-win64
- deltachat-rpc-server-win64-wheel
- deltachat-rpc-server-x86_64-linux
- deltachat-rpc-server-x86_64-linux-wheel
- docs
- libdeltachat
- python-docs
# Fails to build
#- deltachat-repl-x86_64-android
#- deltachat-repl-x86-android
#- deltachat-rpc-server-x86_64-android
#- deltachat-rpc-server-x86-android
steps:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@main
- run: nix build .#${{ matrix.installable }}
build-macos:
name: nix build on macOS
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
installable:
- deltachat-rpc-server
# Fails to bulid
# - deltachat-rpc-server-aarch64-darwin
# - deltachat-rpc-server-x86_64-darwin
steps:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@main
- 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@v1.0.1
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: ${{ secrets.CHATMAIL_DOMAIN }}
NODE_OPTIONS: "--force-node-api-uncaught-exceptions-policy=true"

View File

@@ -5,8 +5,6 @@ on:
release:
types: [published]
permissions: {}
jobs:
build:
name: Build distribution
@@ -16,7 +14,6 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Install pypa/build
run: python3 -m pip install build
- name: Build a binary wheel and a source tarball

View File

@@ -7,8 +7,6 @@ name: Build Windows REPL .exe
on:
workflow_dispatch:
permissions: {}
jobs:
build_repl:
name: Build REPL example
@@ -17,8 +15,8 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build
run: nix build .#deltachat-repl-win64
- name: Upload binary

View File

@@ -6,8 +6,6 @@ on:
- main
- build_jsonrpc_docs_ci
permissions: {}
jobs:
build-rs:
runs-on: ubuntu-latest
@@ -16,7 +14,6 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Build the documentation with cargo
run: |
cargo doc --package deltachat --no-deps --document-private-items
@@ -34,9 +31,9 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build Python documentation
run: nix build .#python-docs
- name: Upload to py.delta.chat
@@ -53,9 +50,9 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Build C documentation
run: nix build .#docs
- name: Upload to c.delta.chat
@@ -75,10 +72,9 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
fetch-depth: 0 # Fetch history to calculate VCS version number.
- name: Use Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: '18'
- name: npm install

View File

@@ -9,8 +9,6 @@ on:
branches:
- main
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
@@ -19,7 +17,6 @@ jobs:
- uses: actions/checkout@v4
with:
show-progress: false
persist-credentials: false
- name: Build the documentation with cargo
run: |
cargo doc --package deltachat_ffi --no-deps

View File

@@ -1,31 +0,0 @@
name: GitHub Actions Security Analysis with zizmor
on:
push:
branches: ["main"]
pull_request:
branches: ["**"]
jobs:
zizmor:
name: zizmor latest via PyPI
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v6
- name: Run zizmor
run: uvx zizmor --format sarif . > results.sarif
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
category: zizmor

5
.gitignore vendored
View File

@@ -1,9 +1,7 @@
target/
/target
**/*.rs.bk
/build
/dist
/fuzz/fuzz_targets/corpus/
/fuzz/fuzz_targets/crashes/
# ignore vi temporaries
*~
@@ -53,4 +51,3 @@ result
# direnv
.envrc
.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}
LIBDIR=${CMAKE_INSTALL_FULL_LIBDIR}
INCLUDEDIR=${CMAKE_INSTALL_FULL_INCLUDEDIR}
${CARGO} build --target-dir=${CMAKE_BINARY_DIR}/target --release
${CARGO} build --target-dir=${CMAKE_BINARY_DIR}/target --release --no-default-features --features jsonrpc
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/deltachat-ffi
)

View File

@@ -1,122 +1,126 @@
# Contributing to Delta Chat
# Contributing guidelines
## Bug reports
## Reporting bugs
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
[Android](https://github.com/deltachat/deltachat-android/issues),
[iOS](https://github.com/deltachat/deltachat-ios/issues) or
[Desktop](https://github.com/deltachat/deltachat-desktop/issues),
report it to the corresponding repository.
## Feature proposals
## Proposing features
If you have a feature request, create a new topic on the [forum](https://support.delta.chat/).
## Code contributions
## Contributing code
If you want to contribute a code, follow this guide.
If you want to contribute a code, [open a Pull Request](https://github.com/deltachat/deltachat-core-rust/pulls).
1. **Select an issue to work on.**
If you have write access to the repository,
push a branch named `<username>/<feature>`
so it is clear who is responsible for the branch,
and open a PR proposing to merge the change.
Otherwise fork the repository and create a branch in your fork.
If you have an write access to the repository, assign the issue to yourself.
Otherwise state in the comment that you are going to work on the issue
to avoid duplicate work.
You can find the list of good first issues
and a link to this guide
on the contributing page: <https://github.com/deltachat/deltachat-core-rust/contribute>
If the issue does not exist yet, create it first.
### Coding conventions
2. **Write the code.**
We format the code using `rustfmt`. Run `cargo fmt` prior to committing the code.
Run `scripts/clippy.sh` to check the code for common mistakes with [Clippy].
Follow the [coding conventions](STYLE.md) when writing the code.
Commit messages follow the [Conventional Commits] notation.
We use [git-cliff] to generate the changelog from commit messages before the release.
3. **Commit the code.**
With **`git cliff --unreleased`**, you can check how the changelog entry for your commit will look.
If you have write access to the repository,
push a branch named `<username>/<feature>`
so it is clear who is responsible for the branch,
and open a PR proposing to merge the change.
Otherwise fork the repository and create a branch in your fork.
The following prefix types are used:
- `feat`: Features, e.g. "feat: Pause IO for BackupProvider". If you are unsure what's the category of your commit, you can often just use `feat`.
- `fix`: Bug fixes, e.g. "fix: delete `smtp` rows when message sending is cancelled"
- `api`: API changes, e.g. "api(rust): add `get_msg_read_receipts(context, msg_id)`"
- `refactor`: Refactorings, e.g. "refactor: iterate over `msg_ids` without `.iter()`"
- `perf`: Performance improvements, e.g. "perf: improve SQLite performance with `PRAGMA synchronous=normal`"
- `test`: Test changes and improvements to the testing framework.
- `build`: Build system and tool configuration changes, e.g. "build(git-cliff): put "ci" commits into "CI" section of changelog"
- `ci`: CI configuration changes, e.g. "ci: limit artifact retention time for `libdeltachat.a` to 1 day"
- `docs`: Documentation changes, e.g. "docs: add contributing guidelines"
- `chore`: miscellaneous tasks, e.g. "chore: add `.DS_Store` to `.gitignore`"
Commit messages follow the [Conventional Commits] notation.
We use [git-cliff] to generate the changelog from commit messages before the release.
Release preparation commits are marked as "chore(release): prepare for vX.Y.Z".
With **`git cliff --unreleased`**, you can check how the changelog entry for your commit will look.
If you intend to squash merge the PR from the web interface,
make sure the PR title follows the conventional commits notation
as it will end up being a commit title.
Otherwise make sure each commit title follows the conventional commit notation.
The following prefix types are used:
- `feat`: Features, e.g. "feat: Pause IO for BackupProvider". If you are unsure what's the category of your commit, you can often just use `feat`.
- `fix`: Bug fixes, e.g. "fix: delete `smtp` rows when message sending is cancelled"
- `api`: API changes, e.g. "api(rust): add `get_msg_read_receipts(context, msg_id)`"
- `refactor`: Refactorings, e.g. "refactor: iterate over `msg_ids` without `.iter()`"
- `perf`: Performance improvements, e.g. "perf: improve SQLite performance with `PRAGMA synchronous=normal`"
- `test`: Test changes and improvements to the testing framework.
- `build`: Build system and tool configuration changes, e.g. "build(git-cliff): put "ci" commits into "CI" section of changelog"
- `ci`: CI configuration changes, e.g. "ci: limit artifact retention time for `libdeltachat.a` to 1 day"
- `docs`: Documentation changes, e.g. "docs: add contributing guidelines"
- `chore`: miscellaneous tasks, e.g. "chore: add `.DS_Store` to `.gitignore`"
#### Breaking Changes
Release preparation commits are marked as "chore(release): prepare for X.Y.Z"
as described in [releasing guide](RELEASE.md).
Use a `!` to mark breaking changes, e.g. "api!: Remove `dc_chat_can_send`".
Use a `!` to mark breaking changes, e.g. "api!: Remove `dc_chat_can_send`".
Alternatively, breaking changes can go into the commit description, e.g.:
Alternatively, breaking changes can go into the commit description, e.g.:
```
fix: Fix race condition and db corruption when a message was received during backup
```
fix: Fix race condition and db corruption when a message was received during backup
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)`
```
#### Multiple Changes in one PR
4. [**Open a Pull Request**](https://github.com/chatmail/core/pulls).
If you have multiple changes in one PR, create multiple conventional commits, and then do a rebase merge. Otherwise, you should usually do a squash merge.
Refer to the corresponding issue.
[Clippy]: https://doc.rust-lang.org/clippy/
[Conventional Commits]: https://www.conventionalcommits.org/
[git-cliff]: https://git-cliff.org/
If you intend to squash merge the PR from the web interface,
make sure the PR title follows the conventional commits notation
as it will end up being a commit title.
Otherwise make sure each commit title follows the conventional commit notation.
### Errors
5. **Make sure all CI checks succeed.**
Delta Chat core mostly uses [`anyhow`](https://docs.rs/anyhow/) errors.
When using [`Context`](https://docs.rs/anyhow/latest/anyhow/trait.Context.html),
capitalize it but do not add a full stop as the contexts will be separated by `:`.
For example:
```
.with_context(|| format!("Unable to trash message {msg_id}"))
```
CI runs the tests and checks code formatting.
All errors should be handled in one of these ways:
- With `if let Err() =` (incl. logging them into `warn!()`/`err!()`).
- With `.log_err().ok()`.
- Bubbled up with `?`.
While it is running, self-review your PR to make sure all the changes you expect are there
and there are no accidentally committed unrelated changes and files.
`backtrace` feature is enabled for `anyhow` crate
and `debug = 1` option is set in the test profile.
This allows to run `RUST_BACKTRACE=1 cargo test`
and get a backtrace with line numbers in resultified tests
which return `anyhow::Result`.
Push the necessary fixup commits or force-push to your branch if needed.
### Logging
6. **Ask for review.**
For logging, use `info!`, `warn!` and `error!` macros.
Log messages should be capitalized and have a full stop in the end. For example:
```
info!(context, "Ignoring addition of {added_addr:?} to {chat_id}.");
```
Use built-in GitHub feature to request a review from suggested reviewers.
Format anyhow errors with `{:#}` to print all the contexts like this:
```
error!(context, "Failed to set selfavatar timestamp: {err:#}.");
```
If you do not have write access to the repository, ask for review in the comments.
### Reviewing
7. **Merge the PR.**
Once a PR has an approval and passes CI, it can be merged.
Once a PR has an approval and passes CI, it can be merged.
PRs from a branch created in the main repository, i.e. authored by those who have write access, are merged by their authors.
This is to ensure that PRs are merged as intended by the author,
e.g. as a squash merge, by rebasing from the web interface or manually from the command line.
PRs from a branch created in the main repository,
i.e. authored by those who have write access, are merged by their authors.
This is to ensure that PRs are merged as intended by the author,
e.g. as a squash merge, by rebasing from the web interface or manually from the command line.
If you have multiple changes in one PR, do a rebase merge.
Otherwise, you should usually do a squash merge.
If PR author does not have write access to the repository,
maintainers who reviewed the PR can merge it.
If you do not have access to the repository and created a PR from a fork,
ask the maintainers to merge the PR and say how it should be merged.
If you do not have access to the repository and created a PR from a fork,
ask the maintainers to merge the PR and say how it should be merged.
## Other ways to contribute
For other ways to contribute, refer to the [website](https://delta.chat/en/contribute).
You can find the list of good first issues
and a link to this guide
on the contributing page: <https://github.com/chatmail/core/contribute>
[Conventional Commits]: https://www.conventionalcommits.org/
[git-cliff]: https://git-cliff.org/

5640
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
[package]
name = "deltachat"
version = "2.4.0"
edition = "2024"
version = "1.140.0"
edition = "2021"
license = "MPL-2.0"
rust-version = "1.85"
repository = "https://github.com/chatmail/core"
rust-version = "1.77"
repository = "https://github.com/deltachat/deltachat-core-rust"
[profile.dev]
debug = 0
@@ -18,9 +18,6 @@ opt-level = 1
debug = 1
opt-level = 0
[profile.fuzz]
inherits = "test"
# Always optimize dependencies.
# This does not apply to crates in the workspace.
# <https://doc.rust-lang.org/cargo/reference/profiles.html#overrides>
@@ -37,94 +34,87 @@ strip = true
[dependencies]
deltachat_derive = { path = "./deltachat_derive" }
deltachat-time = { path = "./deltachat-time" }
deltachat-contact-tools = { workspace = true }
deltachat-contact-tools = { path = "./deltachat-contact-tools" }
format-flowed = { path = "./format-flowed" }
ratelimit = { path = "./deltachat-ratelimit" }
anyhow = { workspace = true }
async-broadcast = "0.7.2"
async-channel = { workspace = true }
async-imap = { version = "0.11.0", default-features = false, features = ["runtime-tokio", "compress"] }
async-broadcast = "0.7.0"
async-channel = "2.2.1"
async-imap = { version = "0.9.7", 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_zip = { version = "0.0.17", default-features = false, features = ["deflate", "tokio-fs"] }
base64 = { workspace = true }
brotli = { version = "8", default-features=false, features = ["std"] }
bytes = "1"
chrono = { workspace = true, features = ["alloc", "clock", "std"] }
data-encoding = "2.9.0"
async-smtp = { version = "0.9", default-features = false, features = ["runtime-tokio"] }
async_zip = { version = "0.0.12", default-features = false, features = ["deflate", "fs"] }
backtrace = "0.3"
base64 = "0.22"
brotli = { version = "6", default-features=false, features = ["std"] }
chrono = { workspace = true }
email = { git = "https://github.com/deltachat/rust-email", branch = "master" }
encoded-words = { git = "https://github.com/async-email/encoded-words", branch = "master" }
escaper = "0.1"
fast-socks5 = "0.10"
fast-socks5 = "0.9"
fd-lock = "4"
futures-lite = { workspace = true }
futures = { workspace = true }
futures = "0.3"
futures-lite = "2.3.0"
hex = "0.4.0"
hickory-resolver = "0.25.2"
http-body-util = "0.1.3"
hickory-resolver = "0.24"
humansize = "2"
hyper = "1"
hyper-util = "0.1.14"
image = { version = "0.25.6", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
iroh-gossip = { version = "0.35", default-features = false, features = ["net"] }
iroh = { version = "0.35", default-features = false }
kamadak-exif = "0.6.1"
libc = { workspace = true }
mail-builder = { version = "0.4.3", default-features = false }
mailparse = { workspace = true }
image = { version = "0.25.1", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
iroh_old = { version = "0.4.2", default-features = false, package = "iroh"}
iroh-net = "0.17.0"
iroh-gossip = { version = "0.17.0", features = ["net"] }
quinn = "0.10.0"
kamadak-exif = "0.5.3"
lettre_email = { git = "https://github.com/deltachat/lettre", branch = "master" }
libc = "0.2"
mailparse = "0.15"
mime = "0.3.17"
num_cpus = "1.17"
num_cpus = "1.16"
num-derive = "0.4"
num-traits = { workspace = true }
parking_lot = "0.12.4"
num-traits = "0.2"
once_cell = { workspace = true }
percent-encoding = "2.3"
pgp = { version = "0.16.0", default-features = false }
pin-project = "1"
parking_lot = "0.12"
pgp = { version = "0.11", default-features = false }
qrcodegen = "1.7.0"
quick-xml = "0.37"
quick-xml = "0.31"
quoted_printable = "0.5"
rand = { workspace = true }
rand = "0.8"
regex = { workspace = true }
reqwest = { version = "0.11.27", features = ["json"] }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rust-hsluv = "0.1"
rustls-pki-types = "1.12.0"
rustls = { version = "0.23.22", default-features = false }
sanitize-filename = { workspace = true }
serde_json = { workspace = true }
serde_urlencoded = "0.7.1"
serde = { workspace = true, features = ["derive"] }
sanitize-filename = "0.5"
serde_json = "1"
serde = { version = "1.0", features = ["derive"] }
sha-1 = "0.10"
sha2 = "0.10"
shadowsocks = { version = "1.23.1", default-features = false, features = ["aead-cipher", "aead-cipher-2022"] }
smallvec = "1.15.1"
strum = "0.27"
strum_macros = "0.27"
smallvec = "1.13.2"
strum = "0.26"
strum_macros = "0.26"
tagger = "4.3.4"
textwrap = "0.16.2"
thiserror = { workspace = true }
tokio-io-timeout = "1.2.1"
tokio-rustls = { version = "0.26.2", default-features = false }
tokio-stream = { version = "0.1.17", features = ["fs"] }
textwrap = "0.16.1"
thiserror = "1"
tokio = { version = "1.37.0", features = ["fs", "rt-multi-thread", "macros"] }
tokio-io-timeout = "1.2.0"
tokio-stream = { version = "0.1.15", features = ["fs"] }
tokio-tar = { version = "0.3" } # TODO: integrate tokio into async-tar
tokio-util = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
tokio-util = "0.7.9"
toml = "0.8"
tracing = "0.1.41"
url = "2"
uuid = { version = "1", features = ["serde", "v4"] }
webpki-roots = "0.26.8"
blake3 = "1.8.2"
[dev-dependencies]
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.
criterion = { version = "0.6.0", features = ["async_tokio"] }
futures-lite = { workspace = true }
log = { workspace = true }
nu-ansi-term = { workspace = true }
pretty_assertions = "1.4.1"
ansi_term = "0.12.0"
anyhow = { version = "1", features = ["backtrace"] } # Enable `backtrace` feature in tests.
criterion = { version = "0.5.1", features = ["async_tokio"] }
futures-lite = "2.3.0"
log = "0.4"
proptest = { version = "1", default-features = false, features = ["std"] }
tempfile = { workspace = true }
testdir = "0.9.3"
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tempfile = "3"
testdir = "0.9.0"
tokio = { version = "1.37.0", features = ["parking_lot", "rt-multi-thread", "macros"] }
pretty_assertions = "1.3.0"
[workspace]
members = [
@@ -137,7 +127,6 @@ members = [
"deltachat-time",
"format-flowed",
"deltachat-contact-tools",
"fuzz",
]
[[bench]]
@@ -154,17 +143,12 @@ harness = false
[[bench]]
name = "receive_emails"
required-features = ["internals"]
harness = false
[[bench]]
name = "get_chat_msgs"
harness = false
[[bench]]
name = "marknoticed_chat"
harness = false
[[bench]]
name = "get_chatlist"
harness = false
@@ -175,39 +159,16 @@ harness = false
[workspace.dependencies]
anyhow = "1"
async-channel = "2.3.1"
base64 = "0.22"
chrono = { version = "0.4.41", default-features = false }
deltachat-contact-tools = { path = "deltachat-contact-tools" }
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
deltachat = { path = ".", default-features = false }
futures = "0.3.31"
futures-lite = "2.6.0"
libc = "0.2"
log = "0.4"
mailparse = "0.16.1"
nu-ansi-term = "0.46"
num-traits = "0.2"
rand = "0.8"
once_cell = "1.18.0"
regex = "1.10"
rusqlite = "0.36"
sanitize-filename = "0.5"
serde = "1.0"
serde_json = "1"
tempfile = "3.20.0"
thiserror = "2"
tokio = "1"
tokio-util = "0.7.14"
tracing-subscriber = "0.3"
yerpc = "0.6.4"
rusqlite = "0.31"
chrono = { version = "0.4.38", default-features=false, features = ["alloc", "clock", "std"] }
[features]
default = ["vendored"]
internals = []
vendored = [
"async-native-tls/vendored",
"rusqlite/bundled-sqlcipher-vendored-openssl",
"async-native-tls/vendored"
"reqwest/native-tls-vendored"
]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(fuzzing)'] }

View File

@@ -1,41 +1,19 @@
<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 align="center">
<a href="https://github.com/chatmail/core/actions/workflows/ci.yml">
<img alt="Rust CI" src="https://github.com/chatmail/core/actions/workflows/ci.yml/badge.svg">
<a href="https://github.com/deltachat/deltachat-core-rust/actions/workflows/ci.yml">
<img alt="Rust CI" src="https://github.com/deltachat/deltachat-core-rust/actions/workflows/ci.yml/badge.svg">
</a>
<a href="https://deps.rs/repo/github/chatmail/core">
<img alt="dependency status" src="https://deps.rs/repo/github/chatmail/core/status.svg">
<a href="https://deps.rs/repo/github/deltachat/deltachat-core-rust">
<img alt="dependency status" src="https://deps.rs/repo/github/deltachat/deltachat-core-rust/status.svg">
</a>
</p>
The chatmail core library implements low-level network and encryption protocols,
integrated by many chat bots and higher level applications,
allowing to securely participate in the globally scaled e-mail server network.
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).
<p align="center">
The core library for Delta Chat, written in Rust
</p>
## Installing Rust and Cargo
@@ -49,26 +27,26 @@ $ curl https://sh.rustup.rs -sSf | sh
## 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 -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
```
$ cargo install --locked --path deltachat-repl/
$ cargo install --path deltachat-repl/
```
and run as
```
$ deltachat-repl ~/profile-db
$ deltachat-repl ~/deltachat-db
```
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 mail_pw yourpassword
> configure
@@ -84,31 +62,33 @@ Create a contact:
```
> addcontact yourfriends@email.org
Command executed successfully.
```
List contacts:
```
> listcontacts
Contact#Contact#Self: Me √ <your@email.org>
1 key contacts.
Contact#Contact#10: yourfriends@email.org <yourfriends@email.org>
1 address contacts.
Contact#10: <name unset> <yourfriends@email.org>
Contact#1: Me √√ <your@email.org>
```
Create a chat with your friend and send a message:
```
> createchat 10
Single#Chat#12 created successfully.
> chat 12
Selecting chat Chat#12
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.
Single#10 created successfully.
> chat 10
Single#10: yourfriends@email.org [yourfriends@email.org]
> 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:
```
@@ -124,7 +104,7 @@ For more commands type:
## 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
$ cmake -B build . -DCMAKE_INSTALL_PREFIX=/usr
$ cmake --build build
@@ -165,7 +145,7 @@ $ cargo install cargo-bolero
Run fuzzing tests with
```sh
$ 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`,
@@ -173,23 +153,33 @@ you can add initial inputs there.
For `fuzz_mailparse` target corpus can be populated with
`../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
- `vendored`: When using Openssl for TLS, this bundles a vendored version.
- `nightly`: Enable nightly only performance and security related features.
## Update Provider Data
To add the updates from the
[provider-db](https://github.com/chatmail/provider-db) to the core,
check line `REV=` inside `./scripts/update-provider-database.sh`
and then run the script.
[provider-db](https://github.com/deltachat/provider-db) to the core, run:
```
./src/provider/update.py ../provider-db/_providers/ > src/provider/data.rs
```
## Language bindings and frontend projects
Language bindings are available for:
- **C** \[[📂 source](./deltachat-ffi) | [📚 docs](https://c.delta.chat)\]
- **JS**: \[[📂 source](./deltachat-rpc-client) | [📦 npm](https://www.npmjs.com/package/@deltachat/jsonrpc-client) | [📚 docs](https://js.jsonrpc.delta.chat/)\]
- **Node.js**
- over cffi: \[[📂 source](./node) | [📦 npm](https://www.npmjs.com/package/deltachat-node) | [📚 docs](https://js.delta.chat)\]
- over jsonrpc built with napi.rs (experimental): \[[📂 source](https://github.com/deltachat/napi-jsonrpc) | [📦 npm](https://www.npmjs.com/package/@deltachat/napi-jsonrpc)\]
- **Python** \[[📂 source](./python) | [📦 pypi](https://pypi.org/project/deltachat) | [📚 docs](https://py.delta.chat)\]
- **Go**
- over jsonrpc: \[[📂 source](https://github.com/deltachat/deltachat-rpc-client-go/)\]

View File

@@ -2,20 +2,20 @@
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`.
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`.
5. Commit the changes as `chore(release): prepare for 1.116.0`.
Optionally, use a separate branch like `prep-1.116.0` for this commit and open a PR for review.
6. Tag the release: `git tag --annotate v1.116.0`.
6. Tag the release: `git tag -a v1.116.0`.
7. Push the release tag: `git push origin v1.116.0`.
8. Create a GitHub release: `gh release create v1.116.0 --notes ''`.
8. Create a GitHub release: `gh release create v1.116.0 -n ''`.

119
STYLE.md
View File

@@ -1,119 +0,0 @@
# Coding conventions
We format the code using `rustfmt`. Run `cargo fmt` prior to committing the code.
Run `scripts/clippy.sh` to check the code for common mistakes with [Clippy].
[Clippy]: https://doc.rust-lang.org/clippy/
## SQL
Multi-line SQL statements should be formatted using string literals,
for example
```
sql.execute(
"CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT DEFAULT '' NOT NULL -- message text
) STRICT",
)
.await?;
```
Do not use macros like [`concat!`](https://doc.rust-lang.org/std/macro.concat.html)
or [`indoc!](https://docs.rs/indoc).
Do not escape newlines like this:
```
sql.execute(
"CREATE TABLE messages ( \
id INTEGER PRIMARY KEY AUTOINCREMENT, \
text TEXT DEFAULT '' NOT NULL \
) STRICT",
)
.await?;
```
Escaping newlines
is prone to errors like this if space before backslash is missing:
```
"SELECT foo\
FROM bar"
```
Literal above results in `SELECT fooFROM bar` string.
This style also does not allow using `--` comments.
---
Declare new SQL tables with [`STRICT`](https://sqlite.org/stricttables.html) keyword
to make SQLite check column types.
Declare primary keys with [`AUTOINCREMENT`](https://www.sqlite.org/autoinc.html) keyword.
This avoids reuse of the row IDs and can avoid dangerous bugs
like forwarding wrong message because the message was deleted
and another message took its row ID.
Declare all new columns as `NOT NULL`
and set the `DEFAULT` value if it is optional so the column can be skipped in `INSERT` statements.
Dealing with `NULL` values both in SQL and in Rust is tricky and we try to avoid it.
If column is already declared without `NOT NULL`, use `IFNULL` function to provide default value when selecting it.
Use `HAVING COUNT(*) > 0` clause
to [prevent aggregate functions such as `MIN` and `MAX` from returning `NULL`](https://stackoverflow.com/questions/66527856/aggregate-functions-max-etc-return-null-instead-of-no-rows).
Don't delete unused columns too early, but maybe after several months/releases, unused columns are
still used by older versions, so deleting them breaks downgrading the core or importing a backup in
an older version. Also don't change the column type, consider adding a new column with another name
instead. Finally, never change column semantics, this is especially dangerous because the `STRICT`
keyword doesn't help here.
## Errors
Delta Chat core mostly uses [`anyhow`](https://docs.rs/anyhow/) errors.
When using [`Context`](https://docs.rs/anyhow/latest/anyhow/trait.Context.html),
capitalize it but do not add a full stop as the contexts will be separated by `:`.
For example:
```
.with_context(|| format!("Unable to trash message {msg_id}"))
```
All errors should be handled in one of these ways:
- With `if let Err() =` (incl. logging them into `warn!()`/`err!()`).
- With `.log_err().ok()`.
- Bubbled up with `?`.
`backtrace` feature is enabled for `anyhow` crate
and `debug = 1` option is set in the test profile.
This allows to run `RUST_BACKTRACE=1 cargo test`
and get a backtrace with line numbers in resultified tests
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.
## Logging
For logging, use `info!`, `warn!` and `error!` macros.
Log messages should be capitalized and have a full stop in the end. For example:
```
info!(context, "Ignoring addition of {added_addr:?} to {chat_id}.");
```
Format anyhow errors with `{:#}` to print all the contexts like this:
```
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>

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,12 +0,0 @@
<path
style="fill:#ffffff;fill-opacity:1;stroke:none"
d="m 24.015419,1.2870249 c -12.549421,0 -22.7283936,10.1789711 -22.7283936,22.7283931 0,12.549422 10.1789726,22.728395 22.7283936,22.728395 14.337742,-0.342877 9.614352,-4.702705 23.697556,0.969161 -7.545453,-13.001555 -1.082973,-13.32964 -0.969161,-23.697556 0,-12.549422 -10.178973,-22.7283931 -22.728395,-22.7283931 z" />
<path
style="fill:#000000;fill-opacity:1;stroke:none"
d="M 23.982249,5.3106163 C 13.645822,5.4364005 5.2618355,13.92999 5.2618355,24.275753 c 0,10.345764 8.3839865,18.635301 18.7204135,18.509516 9.827724,-0.03951 7.516769,-5.489695 18.380082,-0.443187 -5.950849,-9.296115 0.201753,-10.533667 0.340336,-18.521947 0,-10.345766 -8.383989,-18.6353031 -18.720418,-18.5095187 z" />
<g
style="fill:#ffffff"
transform="scale(1.1342891,0.88160947)">
<path
d="m 21.360141,23.513382 q -1.218487,-1.364705 -3.387392,-3.265543 -2.388233,-2.095797 -3.216804,-3.289913 -0.828571,-1.218486 -0.828571,-2.6563 0,-2.144536 1.998318,-3.363022 1.998317,-1.2428565 5.215121,-1.2428565 3.216804,0 5.605037,1.0966375 2.412603,1.096638 2.412603,3.021846 0,0.92605 -0.584873,1.535293 -0.584874,0.609243 -1.364705,0.609243 -1.121008,0 -2.631931,-1.681511 -1.535292,-1.705881 -2.60756,-2.388233 -1.047898,-0.706722 -2.461343,-0.706722 -1.803359,0 -2.973106,0.804201 -1.145377,0.804201 -1.145377,2.047057 0,1.169747 0.950419,2.193275 0.950419,1.023529 4.898315,3.728568 4.215963,2.899998 5.946213,4.532769 1.75462,1.632772 2.851258,3.972265 1.096638,2.339494 1.096638,4.947055 0,4.581508 -3.241174,8.090749 -3.216804,3.484871 -7.530245,3.484871 -3.923526,0 -6.628566,-2.802519 -2.705039,-2.802518 -2.705039,-7.481506 0,-4.508399 2.973106,-7.530245 2.997477,-3.021846 7.359658,-3.655459 z m 1.072268,1.121008 q -6.994112,1.145377 -6.994112,9.601672 0,4.36218 1.730251,6.774783 1.75462,2.412603 4.069744,2.412603 2.412603,0 3.972265,-2.315124 1.559663,-2.339493 1.559663,-6.311759 0,-5.751255 -4.337811,-10.162175 z" />
</g>

Binary file not shown.

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"]
use std::hint::black_box;
use criterion::{Criterion, criterion_group, criterion_main};
use deltachat::Events;
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use deltachat::contact::Contact;
use deltachat::context::Context;
use deltachat::stock_str::StockStrings;
use deltachat::Events;
use tempfile::tempdir;
async fn address_book_benchmark(n: u32, read_count: u32) {

View File

@@ -1,8 +1,7 @@
#![recursion_limit = "256"]
use std::hint::black_box;
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 tempfile::tempdir;

View File

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

View File

@@ -1,12 +1,11 @@
#![recursion_limit = "256"]
use std::hint::black_box;
use std::path::Path;
use criterion::{Criterion, criterion_group, criterion_main};
use deltachat::Events;
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use deltachat::chatlist::Chatlist;
use deltachat::context::Context;
use deltachat::stock_str::StockStrings;
use deltachat::Events;
async fn get_chat_list_benchmark(context: &Context) {
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,30 +1,29 @@
#![recursion_limit = "256"]
use std::hint::black_box;
use std::path::PathBuf;
use criterion::{Criterion, criterion_group, criterion_main};
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use deltachat::{
Events,
config::Config,
context::Context,
imex::{ImexMode, imex},
imex::{imex, ImexMode},
receive_imf::receive_imf,
stock_str::StockStrings,
Events,
};
use tempfile::tempdir;
async fn recv_all_emails(context: Context, iteration: u32) -> Context {
async fn recv_all_emails(context: Context) -> Context {
for i in 0..100 {
let imf_raw = format!(
"Subject: Benchmark
Message-ID: Mr.{iteration}.{i}@testrun.org
Message-ID: Mr.OssSYnOFkhR.{i}@testrun.org
Date: Sat, 07 Dec 2019 19:00:27 +0000
To: alice@example.com
From: sender@testrun.org
Chat-Version: 1.0
Chat-Disposition-Notification-To: sender@testrun.org
Chat-User-Avatar: 0
In-Reply-To: Mr.{iteration}.{i_dec}@testrun.org
In-Reply-To: Mr.OssSYnOFkhR.{i_dec}@testrun.org
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
@@ -42,11 +41,11 @@ Hello {i}",
/// Receive 100 emails that remove charlie@example.com and add
/// him back
async fn recv_groupmembership_emails(context: Context, iteration: u32) -> Context {
async fn recv_groupmembership_emails(context: Context) -> Context {
for i in 0..50 {
let imf_raw = format!(
"Subject: Benchmark
Message-ID: Gr.{iteration}.ADD.{i}@testrun.org
Message-ID: Gr.OssSYnOFkhR.{i}@testrun.org
Date: Sat, 07 Dec 2019 19:00:27 +0000
To: alice@example.com, b@example.com, c@example.com, d@example.com, e@example.com, f@example.com
From: sender@testrun.org
@@ -54,12 +53,13 @@ Chat-Version: 1.0
Chat-Disposition-Notification-To: sender@testrun.org
Chat-User-Avatar: 0
Chat-Group-Member-Added: charlie@example.com
In-Reply-To: Gr.{iteration}.REMOVE.{i_dec}@testrun.org
In-Reply-To: Gr.OssSYnOFkhR.{i_dec}@testrun.org
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
Hello {i}",
i = i,
i_dec = i - 1,
);
receive_imf(&context, black_box(imf_raw.as_bytes()), false)
@@ -68,7 +68,7 @@ Hello {i}",
let imf_raw = format!(
"Subject: Benchmark
Message-ID: Gr.{iteration}.REMOVE.{i}@testrun.org
Message-ID: Gr.OssSYnOFkhR.{i}@testrun.org
Date: Sat, 07 Dec 2019 19:00:27 +0000
To: alice@example.com, b@example.com, c@example.com, d@example.com, e@example.com, f@example.com
From: sender@testrun.org
@@ -76,12 +76,14 @@ Chat-Version: 1.0
Chat-Disposition-Notification-To: sender@testrun.org
Chat-User-Avatar: 0
Chat-Group-Member-Removed: charlie@example.com
In-Reply-To: Gr.{iteration}.ADD.{i}@testrun.org
In-Reply-To: Gr.OssSYnOFkhR.{i_dec}@testrun.org
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
Hello {i}"
Hello {i}",
i = i,
i_dec = i - 1,
);
receive_imf(&context, black_box(imf_raw.as_bytes()), false)
.await
@@ -127,13 +129,11 @@ fn criterion_benchmark(c: &mut Criterion) {
group.bench_function("Receive 100 simple text msgs", |b| {
let rt = tokio::runtime::Runtime::new().unwrap();
let context = rt.block_on(create_context());
let mut i = 0;
b.to_async(&rt).iter(|| {
let ctx = context.clone();
i += 1;
async move {
recv_all_emails(black_box(ctx), i).await;
recv_all_emails(black_box(ctx)).await;
}
});
});
@@ -142,13 +142,11 @@ fn criterion_benchmark(c: &mut Criterion) {
|b| {
let rt = tokio::runtime::Runtime::new().unwrap();
let context = rt.block_on(create_context());
let mut i = 0;
b.to_async(&rt).iter(|| {
let ctx = context.clone();
i += 1;
async move {
recv_groupmembership_emails(black_box(ctx), i).await;
recv_groupmembership_emails(black_box(ctx)).await;
}
});
},

View File

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

View File

@@ -1,17 +1,17 @@
#![recursion_limit = "256"]
use criterion::{Criterion, criterion_group, criterion_main};
use criterion::{criterion_group, criterion_main, Criterion};
use deltachat::context::Context;
use deltachat::stock_str::StockStrings;
use deltachat::{Event, EventType, Events};
use deltachat::{info, Event, EventType, Events};
use tempfile::tempdir;
async fn send_events_benchmark(context: &Context) {
let emitter = context.get_event_emitter();
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 {
match emitter.recv().await.unwrap() {

View File

@@ -11,7 +11,7 @@ filter_unconventional = false
split_commits = false
# regex for preprocessing the commit messages
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
commit_parsers = [
@@ -82,11 +82,11 @@ footer = """
{% if release.version -%}
{% if release.previous.version -%}
[{{ 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 }}
{% endif -%}
{% else -%}
[unreleased]: https://github.com/chatmail/core\
[unreleased]: https://github.com/deltachat/deltachat-core-rust\
/compare/{{ release.previous.version }}..HEAD
{% endif -%}
{% endfor %}

80
contrib/proxy.py Normal file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
# Examples:
#
# Original server that doesn't use SSL:
# ./proxy.py 8080 imap.nauta.cu 143
# ./proxy.py 8081 smtp.nauta.cu 25
#
# Original server that uses SSL:
# ./proxy.py 8080 testrun.org 993 --ssl
# ./proxy.py 8081 testrun.org 465 --ssl
from datetime import datetime
import argparse
import selectors
import ssl
import socket
import socketserver
class Proxy(socketserver.ThreadingTCPServer):
allow_reuse_address = True
def __init__(self, proxy_host, proxy_port, real_host, real_port, use_ssl):
self.real_host = real_host
self.real_port = real_port
self.use_ssl = use_ssl
super().__init__((proxy_host, proxy_port), RequestHandler)
class RequestHandler(socketserver.BaseRequestHandler):
def handle(self):
print('{} - {} CONNECTED.'.format(datetime.now(), self.client_address))
total = 0
real_server = (self.server.real_host, self.server.real_port)
with socket.create_connection(real_server) as sock:
if self.server.use_ssl:
context = ssl.create_default_context()
sock = context.wrap_socket(
sock, server_hostname=real_server[0])
forward = {self.request: sock, sock: self.request}
sel = selectors.DefaultSelector()
sel.register(self.request, selectors.EVENT_READ,
self.client_address)
sel.register(sock, selectors.EVENT_READ, real_server)
active = True
while active:
events = sel.select()
for key, mask in events:
print('\n{} - {} wrote:'.format(datetime.now(), key.data))
data = key.fileobj.recv(1024)
received = len(data)
total += received
print(data)
print('{} Bytes\nTotal: {} Bytes'.format(received, total))
if data:
forward[key.fileobj].sendall(data)
else:
print('\nCLOSING CONNECTION.\n\n')
forward[key.fileobj].close()
key.fileobj.close()
active = False
if __name__ == '__main__':
p = argparse.ArgumentParser(description='Simple Python Proxy')
p.add_argument(
"proxy_port", help="the port where the proxy will listen", type=int)
p.add_argument('host', help="the real host")
p.add_argument('port', help="the port of the real host", type=int)
p.add_argument("--ssl", help="use ssl to connect to the real host",
action="store_true")
args = p.parse_args()
with Proxy('', args.proxy_port, args.host, args.port, args.ssl) as proxy:
proxy.serve_forever()

View File

@@ -9,9 +9,10 @@ license = "MPL-2.0"
[dependencies]
anyhow = { workspace = true }
once_cell = { 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.
chrono = { workspace = true, features = ["alloc", "clock", "std"] }
chrono = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.

View File

@@ -15,28 +15,208 @@
clippy::explicit_into_iter_loop,
clippy::cloned_instead_of_copied
)]
#![cfg_attr(not(test), forbid(clippy::indexing_slicing))]
#![cfg_attr(not(test), forbid(clippy::string_slice))]
#![cfg_attr(not(test), warn(clippy::indexing_slicing))]
#![allow(
clippy::match_bool,
clippy::mixed_read_write_in_expression,
clippy::bool_assert_comparison,
clippy::manual_split_once,
clippy::format_push_string,
clippy::bool_to_int_with_if,
clippy::manual_range_contains
clippy::bool_to_int_with_if
)]
use std::fmt;
use std::ops::Deref;
use std::sync::LazyLock;
use anyhow::bail;
use anyhow::Context as _;
use anyhow::Result;
use chrono::{DateTime, NaiveDateTime};
use once_cell::sync::Lazy;
use regex::Regex;
mod vcard;
pub use vcard::{make_vcard, parse_vcard, VcardContact};
// TODOs to clean up:
// - Check if sanitizing is done correctly everywhere
// - Apply lints everywhere (https://doc.rust-lang.org/cargo/reference/workspaces.html#the-lints-table)
#[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 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`
// TODO this doesn't handle the case where there are quotes around a colon
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 line in lines.by_ref() {
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,"))
{
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.
#[derive(Debug, Clone)]
@@ -76,35 +256,34 @@ impl ContactAddress {
/// Allow converting [`ContactAddress`] to an SQLite type.
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 out = rusqlite::types::ToSqlOutput::Owned(val);
Ok(out)
}
}
/// Takes a name and an address and sanitizes them:
/// - Extracts a name from the addr if the addr is in form "Alice <alice@example.org>"
/// - Removes special characters from the name, see [`sanitize_name()`]
/// - Removes the name if it is equal to the address by setting it to ""
/// Make the name and address
pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) {
static ADDR_WITH_NAME_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new("(.*)<(.*)>").unwrap());
static ADDR_WITH_NAME_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new("(.*)<(.*)>").unwrap());
let (name, addr) = if let Some(captures) = ADDR_WITH_NAME_REGEX.captures(addr.as_ref()) {
(
if name.is_empty() {
captures.get(1).map_or("", |m| m.as_str())
strip_rtlo_characters(captures.get(1).map_or("", |m| m.as_str()))
} else {
name
strip_rtlo_characters(name)
},
captures
.get(2)
.map_or("".to_string(), |m| m.as_str().to_string()),
)
} else {
(name, addr.to_string())
(
strip_rtlo_characters(&normalize_name(name)),
addr.to_string(),
)
};
let mut name = sanitize_name(name);
let mut name = normalize_name(&name);
// If the 'display name' is just the address, remove it:
// Otherwise, the contact would sometimes be shown as "alice@example.com (alice@example.com)" (see `get_name_n_addr()`).
@@ -116,77 +295,31 @@ pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) {
(name, addr)
}
/// Sanitizes a name.
/// Normalize a name.
///
/// - Removes newlines and trims the string
/// - Removes quotes (come from some bad MUA implementations)
/// - Removes potentially-malicious bidi characters
pub fn sanitize_name(name: &str) -> String {
let name = sanitize_single_line(name);
match name.as_bytes() {
[b'\'', .., b'\''] | [b'\"', .., b'\"'] | [b'<', .., b'>'] => name
.get(1..name.len() - 1)
.map_or("".to_string(), |s| s.trim().to_string()),
_ => name.to_string(),
/// - Remove quotes (come from some bad MUA implementations)
/// - Trims the resulting string
///
/// Typically, this function is not needed as it is called implicitly by `Contact::add_address_book`.
pub fn normalize_name(full_name: &str) -> String {
let full_name = full_name.trim();
if full_name.is_empty() {
return full_name.into();
}
}
/// Sanitizes user input
///
/// - Removes newlines and trims the string
/// - Removes potentially-malicious bidi characters
pub fn sanitize_single_line(input: &str) -> String {
sanitize_bidi_characters(input.replace(['\n', '\r'], " ").trim())
match full_name.as_bytes() {
[b'\'', .., b'\''] | [b'\"', .., b'\"'] | [b'<', .., b'>'] => full_name
.get(1..full_name.len() - 1)
.map_or("".to_string(), |s| s.trim().to_string()),
_ => full_name.to_string(),
}
}
const RTLO_CHARACTERS: [char; 5] = ['\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}'];
const ISOLATE_CHARACTERS: [char; 3] = ['\u{2066}', '\u{2067}', '\u{2068}'];
const POP_ISOLATE_CHARACTER: char = '\u{2069}';
/// Some control unicode characters can influence whether adjacent text is shown from
/// left to right or from right to left.
///
/// Since user input is not supposed to influence how adjacent text looks,
/// this function removes some of these characters.
///
/// Also see https://github.com/deltachat/deltachat-core-rust/issues/3479.
pub fn sanitize_bidi_characters(input_str: &str) -> String {
// RTLO_CHARACTERS are apparently rarely used in practice.
// They can impact all following text, so, better remove them all:
let input_str = input_str.replace(|char| RTLO_CHARACTERS.contains(&char), "");
// If the ISOLATE characters are not ended with a POP DIRECTIONAL ISOLATE character,
// we regard the input as potentially malicious and simply remove all ISOLATE characters.
// See https://en.wikipedia.org/wiki/Bidirectional_text#Unicode_bidi_support
// and https://www.w3.org/International/questions/qa-bidi-unicode-controls.en
// for an explanation about ISOLATE characters.
fn isolate_characters_are_valid(input_str: &str) -> bool {
let mut isolate_character_nesting: i32 = 0;
for char in input_str.chars() {
if ISOLATE_CHARACTERS.contains(&char) {
isolate_character_nesting += 1;
} else if char == POP_ISOLATE_CHARACTER {
isolate_character_nesting -= 1;
}
// According to Wikipedia, 125 levels are allowed:
// https://en.wikipedia.org/wiki/Unicode_control_characters
// (although, in practice, we could also significantly lower this number)
if isolate_character_nesting < 0 || isolate_character_nesting > 125 {
return false;
}
}
isolate_character_nesting == 0
}
if isolate_characters_are_valid(&input_str) {
input_str
} else {
input_str.replace(
|char| ISOLATE_CHARACTERS.contains(&char) || POP_ISOLATE_CHARACTER == char,
"",
)
}
/// This method strips all occurrences of the RTLO Unicode character.
/// [Why is this needed](https://github.com/deltachat/deltachat-core-rust/issues/3479)?
pub fn strip_rtlo_characters(input_str: &str) -> String {
input_str.replace(|char| RTLO_CHARACTERS.contains(&char), "")
}
/// Returns false if addr is an invalid address, otherwise true.
@@ -282,7 +415,7 @@ impl 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 out = rusqlite::types::ToSqlOutput::Owned(val);
Ok(out)
@@ -291,8 +424,124 @@ impl rusqlite::types::ToSql for EmailAddress {
#[cfg(test)]
mod tests {
use chrono::TimeZone;
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]
fn test_contact_address() -> Result<()> {
let alice_addr = "alice@example.org";
@@ -340,62 +589,83 @@ mod tests {
}
#[test]
fn test_sanitize_name() {
assert_eq!(&sanitize_name(" hello world "), "hello world");
assert_eq!(&sanitize_name("<"), "<");
assert_eq!(&sanitize_name(">"), ">");
assert_eq!(&sanitize_name("'"), "'");
assert_eq!(&sanitize_name("\""), "\"");
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_sanitize_single_line() {
assert_eq!(sanitize_single_line("Hi\naiae "), "Hi aiae");
assert_eq!(sanitize_single_line("\r\nahte\n\r"), "ahte");
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_sanitize_bidi_characters() {
// Legit inputs:
assert_eq!(
&sanitize_bidi_characters("Tes\u{2067}ting Delta Chat\u{2069}"),
"Tes\u{2067}ting Delta Chat\u{2069}"
);
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==
assert_eq!(
&sanitize_bidi_characters("Tes\u{2067}ting \u{2068} Delta Chat\u{2069}\u{2069}"),
"Tes\u{2067}ting \u{2068} Delta Chat\u{2069}\u{2069}"
);
assert_eq!(
&sanitize_bidi_characters("Tes\u{2067}ting\u{2069} Delta Chat\u{2067}\u{2069}"),
"Tes\u{2067}ting\u{2069} Delta Chat\u{2067}\u{2069}"
);
// Potentially-malicious inputs:
assert_eq!(
&sanitize_bidi_characters("Tes\u{202C}ting Delta Chat"),
"Testing Delta Chat"
);
assert_eq!(
&sanitize_bidi_characters("Testing Delta Chat\u{2069}"),
"Testing Delta Chat"
);
assert_eq!(
&sanitize_bidi_characters("Tes\u{2067}ting Delta Chat"),
"Testing Delta Chat"
);
assert_eq!(
&sanitize_bidi_characters("Tes\u{2069}ting Delta Chat\u{2067}"),
"Testing Delta Chat"
);
assert_eq!(
&sanitize_bidi_characters("Tes\u{2068}ting Delta Chat"),
"Testing Delta Chat"
);
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==");
}
}
}

View File

@@ -1,247 +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,
}
}
}
/// 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())
}
fn escape(s: &str) -> String {
s.replace(',', "\\,")
}
let mut res = "".to_string();
for c in contacts {
// Mustn't contain ',', but it's easier to escape than to error out.
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, value.replace("\\,", ",")))
}
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,278 +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, I'm Alice".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\\, I'm Alice\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");
}

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "2.4.0"
version = "1.140.0"
description = "Deltachat FFI"
edition = "2018"
readme = "README.md"
@@ -14,19 +14,21 @@ name = "deltachat"
crate-type = ["cdylib", "staticlib"]
[dependencies]
deltachat = { workspace = true, default-features = false }
deltachat-jsonrpc = { workspace = true }
libc = { workspace = true }
deltachat = { path = "../", default-features = false }
deltachat-jsonrpc = { path = "../deltachat-jsonrpc", optional = true }
libc = "0.2"
human-panic = { version = "2", default-features = false }
num-traits = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread"] }
anyhow = { workspace = true }
thiserror = { workspace = true }
rand = { workspace = true }
yerpc = { workspace = true, features = ["anyhow_expose"] }
num-traits = "0.2"
serde_json = "1.0"
tokio = { version = "1.37.0", features = ["rt-multi-thread"] }
anyhow = "1"
thiserror = "1"
rand = "0.8"
once_cell = "1.18.0"
yerpc = { version = "0.5.1", features = ["anyhow_expose"] }
[features]
default = ["vendored"]
vendored = ["deltachat/vendored", "deltachat-jsonrpc/vendored"]
vendored = ["deltachat/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="2.0">
<!-- Generated by doxygen 1.13.2 -->
<doxygenlayout version="1.0">
<!-- Generated by doxygen 1.8.20 -->
<!-- Navigation index tabs for HTML output -->
<navindex>
<tab type="mainpage" visible="yes" title=""/>
@@ -12,16 +11,10 @@
</tab>
<tab type="topics" visible="yes" title="Constants" intro="Here is a list of constants:"/>
<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="namespacelist" visible="yes" title="" intro=""/>
<tab type="namespacemembers" visible="yes" title="" intro=""/>
</tab>
<tab type="concepts" visible="yes" title="">
</tab>
<tab type="interfaces" visible="yes" title="">
<tab type="interfacelist" visible="yes" title="" intro=""/>
<tab type="interfaceindex" visible="$ALPHABETICAL_INDEX" title=""/>
@@ -42,228 +35,4 @@
</tab>
<tab type="examples" visible="yes" title="" intro=""/>
</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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -34,39 +34,37 @@ pub enum Meaning {
}
impl Lot {
pub fn get_text1(&self) -> Option<Cow<'_, str>> {
pub fn get_text1(&self) -> Option<&str> {
match self {
Self::Summary(summary) => match &summary.prefix {
None => None,
Some(SummaryPrefix::Draft(text)) => Some(Cow::Borrowed(text)),
Some(SummaryPrefix::Username(username)) => Some(Cow::Borrowed(username)),
Some(SummaryPrefix::Me(text)) => Some(Cow::Borrowed(text)),
Some(SummaryPrefix::Draft(text)) => Some(text),
Some(SummaryPrefix::Username(username)) => Some(username),
Some(SummaryPrefix::Me(text)) => Some(text),
},
Self::Qr(qr) => match qr {
Qr::AskVerifyContact { .. } => None,
Qr::AskVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::AskVerifyGroup { grpname, .. } => Some(grpname),
Qr::FprOk { .. } => None,
Qr::FprMismatch { .. } => None,
Qr::FprWithoutAddr { fingerprint, .. } => Some(Cow::Borrowed(fingerprint)),
Qr::Account { domain } => Some(Cow::Borrowed(domain)),
Qr::Backup2 { .. } => None,
Qr::BackupTooNew { .. } => None,
Qr::WebrtcInstance { domain, .. } => Some(Cow::Borrowed(domain)),
Qr::Proxy { host, port, .. } => Some(Cow::Owned(format!("{host}:{port}"))),
Qr::Addr { draft, .. } => draft.as_deref().map(Cow::Borrowed),
Qr::Url { url } => Some(Cow::Borrowed(url)),
Qr::Text { text } => Some(Cow::Borrowed(text)),
Qr::FprWithoutAddr { fingerprint, .. } => Some(fingerprint),
Qr::Account { domain } => Some(domain),
Qr::Backup { .. } => None,
Qr::WebrtcInstance { domain, .. } => Some(domain),
Qr::Addr { draft, .. } => draft.as_deref(),
Qr::Url { url } => Some(url),
Qr::Text { text } => Some(text),
Qr::WithdrawVerifyContact { .. } => None,
Qr::WithdrawVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::WithdrawVerifyGroup { grpname, .. } => Some(grpname),
Qr::ReviveVerifyContact { .. } => None,
Qr::ReviveVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
Qr::Login { address, .. } => Some(Cow::Borrowed(address)),
Qr::ReviveVerifyGroup { grpname, .. } => Some(grpname),
Qr::Login { address, .. } => Some(address),
},
Self::Error(err) => Some(Cow::Borrowed(err)),
Self::Error(err) => Some(err),
}
}
pub fn get_text2(&self) -> Option<Cow<'_, str>> {
pub fn get_text2(&self) -> Option<Cow<str>> {
match self {
Self::Summary(summary) => Some(summary.truncated_text(160)),
Self::Qr(_) => None,
@@ -103,10 +101,8 @@ impl Lot {
Qr::FprMismatch { .. } => LotState::QrFprMismatch,
Qr::FprWithoutAddr { .. } => LotState::QrFprWithoutAddr,
Qr::Account { .. } => LotState::QrAccount,
Qr::Backup2 { .. } => LotState::QrBackup2,
Qr::BackupTooNew { .. } => LotState::QrBackupTooNew,
Qr::Backup { .. } => LotState::QrBackup,
Qr::WebrtcInstance { .. } => LotState::QrWebrtcInstance,
Qr::Proxy { .. } => LotState::QrProxy,
Qr::Addr { .. } => LotState::QrAddr,
Qr::Url { .. } => LotState::QrUrl,
Qr::Text { .. } => LotState::QrText,
@@ -130,10 +126,8 @@ impl Lot {
Qr::FprMismatch { contact_id } => contact_id.unwrap_or_default().to_u32(),
Qr::FprWithoutAddr { .. } => Default::default(),
Qr::Account { .. } => Default::default(),
Qr::Backup2 { .. } => Default::default(),
Qr::BackupTooNew { .. } => Default::default(),
Qr::Backup { .. } => Default::default(),
Qr::WebrtcInstance { .. } => Default::default(),
Qr::Proxy { .. } => Default::default(),
Qr::Addr { contact_id, .. } => contact_id.to_u32(),
Qr::Url { .. } => Default::default(),
Qr::Text { .. } => Default::default(),
@@ -181,16 +175,11 @@ pub enum LotState {
/// text1=domain
QrAccount = 250,
QrBackup2 = 252,
QrBackupTooNew = 255,
QrBackup = 251,
/// text1=domain, text2=instance pattern
QrWebrtcInstance = 260,
/// text1=address, text2=protocol
QrProxy = 271,
/// id=contact
QrAddr = 320,

View File

@@ -1,33 +1,45 @@
[package]
name = "deltachat-jsonrpc"
version = "2.4.0"
version = "1.140.0"
description = "DeltaChat JSON-RPC API"
edition = "2021"
default-run = "deltachat-jsonrpc-server"
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]
anyhow = { workspace = true }
deltachat = { workspace = true }
deltachat-contact-tools = { workspace = true }
num-traits = { workspace = true }
schemars = "0.8.22"
serde = { workspace = true, features = ["derive"] }
async-channel = { workspace = true }
serde_json = { workspace = true }
yerpc = { workspace = true, features = ["anyhow_expose", "openrpc"] }
typescript-type-def = { version = "0.5.13", features = ["json_value"] }
tokio = { workspace = true }
sanitize-filename = { workspace = true }
anyhow = "1"
deltachat = { path = ".." }
deltachat-contact-tools = { path = "../deltachat-contact-tools" }
num-traits = "0.2"
schemars = "0.8.19"
serde = { version = "1.0", features = ["derive"] }
tempfile = "3.10.1"
log = "0.4"
async-channel = { version = "2.2.1" }
futures = { version = "0.3.30" }
serde_json = "1"
yerpc = { version = "0.5.2", features = ["anyhow_expose", "openrpc"] }
typescript-type-def = { version = "0.5.8", features = ["json_value"] }
tokio = { version = "1.37.0" }
sanitize-filename = "0.5"
walkdir = "2.5.0"
base64 = { workspace = true }
base64 = "0.22"
# optional dependencies
axum = { version = "0.7", optional = true, features = ["ws"] }
env_logger = { version = "0.11.3", optional = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full", "rt-multi-thread"] }
tempfile = { workspace = true }
futures = { workspace = true }
tokio = { version = "1.37.0", features = ["full", "rt-multi-thread"] }
[features]
default = ["vendored"]
webserver = ["dep:env_logger", "dep:axum", "tokio/full", "yerpc/support-axum"]
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:
* A executable `deltachat-rpc-server` that exposes the JSON-RPC API through stdio.
* 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.
* 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). 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
#### 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
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:
```sh
@@ -22,7 +52,15 @@ npm install
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:
```sh
@@ -35,6 +73,18 @@ Then open the [`typescript/docs`](typescript/docs) folder in a web browser.
#### 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
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
```
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

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

View File

@@ -1,5 +1,5 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::str;
use std::sync::Arc;
use std::time::Duration;
@@ -7,7 +7,6 @@ use std::{collections::HashMap, str::FromStr};
use anyhow::{anyhow, bail, ensure, Context, Result};
pub use deltachat::accounts::Accounts;
use deltachat::blob::BlobObject;
use deltachat::chat::{
self, add_contact_to_chat, forward_msgs, get_chat_media, get_chat_msgs, get_chat_msgs_ex,
marknoticed_chat, remove_contact_from_chat, Chat, ChatId, ChatItem, MessageListOptions,
@@ -19,11 +18,10 @@ use deltachat::constants::DC_MSG_ID_DAYMARKER;
use deltachat::contact::{may_be_valid_addr, Contact, ContactId, Origin};
use deltachat::context::get_info;
use deltachat::ephemeral::Timer;
use deltachat::imex;
use deltachat::location;
use deltachat::message::get_msg_read_receipts;
use deltachat::message::{
self, delete_msgs_ex, markseen_msgs, Message, MessageState, MsgId, Viewtype,
self, delete_msgs, markseen_msgs, Message, MessageState, MsgId, Viewtype,
};
use deltachat::peer_channels::{
leave_webxdc_realtime, send_webxdc_realtime_advertisement, send_webxdc_realtime_data,
@@ -36,10 +34,10 @@ use deltachat::securejoin;
use deltachat::stock_str::StockMessage;
use deltachat::webxdc::StatusUpdateSerial;
use deltachat::EventEmitter;
use deltachat::{imex, info};
use sanitize_filename::is_sanitized;
use tokio::fs;
use tokio::sync::{watch, Mutex, RwLock};
use types::login_param::EnteredLoginParam;
use walkdir::WalkDir;
use yerpc::rpc;
@@ -214,30 +212,23 @@ impl CommandApi {
self.accounts.read().await.get_all()
}
/// Select account in account manager, this saves the last used account to accounts.toml
/// Select account id for internally selected state.
/// TODO: Likely this is deprecated as all methods take an account id now.
async fn select_account(&self, id: u32) -> Result<()> {
self.accounts.write().await.select_account(id).await
}
/// Get the selected account from the account manager (on startup it is read from accounts.toml)
/// Get the selected account id of the internal state..
/// TODO: Likely this is deprecated as all methods take an account id now.
async fn get_selected_account_id(&self) -> Option<u32> {
self.accounts.read().await.get_selected_account_id()
}
/// Set the order of accounts.
/// The provided list should contain all account IDs in the desired order.
/// If an account ID is missing from the list, it will be appended at the end.
/// If the list contains non-existent account IDs, they will be ignored.
async fn set_accounts_order(&self, order: Vec<u32>) -> Result<()> {
self.accounts.write().await.set_accounts_order(order).await
}
/// Get a list of all configured accounts.
async fn get_all_accounts(&self) -> Result<Vec<Account>> {
let mut accounts = Vec::new();
let accounts_lock = self.accounts.read().await;
for id in accounts_lock.get_all() {
let context_option = accounts_lock.get_account(id);
for id in self.accounts.read().await.get_all() {
let context_option = self.accounts.read().await.get_account(id);
if let Some(ctx) = context_option {
accounts.push(Account::from_context(&ctx, id).await?)
}
@@ -263,12 +254,11 @@ impl CommandApi {
/// Process all events until you get this one and you can safely return to the background
/// without forgetting to create notifications caused by timing race conditions.
async fn accounts_background_fetch(&self, timeout_in_seconds: f64) -> Result<()> {
let future = {
let lock = self.accounts.read().await;
lock.background_fetch(std::time::Duration::from_secs_f64(timeout_in_seconds))
};
// At this point account manager is not locked anymore.
future.await;
self.accounts
.write()
.await
.background_fetch(std::time::Duration::from_secs_f64(timeout_in_seconds))
.await;
Ok(())
}
@@ -331,16 +321,12 @@ impl CommandApi {
) -> Result<Option<ProviderInfo>> {
let ctx = self.get_context(account_id).await?;
let proxy_enabled = ctx
.get_config_bool(deltachat::config::Config::ProxyEnabled)
let socks5_enabled = ctx
.get_config_bool(deltachat::config::Config::Socks5Enabled)
.await?;
let provider_info = get_provider_info(
&ctx,
email.split('@').next_back().unwrap_or(""),
proxy_enabled,
)
.await;
let provider_info =
get_provider_info(&ctx, email.split('@').last().unwrap_or(""), socks5_enabled).await;
Ok(ProviderInfo::from_dc_type(provider_info))
}
@@ -356,33 +342,11 @@ impl CommandApi {
ctx.get_info().await
}
/// Get the blob dir.
async fn get_blob_dir(&self, account_id: u32) -> Result<Option<String>> {
let ctx = self.get_context(account_id).await?;
Ok(ctx.get_blobdir().to_str().map(|s| s.to_owned()))
}
/// If there was an error while the account was opened
/// and migrated to the current version,
/// then this function returns it.
///
/// This function is useful because the key-contacts migration could fail due to bugs
/// and then the account will not work properly.
///
/// After opening an account, the UI should call this function
/// and show the error string if one is returned.
async fn get_migration_error(&self, account_id: u32) -> Result<Option<String>> {
let ctx = self.get_context(account_id).await?;
Ok(ctx.get_migration_error())
}
/// Copy file to blob dir.
async fn copy_to_blob_dir(&self, account_id: u32, path: String) -> Result<PathBuf> {
let ctx = self.get_context(account_id).await?;
let file = Path::new(&path);
Ok(BlobObject::create_and_deduplicate(&ctx, file, file)?.to_abs_path())
}
async fn draft_self_report(&self, account_id: u32) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
Ok(ctx.draft_self_report().await?.to_u32())
@@ -459,9 +423,6 @@ impl CommandApi {
/// Configures this account with the currently set parameters.
/// Setup the credential config before calling this.
///
/// Deprecated as of 2025-02; use `add_transport_from_qr()`
/// or `add_or_update_transport()` instead.
async fn configure(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.stop_io().await;
@@ -476,78 +437,6 @@ impl CommandApi {
Ok(())
}
/// Configures a new email account using the provided parameters
/// and adds it as a transport.
///
/// If the email address is the same as an existing transport,
/// then this existing account will be reconfigured instead of a new one being added.
///
/// This function stops and starts IO as needed.
///
/// Usually it will be enough to only set `addr` and `password`,
/// and all the other settings will be autoconfigured.
///
/// During configuration, ConfigureProgress events are emitted;
/// they indicate a successful configuration as well as errors
/// and may be used to create a progress bar.
/// This function will return after configuration is finished.
///
/// If configuration is successful,
/// the working server parameters will be saved
/// and used for connecting to the server.
/// The parameters entered by the user will be saved separately
/// so that they can be prefilled when the user opens the server-configuration screen again.
///
/// See also:
/// - [Self::is_configured()] to check whether there is
/// at least one working transport.
/// - [Self::add_transport_from_qr()] to add a transport
/// from a server encoded in a QR code.
/// - [Self::list_transports()] to get a list of all configured transports.
/// - [Self::delete_transport()] to remove a transport.
async fn add_or_update_transport(
&self,
account_id: u32,
param: EnteredLoginParam,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.add_or_update_transport(&mut param.try_into()?).await
}
/// Deprecated 2025-04. Alias for [Self::add_or_update_transport()].
async fn add_transport(&self, account_id: u32, param: EnteredLoginParam) -> Result<()> {
self.add_or_update_transport(account_id, param).await
}
/// Adds a new email account as a transport
/// using the server encoded in the QR code.
/// See [Self::add_or_update_transport].
async fn add_transport_from_qr(&self, account_id: u32, qr: String) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.add_transport_from_qr(&qr).await
}
/// Returns the list of all email accounts that are used as a transport in the current profile.
/// Use [Self::add_or_update_transport()] to add or change a transport
/// and [Self::delete_transport()] to delete a transport.
async fn list_transports(&self, account_id: u32) -> Result<Vec<EnteredLoginParam>> {
let ctx = self.get_context(account_id).await?;
let res = ctx
.list_transports()
.await?
.into_iter()
.map(|t| t.into())
.collect();
Ok(res)
}
/// Removes the transport with the specified email address
/// (i.e. [EnteredLoginParam::addr]).
async fn delete_transport(&self, account_id: u32, addr: String) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.delete_transport(&addr).await
}
/// Signal an ongoing process to stop.
async fn stop_ongoing_process(&self, account_id: u32) -> Result<()> {
let ctx = self.get_context(account_id).await?;
@@ -818,22 +707,7 @@ impl CommandApi {
ChatId::new(chat_id).get_encryption_info(&ctx).await
}
/// Get QR code text that will offer a [SecureJoin](https://securejoin.delta.chat/) invitation.
///
/// If `chat_id` is a group chat ID, SecureJoin QR code for the group is returned.
/// If `chat_id` is unset, setup contact QR code is returned.
async fn get_chat_securejoin_qr_code(
&self,
account_id: u32,
chat_id: Option<u32>,
) -> Result<String> {
let ctx = self.get_context(account_id).await?;
let chat = chat_id.map(ChatId::new);
let qr = securejoin::get_securejoin_qr(&ctx, chat).await?;
Ok(qr)
}
/// Get QR code (text and SVG) that will offer a Setup-Contact or Verified-Group invitation.
/// Get QR code (text and SVG) that will offer an Setup-Contact or Verified-Group invitation.
/// The QR code is compatible to the OPENPGP4FPR format
/// so that a basic fingerprint comparison also works e.g. with OpenKeychain.
///
@@ -855,9 +729,10 @@ impl CommandApi {
) -> Result<(String, String)> {
let ctx = self.get_context(account_id).await?;
let chat = chat_id.map(ChatId::new);
let qr = securejoin::get_securejoin_qr(&ctx, chat).await?;
let svg = get_securejoin_qr_svg(&ctx, chat).await?;
Ok((qr, svg))
Ok((
securejoin::get_securejoin_qr(&ctx, chat).await?,
get_securejoin_qr_svg(&ctx, chat).await?,
))
}
/// Continue a Setup-Contact or Verified-Group-Invite protocol
@@ -934,7 +809,7 @@ impl CommandApi {
/// explicitly as it may happen that oneself gets removed from a still existing
/// group
///
/// - for broadcast channels, all recipients are returned, DC_CONTACT_ID_SELF is not included
/// - for broadcasts, all recipients are returned, DC_CONTACT_ID_SELF is not included
///
/// - for mailing lists, the behavior is not documented currently, we will decide on that later.
/// for now, the UI should not show the list for mailing lists.
@@ -946,14 +821,7 @@ impl CommandApi {
Ok(contacts.iter().map(|id| id.to_u32()).collect::<Vec<u32>>())
}
/// Returns contact IDs of the past chat members.
async fn get_past_chat_contacts(&self, account_id: u32, chat_id: u32) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?;
let contacts = chat::get_past_chat_contacts(&ctx, ChatId::new(chat_id)).await?;
Ok(contacts.iter().map(|id| id.to_u32()).collect::<Vec<u32>>())
}
/// Create a new encrypted group chat (with key-contacts).
/// Create a new group chat.
///
/// After creation,
/// the group has one member with the ID DC_CONTACT_ID_SELF
@@ -971,52 +839,30 @@ impl CommandApi {
///
/// @param protect If set to 1 the function creates group with protection initially enabled.
/// Only verified members are allowed in these groups
/// and end-to-end-encryption is always enabled.
async fn create_group_chat(&self, account_id: u32, name: String, protect: bool) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
let protect = match protect {
true => ProtectionStatus::Protected,
false => ProtectionStatus::Unprotected,
};
chat::create_group_ex(&ctx, Some(protect), &name)
chat::create_group_chat(&ctx, protect, &name)
.await
.map(|id| id.to_u32())
}
/// Create a new unencrypted group chat.
/// Create a new broadcast list.
///
/// Same as [`Self::create_group_chat`], but the chat is unencrypted and can only have
/// address-contacts.
async fn create_group_chat_unencrypted(&self, account_id: u32, name: String) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
chat::create_group_ex(&ctx, None, &name)
.await
.map(|id| id.to_u32())
}
/// Deprecated 2025-07 in favor of create_broadcast().
async fn create_broadcast_list(&self, account_id: u32) -> Result<u32> {
self.create_broadcast(account_id, "Channel".to_string())
.await
}
/// Create a new **broadcast channel**
/// (called "Channel" in the UI).
///
/// Broadcast channels are similar to groups on the sending device,
/// Broadcast lists 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.
/// and will 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 [`CommandApi::create_group_chat`] for more information on the unpromoted state.
///
/// Returns the created chat's id.
async fn create_broadcast(&self, account_id: u32, chat_name: String) -> Result<u32> {
/// For historical reasons, this function does not take a name directly,
/// instead you have to set the name using dc_set_chat_name()
/// after creating the broadcast list.
async fn create_broadcast_list(&self, account_id: u32) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
chat::create_broadcast(&ctx, chat_name)
chat::create_broadcast_list(&ctx)
.await
.map(|id| id.to_u32())
}
@@ -1132,12 +978,6 @@ impl CommandApi {
marknoticed_chat(&ctx, ChatId::new(chat_id)).await
}
/// Returns the message that is immediately followed by the last seen
/// message.
/// From the point of view of the user this is effectively
/// "first unread", but in reality in the database a seen message
/// _can_ be followed by a fresh (unseen) message
/// if that message has not been individually marked as seen.
async fn get_first_unread_message_of_chat(
&self,
account_id: u32,
@@ -1226,9 +1066,6 @@ impl CommandApi {
markseen_msgs(&ctx, msg_ids.into_iter().map(MsgId::new).collect()).await
}
/// Returns all messages of a particular chat.
/// If `add_daymarker` is `true`, it will return them as
/// `DC_MSG_ID_DAYMARKER`, e.g. [1234, 1237, 9, 1239].
async fn get_message_ids(
&self,
account_id: u32,
@@ -1283,11 +1120,9 @@ impl CommandApi {
async fn get_message(&self, account_id: u32, msg_id: u32) -> Result<MessageObject> {
let ctx = self.get_context(account_id).await?;
let msg_id = MsgId::new(msg_id);
let message_object = MessageObject::from_msg_id(&ctx, msg_id)
MessageObject::from_msg_id(&ctx, msg_id)
.await
.with_context(|| format!("Failed to load message {msg_id} for account {account_id}"))?
.with_context(|| format!("Message {msg_id} does not exist for account {account_id}"))?;
Ok(message_object)
.with_context(|| format!("Failed to load message {msg_id} for account {account_id}"))
}
async fn get_message_html(&self, account_id: u32, message_id: u32) -> Result<Option<String>> {
@@ -1311,10 +1146,7 @@ impl CommandApi {
messages.insert(
message_id,
match message_result {
Ok(Some(message)) => MessageLoadResult::Message(message),
Ok(None) => MessageLoadResult::LoadingError {
error: "Message does not exist".to_string(),
},
Ok(message) => MessageLoadResult::Message(message),
Err(error) => MessageLoadResult::LoadingError {
error: format!("{error:#}"),
},
@@ -1339,15 +1171,7 @@ impl CommandApi {
async fn delete_messages(&self, account_id: u32, message_ids: Vec<u32>) -> Result<()> {
let ctx = self.get_context(account_id).await?;
let msgs: Vec<MsgId> = message_ids.into_iter().map(MsgId::new).collect();
delete_msgs_ex(&ctx, &msgs, false).await
}
/// Delete messages. The messages are deleted on the current device,
/// on the IMAP server and also for all chat members
async fn delete_messages_for_all(&self, account_id: u32, message_ids: Vec<u32>) -> Result<()> {
let ctx = self.get_context(account_id).await?;
let msgs: Vec<MsgId> = message_ids.into_iter().map(MsgId::new).collect();
delete_msgs_ex(&ctx, &msgs, true).await
delete_msgs(&ctx, &msgs).await
}
/// Get an informational text for a single message. The text is multiline and may
@@ -1447,12 +1271,6 @@ impl CommandApi {
Ok(results)
}
async fn save_msgs(&self, account_id: u32, message_ids: Vec<u32>) -> Result<()> {
let ctx = self.get_context(account_id).await?;
let message_ids: Vec<MsgId> = message_ids.into_iter().map(MsgId::new).collect();
chat::save_msgs(&ctx, &message_ids).await
}
// ---------------------------------------------
// contact
// ---------------------------------------------
@@ -1523,14 +1341,6 @@ impl CommandApi {
Ok(contacts)
}
/// Returns ids of known and unblocked contacts.
///
/// By default, key-contacts are listed.
///
/// * `list_flags` - A combination of flags:
/// - `DC_GCL_ADD_SELF` - Add SELF unless filtered by other parameters.
/// - `DC_GCL_ADDRESS` - List address-contacts instead of key-contacts.
/// * `query` - A string to filter the list.
async fn get_contact_ids(
&self,
account_id: u32,
@@ -1542,10 +1352,8 @@ impl CommandApi {
Ok(contacts.into_iter().map(|c| c.to_u32()).collect())
}
/// Returns known and unblocked contacts.
///
/// Formerly called `getContacts2` in Desktop.
/// See [`Self::get_contact_ids`] for parameters and more info.
/// Get a list of contacts.
/// (formerly called getContacts2 in desktop)
async fn get_contacts(
&self,
account_id: u32,
@@ -1596,7 +1404,6 @@ impl CommandApi {
Ok(())
}
/// Sets display name for existing contact.
async fn change_contact_name(
&self,
account_id: u32,
@@ -1605,7 +1412,9 @@ impl CommandApi {
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
let contact_id = ContactId::new(contact_id);
contact_id.set_name(&ctx, &name).await?;
let contact = Contact::get_by_id(&ctx, contact_id).await?;
let addr = contact.get_addr();
Contact::create(&ctx, &name, addr).await?;
Ok(())
}
@@ -1660,18 +1469,6 @@ impl CommandApi {
.collect())
}
/// Imports contacts from a vCard.
///
/// Returns the ids of created/modified contacts in the order they appear in the vCard.
async fn import_vcard_contents(&self, account_id: u32, vcard: String) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?;
Ok(deltachat::contact::import_vcard(&ctx, &vcard)
.await?
.into_iter()
.map(|c| c.to_u32())
.collect())
}
/// Returns a vCard containing contacts with the given ids.
async fn make_vcard(&self, account_id: u32, contacts: Vec<u32>) -> Result<String> {
let ctx = self.get_context(account_id).await?;
@@ -1679,20 +1476,6 @@ impl CommandApi {
deltachat::contact::make_vcard(&ctx, &contacts).await
}
/// Sets vCard containing the given contacts to the message draft.
async fn set_draft_vcard(
&self,
account_id: u32,
msg_id: u32,
contacts: Vec<u32>,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
let contacts: Vec<_> = contacts.iter().map(|&c| ContactId::new(c)).collect();
let mut msg = Message::load_from_db(&ctx, MsgId::new(msg_id)).await?;
msg.make_vcard(&ctx, &contacts).await?;
msg.get_chat_id().set_draft(&ctx, Some(&mut msg)).await
}
// ---------------------------------------------
// chat
// ---------------------------------------------
@@ -1741,6 +1524,55 @@ impl CommandApi {
Ok(media.iter().map(|msg_id| msg_id.to_u32()).collect())
}
/// Search next/previous message based on a given message and a list of types.
/// Typically used to implement the "next" and "previous" buttons
/// in a gallery or in a media player.
///
/// one combined call for getting chat::get_next_media for both directions
/// the manual chat::get_next_media in only one direction is not exposed by the jsonrpc yet
///
/// Deprecated 2023-10-03, use `get_chat_media` method
/// and navigate the returned array instead.
#[allow(deprecated)]
async fn get_neighboring_chat_media(
&self,
account_id: u32,
msg_id: u32,
message_type: MessageViewtype,
or_message_type2: Option<MessageViewtype>,
or_message_type3: Option<MessageViewtype>,
) -> Result<(Option<u32>, Option<u32>)> {
let ctx = self.get_context(account_id).await?;
let msg_type: Viewtype = message_type.into();
let msg_type2: Viewtype = or_message_type2.map(|v| v.into()).unwrap_or_default();
let msg_type3: Viewtype = or_message_type3.map(|v| v.into()).unwrap_or_default();
let prev = chat::get_next_media(
&ctx,
MsgId::new(msg_id),
chat::Direction::Backward,
msg_type,
msg_type2,
msg_type3,
)
.await?
.map(|id| id.to_u32());
let next = chat::get_next_media(
&ctx,
MsgId::new(msg_id),
chat::Direction::Forward,
msg_type,
msg_type2,
msg_type3,
)
.await?
.map(|id| id.to_u32());
Ok((prev, next))
}
// ---------------------------------------------
// backup
// ---------------------------------------------
@@ -1812,10 +1644,10 @@ impl CommandApi {
///
/// This call will block until the QR code is ready,
/// even if there is no concurrent call to [`CommandApi::provide_backup`],
/// but will fail after 60 seconds to avoid deadlocks.
/// but will fail after 10 seconds to avoid deadlocks.
async fn get_backup_qr(&self, account_id: u32) -> Result<String> {
let qr = tokio::time::timeout(
Duration::from_secs(60),
Duration::from_secs(10),
self.inner_get_backup_qr(account_id),
)
.await
@@ -1831,13 +1663,13 @@ impl CommandApi {
///
/// This call will block until the QR code is ready,
/// even if there is no concurrent call to [`CommandApi::provide_backup`],
/// but will fail after 60 seconds to avoid deadlocks.
/// but will fail after 10 seconds to avoid deadlocks.
///
/// Returns the QR code rendered as an SVG image.
async fn get_backup_qr_svg(&self, account_id: u32) -> Result<String> {
let ctx = self.get_context(account_id).await?;
let qr = tokio::time::timeout(
Duration::from_secs(60),
Duration::from_secs(10),
self.inner_get_backup_qr(account_id),
)
.await
@@ -1941,10 +1773,10 @@ impl CommandApi {
account_id: u32,
instance_msg_id: u32,
update_str: String,
_descr: Option<String>,
description: String,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
ctx.send_webxdc_status_update(MsgId::new(instance_msg_id), &update_str)
ctx.send_webxdc_status_update(MsgId::new(instance_msg_id), &update_str, &description)
.await
}
@@ -1964,10 +1796,12 @@ impl CommandApi {
instance_msg_id: u32,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
if let Some(fut) =
send_webxdc_realtime_advertisement(&ctx, MsgId::new(instance_msg_id)).await?
{
tokio::spawn(fut);
let fut = send_webxdc_realtime_advertisement(&ctx, MsgId::new(instance_msg_id)).await?;
if let Some(fut) = fut {
tokio::spawn(async move {
fut.await.ok();
info!(ctx, "send_webxdc_realtime_advertisement done")
});
}
Ok(())
}
@@ -2001,14 +1835,6 @@ impl CommandApi {
WebxdcMessageInfo::get_for_message(&ctx, MsgId::new(instance_msg_id)).await
}
/// Get href from a WebxdcInfoMessage which might include a hash holding
/// information about a specific position or state in a webxdc app (optional)
async fn get_webxdc_href(&self, account_id: u32, info_msg_id: u32) -> Result<Option<String>> {
let ctx = self.get_context(account_id).await?;
let message = Message::load_from_db(&ctx, MsgId::new(info_msg_id)).await?;
Ok(message.get_webxdc_href())
}
/// Get blob encoded as base64 from a webxdc message
///
/// path is the path of the file within webxdc archive
@@ -2098,7 +1924,7 @@ impl CommandApi {
let ctx = self.get_context(account_id).await?;
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(&ctx, Path::new(&sticker_path), None, None)?;
msg.set_file(&sticker_path, None);
// JSON-rpc does not need heuristics to turn [Viewtype::Sticker] into [Viewtype::Image]
msg.force_sticker();
@@ -2141,27 +1967,13 @@ impl CommandApi {
async fn send_msg(&self, account_id: u32, chat_id: u32, data: MessageData) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
let mut message = data
.create_message(&ctx)
.await
.context("Failed to create message")?;
let mut message = data.create_message(&ctx).await?;
let msg_id = chat::send_msg(&ctx, ChatId::new(chat_id), &mut message)
.await
.context("Failed to send created message")?
.await?
.to_u32();
Ok(msg_id)
}
async fn send_edit_request(
&self,
account_id: u32,
msg_id: u32,
new_text: String,
) -> Result<()> {
let ctx = self.get_context(account_id).await?;
chat::send_edit_request(&ctx, MsgId::new(msg_id), new_text).await
}
/// Checks if messages can be sent to a given chat.
async fn can_send(&self, account_id: u32, chat_id: u32) -> Result<bool> {
let ctx = self.get_context(account_id).await?;
@@ -2194,7 +2006,9 @@ impl CommandApi {
async fn get_draft(&self, account_id: u32, chat_id: u32) -> Result<Option<MessageObject>> {
let ctx = self.get_context(account_id).await?;
if let Some(draft) = ChatId::new(chat_id).get_draft(&ctx).await? {
Ok(MessageObject::from_msg_id(&ctx, draft.get_id()).await?)
Ok(Some(
MessageObject::from_msg_id(&ctx, draft.get_id()).await?,
))
} else {
Ok(None)
}
@@ -2320,7 +2134,8 @@ impl CommandApi {
) -> Result<u32> {
let ctx = self.get_context(account_id).await?;
let mut msg = Message::new_text(text);
let mut msg = Message::new(Viewtype::Text);
msg.set_text(text);
let message_id = deltachat::chat::send_msg(&ctx, ChatId::new(chat_id), &mut msg).await?;
Ok(message_id.to_u32())
@@ -2328,45 +2143,12 @@ impl CommandApi {
// mimics the old desktop call, will get replaced with something better in the composer rewrite,
// the better version will just be sending the current draft, though there will be probably something similar with more options to this for the corner cases like setting a marker on the map
/// Send a message to a chat.
///
/// This function returns after the message has been placed in the sending queue.
/// This does not imply that the message was really sent out yet.
/// However, from your view, you're done with the message.
/// Sooner or later it will find its way.
///
/// **Attaching files:**
///
/// Pass the file path in the `file` parameter.
/// If `file` is not in the blob directory yet,
/// it will be copied into the blob directory.
/// If you want, you can delete the file immediately after this function returns.
///
/// You can also write the attachment directly into the blob directory
/// and then pass the path as the `file` parameter;
/// this will prevent an unnecessary copying of the file.
///
/// In `filename`, you can pass the original name of the file,
/// which will then be shown in the UI.
/// in this case the current name of `file` on the filesystem will be ignored.
///
/// In order to deduplicate files that contain the same data,
/// the file will be named `<hash>.<extension>`, e.g. `ce940175885d7b78f7b7e9f1396611f.jpg`.
///
/// NOTE:
/// - This function will rename the file. To get the new file path, call `get_file()`.
/// - The file must not be modified after this function was called.
/// - Images etc. will NOT be recoded.
/// In order to recode images,
/// use `misc_set_draft` and pass `Image` as the viewtype.
#[expect(clippy::too_many_arguments)]
async fn misc_send_msg(
&self,
account_id: u32,
chat_id: u32,
text: Option<String>,
file: Option<String>,
filename: Option<String>,
location: Option<(f64, f64)>,
quoted_message_id: Option<u32>,
) -> Result<(u32, MessageObject)> {
@@ -2378,7 +2160,7 @@ impl CommandApi {
});
message.set_text(text.unwrap_or_default());
if let Some(file) = file {
message.set_file_and_deduplicate(&ctx, Path::new(&file), filename.as_deref(), None)?;
message.set_file(file, None);
}
if let Some((latitude, longitude)) = location {
message.set_location(latitude, longitude);
@@ -2396,9 +2178,7 @@ impl CommandApi {
.await?;
}
let msg_id = chat::send_msg(&ctx, ChatId::new(chat_id), &mut message).await?;
let message = MessageObject::from_msg_id(&ctx, msg_id)
.await?
.context("Just sent message does not exist")?;
let message = MessageObject::from_msg_id(&ctx, msg_id).await?;
Ok((msg_id.to_u32(), message))
}
@@ -2406,14 +2186,12 @@ impl CommandApi {
// the better version should support:
// - changing viewtype to enable/disable compression
// - keeping same message id as long as attachment does not change for webxdc messages
#[expect(clippy::too_many_arguments)]
async fn misc_set_draft(
&self,
account_id: u32,
chat_id: u32,
text: Option<String>,
file: Option<String>,
filename: Option<String>,
quoted_message_id: Option<u32>,
view_type: Option<MessageViewtype>,
) -> Result<()> {
@@ -2430,7 +2208,7 @@ impl CommandApi {
));
draft.set_text(text.unwrap_or_default());
if let Some(file) = file {
draft.set_file_and_deduplicate(&ctx, Path::new(&file), filename.as_deref(), None)?;
draft.set_file(file, None);
}
if let Some(id) = quoted_message_id {
draft

View File

@@ -17,9 +17,6 @@ pub enum Account {
// size: u32,
profile_image: Option<String>, // TODO: This needs to be converted to work with blob http server.
color: String,
/// Optional tag as "Work", "Family".
/// Meant to help profile owner to differ between profiles with similar names.
private_tag: Option<String>,
},
#[serde(rename_all = "camelCase")]
Unconfigured { id: u32 },
@@ -34,14 +31,12 @@ impl Account {
let color = color_int_to_hex_string(
Contact::get_by_id(ctx, ContactId::SELF).await?.get_color(),
);
let private_tag = ctx.get_config(Config::PrivateTag).await?;
Ok(Account::Configured {
id,
display_name,
addr,
profile_image,
color,
private_tag,
})
} else {
Ok(Account::Unconfigured { id })

View File

@@ -1,7 +1,7 @@
use std::time::{Duration, SystemTime};
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::constants::Chattype;
use deltachat::contact::{Contact, ContactId};
@@ -30,42 +30,14 @@ pub struct FullChat {
/// in the contact profile
/// if 1:1 chat with this contact exists and is protected.
is_protected: bool,
/// True if the chat is encrypted.
/// This means that all messages in the chat are encrypted,
/// and all contacts in the chat are "key-contacts",
/// 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,
profile_image: Option<String>, //BLOBS ?
archived: bool,
pinned: bool,
// subtitle - will be moved to frontend because it uses translation functions
chat_type: u32,
is_unpromoted: bool,
is_self_talk: bool,
contacts: Vec<ContactObject>,
contact_ids: Vec<u32>,
/// Contact IDs of the past chat members.
past_contact_ids: Vec<u32>,
color: String,
fresh_message_counter: usize,
// is_group - please check over chat.type in frontend instead
@@ -86,7 +58,6 @@ impl FullChat {
let chat = Chat::load_from_db(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());
@@ -131,16 +102,13 @@ impl FullChat {
id: chat_id,
name: chat.name.clone(),
is_protected: chat.is_protected(),
is_encrypted: chat.is_encrypted(context).await?,
profile_image, //BLOBS ?
archived: chat.get_visibility() == chat::ChatVisibility::Archived,
pinned: chat.get_visibility() == chat::ChatVisibility::Pinned,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
is_unpromoted: chat.is_unpromoted(),
is_self_talk: chat.is_self_talk(),
contacts,
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,
fresh_message_counter,
is_contact_request: chat.is_contact_request(),
@@ -183,33 +151,8 @@ pub struct BasicChat {
/// in the contact profile
/// if 1:1 chat with this contact exists and is protected.
is_protected: bool,
/// True if the chat is encrypted.
/// This means that all messages in the chat are encrypted,
/// and all contacts in the chat are "key-contacts",
/// 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,
profile_image: Option<String>, //BLOBS ?
archived: bool,
pinned: bool,
chat_type: u32,
is_unpromoted: bool,
is_self_talk: bool,
@@ -235,10 +178,8 @@ impl BasicChat {
id: chat_id,
name: chat.name.clone(),
is_protected: chat.is_protected(),
is_encrypted: chat.is_encrypted(context).await?,
profile_image, //BLOBS ?
archived: chat.get_visibility() == chat::ChatVisibility::Archived,
pinned: chat.get_visibility() == chat::ChatVisibility::Pinned,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
is_unpromoted: chat.is_unpromoted(),
is_self_talk: chat.is_self_talk(),

View File

@@ -23,7 +23,6 @@ pub enum ChatListItemFetchResult {
name: String,
avatar_path: Option<String>,
color: String,
chat_type: u32,
last_updated: Option<i64>,
summary_text1: String,
summary_text2: String,
@@ -31,31 +30,6 @@ pub enum ChatListItemFetchResult {
/// showing preview if last chat message is image
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,
fresh_message_counter: usize,
is_self_talk: bool,
@@ -66,6 +40,8 @@ pub enum ChatListItemFetchResult {
is_pinned: bool,
is_muted: 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)
dm_chat_contact: Option<u32>,
was_seen_recently: bool,
@@ -112,17 +88,11 @@ pub(crate) async fn get_chat_list_item_by_id(
let (last_updated, message_type) = match last_msgid {
Some(id) => {
if let Some(last_message) =
deltachat::message::Message::load_from_db_optional(ctx, id).await?
{
(
Some(last_message.get_timestamp() * 1000),
Some(last_message.get_viewtype().into()),
)
} else {
// Message may be deleted by the time we try to load it.
(None, None)
}
let last_message = deltachat::message::Message::load_from_db(ctx, id).await?;
(
Some(last_message.get_timestamp() * 1000),
Some(last_message.get_viewtype().into()),
)
}
None => (None, None),
};
@@ -155,14 +125,12 @@ pub(crate) async fn get_chat_list_item_by_id(
name: chat.get_name().to_owned(),
avatar_path,
color,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
last_updated,
summary_text1,
summary_text2,
summary_status: summary.state.to_u32().expect("impossible"), // idea and a function to transform the constant to strings? or return string enum
summary_preview_image,
is_protected: chat.is_protected(),
is_encrypted: chat.is_encrypted(ctx).await?,
is_group: chat.get_type() == Chattype::Group,
fresh_message_counter,
is_self_talk: chat.is_self_talk(),
@@ -173,6 +141,7 @@ pub(crate) async fn get_chat_list_item_by_id(
is_pinned: visibility == ChatVisibility::Pinned,
is_muted: chat.is_muted(),
is_contact_request: chat.is_contact_request(),
is_broadcast: chat.get_type() == Chattype::Broadcast,
dm_chat_contact,
was_seen_recently,
last_message_type: message_type,

View File

@@ -20,17 +20,6 @@ pub struct ContactObject {
name_and_addr: String,
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,
/// True if the contact can be added to verified groups.
///
/// If this is true
@@ -77,7 +66,6 @@ impl ContactObject {
let verifier_id = contact
.get_verifier_id(context)
.await?
.flatten()
.map(|contact_id| contact_id.to_u32());
Ok(ContactObject {
@@ -91,8 +79,6 @@ impl ContactObject {
profile_image, //BLOBS
name_and_addr: contact.get_name_n_addr(),
is_blocked: contact.is_blocked(),
is_key_contact: contact.is_key_contact(),
e2ee_avail: contact.e2ee_avail(context).await?,
is_verified,
is_profile_verified,
verifier_id,

View File

@@ -69,7 +69,7 @@ pub enum EventType {
/// or for functions that are expected to fail (eg. autocryptContinueKeyTransfer())
/// it might be better to delay showing these events until the function has really
/// failed (returned false). It should be sufficient to report only the *last* error
/// in a message box then.
/// in a messasge box then.
Error { msg: String },
/// An action cannot be performed because the user is not in the group.
@@ -84,78 +84,26 @@ pub enum EventType {
/// - Messages sent, received or removed
/// - Chats created, deleted or archived
/// - 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")]
MsgsChanged {
/// 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,
},
MsgsChanged { chat_id: u32, msg_id: u32 },
/// Reactions for the message changed.
#[serde(rename_all = "camelCase")]
ReactionsChanged {
/// ID of the chat which the message belongs to.
chat_id: u32,
/// ID of the message for which reactions were changed.
msg_id: u32,
/// ID of the contact whose reaction set is changed.
contact_id: u32,
},
/// A reaction to one's own sent message received.
/// Typically, the UI will show a notification for that.
///
/// In addition to this event, ReactionsChanged is emitted.
#[serde(rename_all = "camelCase")]
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,
/// ID of the message for which reactions were changed.
msg_id: u32,
/// The reaction.
reaction: String,
},
/// Incoming webxdc info or summary update, should be notified.
#[serde(rename_all = "camelCase")]
IncomingWebxdcNotify {
/// ID of the chat.
chat_id: u32,
/// ID of the contact sending.
contact_id: u32,
/// ID of the added info message or webxdc instance in case of summary change.
msg_id: u32,
/// Text to notify.
text: String,
/// Link assigned to this notification, if any.
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.
///
/// There is no extra #DC_EVENT_MSGS_CHANGED event sent together with this event.
#[serde(rename_all = "camelCase")]
IncomingMsg {
/// ID of the chat where the message is assigned.
chat_id: u32,
/// ID of the message.
msg_id: u32,
},
IncomingMsg { chat_id: u32, msg_id: u32 },
/// Downloading a bunch of messages just finished. This is an
/// event to allow the UI to only show one notification per message bunch,
@@ -171,57 +119,21 @@ pub enum EventType {
/// A single message is sent successfully. State changed from DC_STATE_OUT_PENDING to
/// DC_STATE_OUT_DELIVERED, see `Message.state`.
#[serde(rename_all = "camelCase")]
MsgDelivered {
/// ID of the chat which the message belongs to.
chat_id: u32,
/// ID of the message that was successfully sent.
msg_id: u32,
},
MsgDelivered { chat_id: u32, msg_id: u32 },
/// 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`.
#[serde(rename_all = "camelCase")]
MsgFailed {
/// ID of the chat which the message belongs to.
chat_id: u32,
/// ID of the message that could not be sent.
msg_id: u32,
},
MsgFailed { chat_id: u32, msg_id: u32 },
/// A single message is read by the receiver. State changed from DC_STATE_OUT_DELIVERED to
/// DC_STATE_OUT_MDN_RCVD, see `Message.state`.
#[serde(rename_all = "camelCase")]
MsgRead {
/// ID of the chat which the message belongs to.
chat_id: u32,
MsgRead { chat_id: u32, msg_id: u32 },
/// ID of the message that was read.
msg_id: u32,
},
/// A single message was deleted.
///
/// This event means that the message will no longer appear in the messagelist.
/// 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.
/// A single message is deleted.
#[serde(rename_all = "camelCase")]
MsgDeleted {
/// 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,
},
MsgDeleted { chat_id: u32, msg_id: u32 },
/// 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.
@@ -235,35 +147,21 @@ pub enum EventType {
/// Chat ephemeral timer changed.
#[serde(rename_all = "camelCase")]
ChatEphemeralTimerModified {
/// Chat ID.
chat_id: u32,
/// New ephemeral timer value.
timer: u32,
},
/// Chat deleted.
ChatDeleted {
/// Chat ID.
chat_id: u32,
},
ChatEphemeralTimerModified { chat_id: u32, timer: u32 },
/// 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")]
ContactsChanged {
/// If set, this is the contact_id of an added contact that should be selected.
contact_id: Option<u32>,
},
ContactsChanged { contact_id: Option<u32> },
/// 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")]
LocationChanged {
/// 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>,
},
LocationChanged { contact_id: Option<u32> },
/// Inform about the configuration progress started by configure().
ConfigureProgress {
@@ -278,11 +176,10 @@ pub enum EventType {
/// 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")]
ImexProgress {
/// 0=error, 1-999=progress in permille, 1000=success and done
progress: usize,
},
ImexProgress { progress: usize },
/// 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().
@@ -299,34 +196,26 @@ pub enum EventType {
///
/// These events are typically sent after a joiner has scanned the QR code
/// 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")]
SecurejoinInviterProgress {
/// ID of the contact that wants to join.
contact_id: u32,
/// Progress as:
/// 300=vg-/vc-request received, typically shown as "bob@addr joins".
/// 600=vg-/vc-request-with-auth received, vg-member-added/vc-contact-confirm sent, typically shown as "bob@addr verified".
/// 800=contact added to chat, shown as "bob@addr securely joined GROUP". Only for the verified-group-protocol.
/// 1000=Protocol finished for this contact.
progress: usize,
},
SecurejoinInviterProgress { contact_id: u32, progress: usize },
/// Progress information of a secure-join handshake from the view of the joiner
/// (Bob, the person who scans the QR code).
/// The events are typically sent while secureJoin(), which
/// 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")]
SecurejoinJoinerProgress {
/// 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: usize,
},
SecurejoinJoinerProgress { contact_id: u32, progress: usize },
/// The connectivity to the server changed.
/// This means that you should refresh the connectivity view
@@ -347,37 +236,17 @@ pub enum EventType {
#[serde(rename_all = "camelCase")]
WebxdcStatusUpdate {
/// Message ID.
msg_id: u32,
/// Status update ID.
status_update_serial: u32,
},
/// Data received over an ephemeral peer channel.
#[serde(rename_all = "camelCase")]
WebxdcRealtimeData {
/// Message ID.
msg_id: u32,
/// Realtime data.
data: Vec<u8>,
},
/// Advertisement received over an ephemeral peer channel.
/// This can be used by bots to initiate peer-to-peer communication from their side.
#[serde(rename_all = "camelCase")]
WebxdcRealtimeAdvertisementReceived {
/// Message ID of the webxdc instance.
msg_id: u32,
},
WebxdcRealtimeData { msg_id: u32, data: Vec<u8> },
/// Inform that a message containing a webxdc instance has been deleted
#[serde(rename_all = "camelCase")]
WebxdcInstanceDeleted {
/// ID of the deleted message.
msg_id: u32,
},
WebxdcInstanceDeleted { msg_id: u32 },
/// 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
@@ -393,30 +262,10 @@ pub enum EventType {
/// 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.
#[serde(rename_all = "camelCase")]
ChatlistItemChanged {
/// 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)
///
/// This event is only emitted by the account manager
AccountsChanged,
/// Inform that an account property that might be shown in the account list changed, namely:
/// - is_configured (see is_configured())
/// - displayname
/// - selfavatar
/// - private_tag
///
/// This event is emitted from the account whose property changed.
AccountsItemChanged,
ChatlistItemChanged { chat_id: Option<u32> },
/// Inform than some events have been skipped due to event channel overflow.
EventChannelOverflow {
/// Number of events skipped.
n: u64,
},
EventChannelOverflow { n: u64 },
}
impl From<CoreEventType> for EventType {
@@ -448,30 +297,6 @@ impl From<CoreEventType> for EventType {
msg_id: msg_id.to_u32(),
contact_id: contact_id.to_u32(),
},
CoreEventType::IncomingReaction {
chat_id,
contact_id,
msg_id,
reaction,
} => IncomingReaction {
chat_id: chat_id.to_u32(),
contact_id: contact_id.to_u32(),
msg_id: msg_id.to_u32(),
reaction: reaction.as_str().to_string(),
},
CoreEventType::IncomingWebxdcNotify {
chat_id,
contact_id,
msg_id,
text,
href,
} => IncomingWebxdcNotify {
chat_id: chat_id.to_u32(),
contact_id: contact_id.to_u32(),
msg_id: msg_id.to_u32(),
text,
href,
},
CoreEventType::IncomingMsg { chat_id, msg_id } => IncomingMsg {
chat_id: chat_id.to_u32(),
msg_id: msg_id.to_u32(),
@@ -505,9 +330,6 @@ impl From<CoreEventType> for EventType {
timer: timer.to_u32(),
}
}
CoreEventType::ChatDeleted { chat_id } => ChatDeleted {
chat_id: chat_id.to_u32(),
},
CoreEventType::ContactsChanged(contact) => ContactsChanged {
contact_id: contact.map(|c| c.to_u32()),
},
@@ -551,11 +373,6 @@ impl From<CoreEventType> for EventType {
msg_id: msg_id.to_u32(),
data,
},
CoreEventType::WebxdcRealtimeAdvertisementReceived { msg_id } => {
WebxdcRealtimeAdvertisementReceived {
msg_id: msg_id.to_u32(),
}
}
CoreEventType::WebxdcInstanceDeleted { msg_id } => WebxdcInstanceDeleted {
msg_id: msg_id.to_u32(),
},
@@ -565,11 +382,6 @@ impl From<CoreEventType> for EventType {
},
CoreEventType::ChatlistChanged => ChatlistChanged,
CoreEventType::EventChannelOverflow { n } => EventChannelOverflow { n },
CoreEventType::AccountsChanged => AccountsChanged,
CoreEventType::AccountsItemChanged => AccountsItemChanged,
#[allow(unreachable_patterns)]
#[cfg(test)]
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),
}
}
}

View File

@@ -1,203 +0,0 @@
use anyhow::Result;
use deltachat::login_param as dc;
use serde::Deserialize;
use serde::Serialize;
use yerpc::TypeDef;
/// 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 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::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_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::EnteredServerLoginParam {
server: param.imap_server.unwrap_or_default(),
port: param.imap_port.unwrap_or_default(),
security: param.imap_security.unwrap_or_default().into(),
user: param.imap_user.unwrap_or_default(),
password: param.password,
},
smtp: dc::EnteredServerLoginParam {
server: param.smtp_server.unwrap_or_default(),
port: param.smtp_port.unwrap_or_default(),
security: param.smtp_security.unwrap_or_default().into(),
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 anyhow::{Context as _, Result};
use deltachat::chat::Chat;
@@ -19,10 +17,10 @@ use typescript_type_def::TypeDef;
use super::color_int_to_hex_string;
use super::contact::ContactObject;
use super::reactions::JSONRPCReactions;
use super::webxdc::WebxdcMessageInfo;
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", tag = "kind")]
#[expect(clippy::large_enum_variant)]
pub enum MessageLoadResult {
Message(MessageObject),
LoadingError { error: String },
@@ -39,8 +37,6 @@ pub struct MessageObject {
text: String,
is_edited: bool,
/// Check if a message has a POI location bound to it.
/// These locations are also returned by `get_locations` method.
/// The UI may decide to display a special icon beside such messages.
@@ -59,13 +55,6 @@ pub struct MessageObject {
// summary - use/create another function if you need it
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,
is_setupmessage: bool,
is_info: bool,
@@ -77,9 +66,6 @@ pub struct MessageObject {
/// when is_info is true this describes what type of system message it is
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,
dimensions_height: i32,
dimensions_width: i32,
@@ -97,14 +83,10 @@ pub struct MessageObject {
file_bytes: u64,
file_name: Option<String>,
webxdc_href: Option<String>,
webxdc_info: Option<WebxdcMessageInfo>,
download_state: DownloadState,
original_msg_id: Option<u32>,
saved_message_id: Option<u32>,
reactions: Option<JSONRPCReactions>,
vcard_contact: Option<VcardContact>,
@@ -120,9 +102,6 @@ enum MessageQuote {
WithMessage {
text: String,
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_color: String,
override_sender_name: Option<String>,
@@ -133,10 +112,8 @@ enum MessageQuote {
}
impl MessageObject {
pub async fn from_msg_id(context: &Context, msg_id: MsgId) -> Result<Option<Self>> {
let Some(message) = Message::load_from_db_optional(context, msg_id).await? else {
return Ok(None);
};
pub async fn from_msg_id(context: &Context, msg_id: MsgId) -> Result<Self> {
let message = Message::load_from_db(context, msg_id).await?;
let sender_contact = Contact::get_by_id(context, message.get_from_id())
.await
@@ -147,6 +124,12 @@ impl MessageObject {
let file_bytes = message.get_filebytes(context).await?.unwrap_or_default();
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 download_state = message.download_state().into();
@@ -160,7 +143,6 @@ impl MessageObject {
Some(MessageQuote::WithMessage {
text: quoted_text,
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_color: color_int_to_hex_string(quote_author.get_color()),
override_sender_name: quote.get_override_sender_name(),
@@ -201,14 +183,13 @@ impl MessageObject {
.map(Into::into)
.collect();
let message_object = MessageObject {
Ok(MessageObject {
id: msg_id.to_u32(),
chat_id: message.get_chat_id().to_u32(),
from_id: message.get_from_id().to_u32(),
quote,
parent_id,
text: message.get_text(),
is_edited: message.is_edited(),
has_location: message.has_location(),
has_html: message.has_html(),
view_type: message.get_viewtype().into(),
@@ -230,10 +211,6 @@ impl MessageObject {
is_forwarded: message.is_forwarded(),
is_bot: message.is_bot(),
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(),
dimensions_height: message.get_height(),
@@ -260,28 +237,14 @@ impl MessageObject {
file_mime: message.get_filemime(),
file_bytes,
file_name: message.get_filename(),
// On a WebxdcInfoMessage this might include a hash holding
// information about a specific position or state in a webxdc app
webxdc_href: message.get_webxdc_href(),
webxdc_info,
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,
vcard_contact: vcard_contacts.first().cloned(),
};
Ok(Some(message_object))
})
}
}
@@ -301,9 +264,6 @@ pub enum MessageViewtype {
Gif,
/// Message containing a sticker, similar to image.
/// NB: When sending, the message viewtype may be changed to `Image` by some heuristics like
/// checking for transparent pixels. Use `Message::force_sticker()` to disable them.
///
/// If possible, the ui should display the image without borders in a transparent way.
/// A click on a sticker will offer to install the sticker set in some future.
Sticker,
@@ -416,9 +376,6 @@ pub enum SystemMessageType {
/// Chat ephemeral message timer is changed.
EphemeralTimerChanged,
// Chat is e2ee
ChatE2ee,
// Chat protection state changed
ChatProtectionEnabled,
ChatProtectionDisabled,
@@ -453,7 +410,6 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
SystemMessage::LocationStreamingEnabled => SystemMessageType::LocationStreamingEnabled,
SystemMessage::LocationOnly => SystemMessageType::LocationOnly,
SystemMessage::EphemeralTimerChanged => SystemMessageType::EphemeralTimerChanged,
SystemMessage::ChatE2ee => SystemMessageType::ChatE2ee,
SystemMessage::ChatProtectionEnabled => SystemMessageType::ChatProtectionEnabled,
SystemMessage::ChatProtectionDisabled => SystemMessageType::ChatProtectionDisabled,
SystemMessage::MultiDeviceSync => SystemMessageType::MultiDeviceSync,
@@ -534,7 +490,6 @@ pub struct MessageSearchResult {
author_name: String,
author_color: String,
author_id: u32,
chat_id: u32,
chat_profile_image: Option<String>,
chat_color: String,
chat_name: String,
@@ -574,7 +529,6 @@ impl MessageSearchResult {
author_name,
author_color: color_int_to_hex_string(sender.get_color()),
author_id: sender.id.to_u32(),
chat_id: chat.id.to_u32(),
chat_name: chat.get_name().to_owned(),
chat_color,
chat_type: chat.get_type().to_u32().context("unknown chat type id")?,
@@ -621,12 +575,9 @@ pub struct MessageData {
pub html: Option<String>,
pub viewtype: Option<MessageViewtype>,
pub file: Option<String>,
pub filename: Option<String>,
pub location: Option<(f64, f64)>,
pub override_sender_name: Option<String>,
/// Quoted message id. Takes preference over `quoted_text` (see below).
pub quoted_message_id: Option<u32>,
pub quoted_text: Option<String>,
}
impl MessageData {
@@ -646,27 +597,22 @@ impl MessageData {
message.set_override_sender_name(self.override_sender_name);
}
if let Some(file) = self.file {
message.set_file_and_deduplicate(
context,
Path::new(&file),
self.filename.as_deref(),
None,
)?;
message.set_file(file, None);
}
if let Some((latitude, longitude)) = self.location {
message.set_location(latitude, longitude);
}
if let Some(id) = self.quoted_message_id {
let quoted_message = Message::load_from_db(context, MsgId::new(id))
.await
.context("Failed to load quoted message")?;
message
.set_quote(context, Some(&quoted_message))
.await
.context("Failed to set quote")?;
} else if let Some(text) = self.quoted_text {
let protect = false;
message.set_quote_text(Some((text, protect)));
.set_quote(
context,
Some(
&Message::load_from_db(context, MsgId::new(id))
.await
.context("message to quote could not be loaded")?,
),
)
.await?;
}
Ok(message)
}
@@ -682,18 +628,20 @@ pub struct MessageReadReceipt {
#[derive(Serialize, TypeDef, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct MessageInfo {
rawtext: String,
ephemeral_timer: EphemeralTimer,
/// When message is ephemeral this contains the timestamp of the message expiry
ephemeral_timestamp: Option<i64>,
error: Option<String>,
rfc724_mid: String,
server_urls: Vec<String>,
hop_info: String,
hop_info: Option<String>,
}
impl MessageInfo {
pub async fn from_msg_id(context: &Context, msg_id: MsgId) -> Result<Self> {
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_timestamp = match message.get_ephemeral_timer() {
deltachat::ephemeral::Timer::Disabled => None,
@@ -706,6 +654,7 @@ impl MessageInfo {
let hop_info = msg_id.hop_info(context).await?;
Ok(Self {
rawtext,
ephemeral_timer,
ephemeral_timestamp,
error: message.error(),

View File

@@ -5,7 +5,6 @@ pub mod contact;
pub mod events;
pub mod http;
pub mod location;
pub mod login_param;
pub mod message;
pub mod provider_info;
pub mod qr;

View File

@@ -6,165 +6,77 @@ use typescript_type_def::TypeDef;
#[serde(rename = "Qr", rename_all = "camelCase")]
#[serde(tag = "kind")]
pub enum QrObject {
/// Ask the user whether to verify the contact.
///
/// If the user agrees, pass this QR code to [`crate::securejoin::join_securejoin`].
AskVerifyContact {
/// ID of the contact.
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 whether to join the group.
AskVerifyGroup {
/// Group name.
grpname: String,
/// Group ID.
grpid: String,
/// ID of the contact.
contact_id: u32,
/// Fingerprint of the contact key as scanned from the QR code.
fingerprint: String,
/// Invite number.
invitenumber: String,
/// Authentication code.
authcode: String,
},
/// Contact fingerprint is verified.
///
/// Ask the user if they want to start chatting.
FprOk {
/// Contact ID.
contact_id: u32,
},
/// Scanned fingerprint does not match the last seen fingerprint.
FprMismatch {
/// Contact ID.
contact_id: Option<u32>,
},
/// The scanned QR code contains a fingerprint but no e-mail address.
FprWithoutAddr {
/// Key fingerprint.
fingerprint: String,
},
/// Ask the user if they want to create an account on the given domain.
Account {
/// Server domain name.
domain: String,
},
/// Provides a backup that can be retrieved using iroh-net based backup transfer protocol.
Backup2 {
/// Authentication token.
auth_token: String,
/// Iroh node address.
node_addr: String,
Backup {
ticket: String,
},
BackupTooNew {},
/// Ask the user if they want to use the given service for video chats.
WebrtcInstance {
domain: String,
instance_pattern: String,
},
/// Ask the user if they want to use the given proxy.
///
/// Note that HTTP(S) URLs without a path
/// and query parameters are treated as HTTP(S) proxy URL.
/// UI may want to still offer to open the URL
/// in the browser if QR code contents
/// starts with `http://` or `https://`
/// and the QR code was not scanned from
/// the proxy configuration screen.
Proxy {
/// Proxy URL.
///
/// This is the URL that is going to be added.
url: String,
/// Host extracted from the URL to display in the UI.
host: String,
/// Port extracted from the URL to display in the UI.
port: u16,
},
/// Contact address is scanned.
///
/// Optionally, a draft message could be provided.
/// Ask the user if they want to start chatting.
Addr {
/// Contact ID.
contact_id: u32,
/// Draft message.
draft: Option<String>,
},
/// URL scanned.
///
/// Ask the user if they want to open a browser or copy the URL to clipboard.
Url {
url: String,
},
/// Text scanned.
///
/// Ask the user if they want to copy the text to clipboard.
Text {
text: String,
},
/// Ask the user if they want to withdraw their own QR code.
WithdrawVerifyContact {
/// Contact ID.
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 withdraw their own group invite QR code.
WithdrawVerifyGroup {
/// Group name.
grpname: String,
/// Group ID.
grpid: String,
/// Contact ID.
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.
ReviveVerifyContact {
/// Contact ID.
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 group invite QR code.
ReviveVerifyGroup {
/// Contact ID.
grpname: String,
/// Group ID.
grpid: String,
/// Contact ID.
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.
///
/// Ask the user if they want to login with the email address.
Login {
address: String,
},
@@ -217,14 +129,9 @@ impl From<Qr> for QrObject {
}
Qr::FprWithoutAddr { fingerprint } => QrObject::FprWithoutAddr { fingerprint },
Qr::Account { domain } => QrObject::Account { domain },
Qr::Backup2 {
ref node_addr,
auth_token,
} => QrObject::Backup2 {
node_addr: serde_json::to_string(node_addr).unwrap_or_default(),
auth_token,
Qr::Backup { ticket } => QrObject::Backup {
ticket: ticket.to_string(),
},
Qr::BackupTooNew {} => QrObject::BackupTooNew {},
Qr::WebrtcInstance {
domain,
instance_pattern,
@@ -232,7 +139,6 @@ impl From<Qr> for QrObject {
domain,
instance_pattern,
},
Qr::Proxy { url, host, port } => QrObject::Proxy { url, host, port },
Qr::Addr { contact_id, draft } => {
let contact_id = contact_id.to_u32();
QrObject::Addr { contact_id, draft }

View File

@@ -35,14 +35,6 @@ pub struct WebxdcMessageInfo {
source_code_url: Option<String>,
/// True if full internet access should be granted to the app.
internet_access: bool,
/// Address to be used for `window.webxdc.selfAddr` in JS land.
self_addr: String,
/// Milliseconds to wait before calling `sendUpdate()` again since the last call.
/// Should be exposed to `window.sendUpdateInterval` in JS land.
send_update_interval: usize,
/// Maximum number of bytes accepted for a serialized update object.
/// Should be exposed to `window.sendUpdateMaxSize` in JS land.
send_update_max_size: usize,
}
impl WebxdcMessageInfo {
@@ -57,11 +49,7 @@ impl WebxdcMessageInfo {
document,
summary,
source_code_url,
request_integration: _,
internet_access,
self_addr,
send_update_interval,
send_update_max_size,
} = message.get_webxdc_info(context).await?;
Ok(Self {
@@ -71,9 +59,6 @@ impl WebxdcMessageInfo {
summary: maybe_empty_string_to_option(summary),
source_code_url: maybe_empty_string_to_option(source_code_url),
internet_access,
self_addr,
send_update_interval,
send_update_max_size,
})
}
}

View File

@@ -1,6 +1,4 @@
#![recursion_limit = "256"]
#![cfg_attr(not(test), forbid(clippy::indexing_slicing))]
#![cfg_attr(not(test), forbid(clippy::string_slice))]
pub mod api;
pub use yerpc;
@@ -85,7 +83,7 @@ mod tests {
assert_eq!(result, response.to_owned());
}
{
let request = r#"{"jsonrpc":"2.0","method":"batch_set_config","id":2,"params":[1,{"addr":"","mail_user":"","mail_pw":"","mail_server":"","mail_port":"","mail_security":"","imap_certificate_checks":"","send_user":"","send_pw":"","send_server":"","send_port":"","send_security":"","smtp_certificate_checks":""}]}"#;
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":"","socks5_enabled":"0","socks5_host":"","socks5_port":"","socks5_user":"","socks5_password":""}]}"#;
let response = r#"{"jsonrpc":"2.0","id":2,"result":null}"#;
session.handle_incoming(request).await;
let result = receiver.recv().await?;

View File

@@ -0,0 +1,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>",
"dependencies": {
"@deltachat/tiny-emitter": "3.0.0",
"isomorphic-ws": "^5.0.0",
"yerpc": "^0.6.2"
"isomorphic-ws": "^4.0.1",
"yerpc": "^0.4.3"
},
"devDependencies": {
"@types/chai": "^4.3.10",
"@types/chai-as-promised": "^7.1.8",
"@types/mocha": "^10.0.4",
"@types/ws": "^8.5.9",
"c8": "^8.0.1",
"@types/chai": "^4.2.21",
"@types/chai-as-promised": "^7.1.5",
"@types/mocha": "^9.0.0",
"@types/ws": "^7.2.4",
"c8": "^7.10.0",
"chai": "^4.3.4",
"chai-as-promised": "^7.1.1",
"esbuild": "^0.25.5",
"esbuild": "^0.17.9",
"http-server": "^14.1.1",
"mocha": "^10.2.0",
"mocha": "^9.1.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"typedoc": "^0.28.5",
"typescript": "^5.8.3",
"prettier": "^2.6.2",
"typedoc": "^0.23.2",
"typescript": "^4.5.5",
"ws": "^8.5.0"
},
"exports": {
@@ -34,7 +34,7 @@
"name": "@deltachat/jsonrpc-client",
"repository": {
"type": "git",
"url": "https://github.com/chatmail/core.git"
"url": "https://github.com/deltachat/deltachat-core-rust.git"
},
"scripts": {
"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:tsc": "tsc",
"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",
"generate-bindings": "cargo test",
"prettier:check": "prettier --check .",
@@ -54,5 +58,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "2.4.0"
"version": "1.140.0"
}

View File

@@ -5,24 +5,24 @@ const json = JSON.parse(readFileSync("./coverage/coverage-final.json"));
const jsonCoverage =
json[Object.keys(json).find((k) => k.includes(generatedFile))];
const fnMap = Object.keys(jsonCoverage.fnMap).map(
(key) => jsonCoverage.fnMap[key],
(key) => jsonCoverage.fnMap[key]
);
const htmlCoverage = readFileSync(
"./coverage/" + generatedFile + ".html",
"utf8",
"utf8"
);
const uncoveredLines = htmlCoverage
.split("\n")
.filter((line) => line.includes(`"function not covered"`));
const uncoveredFunctions = uncoveredLines.map(
(line) => />([\w_]+)\(/.exec(line)[1],
(line) => />([\w_]+)\(/.exec(line)[1]
);
console.log(
"\nUncovered api functions:\n" +
uncoveredFunctions
.map((uF) => fnMap.find(({ name }) => name === uF))
.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
.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) => {
if (lhs.key < rhs.key) return -1;
@@ -50,5 +50,5 @@ const constants = data
writeFileSync(
resolve(__dirname, "../generated/constants.ts"),
`// Generated!\n\nexport enum C {\n${constants.replace(/:/g, " =")},\n}\n`,
`// Generated!\n\nexport enum C {\n${constants.replace(/:/g, " =")},\n}\n`
);

View File

@@ -2,19 +2,19 @@ import * as T from "../generated/types.js";
import { EventType } from "../generated/types.js";
import * as RPC from "../generated/jsonrpc.js";
import { RawClient } from "../generated/client.js";
import { BaseTransport, Request } from "yerpc";
import { WebsocketTransport, BaseTransport, Request } from "yerpc";
import { TinyEmitter } from "@deltachat/tiny-emitter";
type Events = { ALL: (accountId: number, event: EventType) => void } & {
[Property in EventType["kind"]]: (
accountId: number,
event: Extract<EventType, { kind: Property }>,
event: Extract<EventType, { kind: Property }>
) => void;
};
type ContextEvents = { ALL: (event: EventType) => void } & {
[Property in EventType["kind"]]: (
event: Extract<EventType, { kind: Property }>,
event: Extract<EventType, { kind: Property }>
) => void;
};
@@ -25,7 +25,7 @@ export type DcEventType<T extends EventType["kind"]> = Extract<
>;
export class BaseDeltaChat<
Transport extends BaseTransport<any>,
Transport extends BaseTransport<any>
> extends TinyEmitter<Events> {
rpc: RawClient;
account?: T.Account;
@@ -34,10 +34,7 @@ export class BaseDeltaChat<
//@ts-ignore
private eventTask: Promise<void>;
constructor(
public transport: Transport,
startEventLoop: boolean,
) {
constructor(public transport: Transport, startEventLoop: boolean) {
super();
this.rpc = new RawClient(this.transport);
if (startEventLoop) {
@@ -56,7 +53,7 @@ export class BaseDeltaChat<
this.contextEmitters[event.contextId].emit(
event.event.kind,
//@ts-ignore
event.event as any,
event.event as any
);
this.contextEmitters[event.contextId].emit("ALL", event.event as any);
}
@@ -77,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> {
close() {}
constructor(input: any, output: any, startEventLoop: boolean) {
@@ -86,10 +111,7 @@ export class StdioDeltaChat extends BaseDeltaChat<StdioTransport> {
}
export class StdioTransport extends BaseTransport {
constructor(
public input: any,
public output: any,
) {
constructor(public input: any, public output: any) {
super();
var buffer = "";

View File

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

View File

@@ -1,5 +1,5 @@
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";
const EVENT_TIMEOUT = 20000;
@@ -17,12 +17,12 @@ describe("online tests", function () {
if (process.env.COVERAGE && !process.env.COVERAGE_OFFLINE) {
console.error(
"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);
}
console.log(
"Missing CHATMAIL_DOMAIN environment variable!, skip integration tests",
"Missing CHATMAIL_DOMAIN environment variable!, skip integration tests"
);
this.skip();
}
@@ -36,7 +36,7 @@ describe("online tests", function () {
account1 = createTempUser(process.env.CHATMAIL_DOMAIN);
if (!account1 || !account1.email || !account1.password) {
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();
}
@@ -44,7 +44,7 @@ describe("online tests", function () {
account2 = createTempUser(process.env.CHATMAIL_DOMAIN);
if (!account2 || !account2.email || !account2.password) {
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();
}
@@ -80,10 +80,16 @@ describe("online tests", function () {
}
this.timeout(15000);
const vcard = await dc.rpc.makeVcard(accountId2, [C.DC_CONTACT_ID_SELF]);
const contactId = (await dc.rpc.importVcardContents(accountId1, vcard))[0];
const contactId = await dc.rpc.createContact(
accountId1,
account2.email,
null
);
const chatId = await dc.rpc.createChatByContactId(accountId1, contactId);
const eventPromise = waitForEvent(dc, "IncomingMsg", accountId2);
const eventPromise = Promise.race([
waitForEvent(dc, "MsgsChanged", accountId2),
waitForEvent(dc, "IncomingMsg", accountId2),
]);
await dc.rpc.miscSendTextMessage(accountId1, chatId, "Hello");
const { chatId: chatIdOnAccountB } = await eventPromise;
@@ -92,28 +98,31 @@ describe("online tests", function () {
accountId2,
chatIdOnAccountB,
false,
false,
false
);
// There are 2 messages in the chat:
// 'Messages are end-to-end encrypted' (info message) and 'Hello'
expect(messageList).have.length(2);
const message = await dc.rpc.getMessage(accountId2, messageList[1]);
expect(messageList).have.length(1);
const message = await dc.rpc.getMessage(accountId2, messageList[0]);
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) {
this.skip();
}
this.timeout(10000);
// send message from A to B
const vcard = await dc.rpc.makeVcard(accountId2, [C.DC_CONTACT_ID_SELF]);
const contactId = (await dc.rpc.importVcardContents(accountId1, vcard))[0];
const contactId = await dc.rpc.createContact(
accountId1,
account2.email,
null
);
const chatId = await dc.rpc.createChatByContactId(accountId1, contactId);
const eventPromise = waitForEvent(dc, "IncomingMsg", accountId2);
const eventPromise = Promise.race([
waitForEvent(dc, "MsgsChanged", accountId2),
waitForEvent(dc, "IncomingMsg", accountId2),
]);
dc.rpc.miscSendTextMessage(accountId1, chatId, "Hello2");
// wait for message from A
console.log("wait for message from A");
@@ -126,15 +135,18 @@ describe("online tests", function () {
accountId2,
chatIdOnAccountB,
false,
false,
false
);
const message = await dc.rpc.getMessage(
accountId2,
messageList.reverse()[0],
messageList.reverse()[0]
);
expect(message.text).equal("Hello2");
// Send message back from B to A
const eventPromise2 = waitForEvent(dc, "IncomingMsg", accountId1);
const eventPromise2 = Promise.race([
waitForEvent(dc, "MsgsChanged", accountId1),
waitForEvent(dc, "IncomingMsg", accountId1),
]);
dc.rpc.miscSendTextMessage(accountId2, chatId, "super secret message");
// Check if answer arrives at A and if it is encrypted
await eventPromise2;
@@ -152,7 +164,7 @@ describe("online tests", function () {
const info = await dc.rpc.getProviderInfo(acc, "example.com");
expect(info).to.be.not.null;
expect(info?.overviewPage).to.equal(
"https://providers.delta.chat/example-com",
"https://providers.delta.chat/example-com"
);
expect(info?.status).to.equal(3);
});
@@ -169,12 +181,12 @@ async function waitForEvent<T extends DcEvent["kind"]>(
dc: DeltaChat,
eventType: T,
accountId: number,
timeout: number = EVENT_TIMEOUT,
timeout: number = EVENT_TIMEOUT
): Promise<Extract<DcEvent, { kind: T }>> {
return new Promise((resolve, reject) => {
const rejectTimeout = setTimeout(
() => reject(new Error("Timeout reached before event came in")),
timeout,
timeout
);
const callback = (contextId: number, event: DcEvent) => {
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 pathToServerBinary = resolve(
join(await getTargetDir(), "debug/deltachat-rpc-server"),
join(await getTargetDir(), "debug/deltachat-rpc-server")
);
const server = spawn(pathToServerBinary, {
@@ -29,7 +29,7 @@ export async function startServer(): Promise<RpcServerHandle> {
throw new Error(
"Failed to start server executable " +
pathToServerBinary +
", make sure you built it first.",
", make sure you built it first."
);
});
let shouldClose = false;
@@ -83,7 +83,7 @@ function getTargetDir(): Promise<string> {
reject(error);
}
}
},
}
);
});
}

View File

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

View File

@@ -90,11 +90,6 @@ impl Ratelimit {
pub fn until_can_send(&self) -> Duration {
self.until_can_send_at(SystemTime::now())
}
/// Returns minimum possible update interval in milliseconds.
pub fn update_interval(&self) -> usize {
(self.window.as_millis() as f64 / self.quota) as usize
}
}
#[cfg(test)]
@@ -107,7 +102,6 @@ mod tests {
let mut ratelimit = Ratelimit::new_at(Duration::new(60, 0), 3.0, now);
assert!(ratelimit.can_send_at(now));
assert_eq!(ratelimit.update_interval(), 20_000);
// Send burst of 3 messages.
ratelimit.send_at(now);

View File

@@ -1,21 +1,20 @@
[package]
name = "deltachat-repl"
version = "2.4.0"
version = "1.140.0"
license = "MPL-2.0"
edition = "2021"
repository = "https://github.com/chatmail/core"
repository = "https://github.com/deltachat/deltachat-core-rust"
[dependencies]
anyhow = { workspace = true }
deltachat = { workspace = true, features = ["internals"]}
dirs = "6"
log = { workspace = true }
nu-ansi-term = { workspace = true }
qr2term = "0.3.3"
rusqlite = { workspace = true }
rustyline = "16"
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
ansi_term = "0.12.1"
anyhow = "1"
deltachat = { path = "..", features = ["internals"]}
dirs = "5"
log = "0.4.21"
rusqlite = "0.31"
rustyline = "14"
tokio = { version = "1.37.0", features = ["fs", "rt-multi-thread", "macros"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[features]
default = ["vendored"]

View File

@@ -20,8 +20,8 @@ use deltachat::log::LogExt;
use deltachat::message::{self, Message, MessageState, MsgId, Viewtype};
use deltachat::mimeparser::SystemMessage;
use deltachat::peer_channels::{send_webxdc_realtime_advertisement, send_webxdc_realtime_data};
use deltachat::peerstate::*;
use deltachat::qr::*;
use deltachat::qr_code_generator::create_qr_svg;
use deltachat::reaction::send_reaction;
use deltachat::receive_imf::*;
use deltachat::sql;
@@ -34,6 +34,14 @@ use tokio::fs;
/// e.g. bitmask 7 triggers actions defined with bits 1, 2 and 4.
async fn reset_tables(context: &Context, bits: i32) {
println!("Resetting tables ({bits})...");
if 0 != bits & 2 {
context
.sql()
.execute("DELETE FROM acpeerstates;", ())
.await
.unwrap();
println!("(2) Peerstates reset.");
}
if 0 != bits & 4 {
context
.sql()
@@ -83,11 +91,11 @@ async fn reset_tables(context: &Context, bits: i32) {
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?;
if let Err(err) = receive_imf(context, &data, false).await {
eprintln!("receive_imf errored: {err:?}");
println!("receive_imf errored: {err:?}");
}
Ok(())
}
@@ -111,13 +119,13 @@ async fn poke_spec(context: &Context, spec: Option<&str>) -> bool {
} else {
let rs = context.sql().get_raw_config("import_spec").await.unwrap();
if rs.is_none() {
eprintln!("Import: No file or folder given.");
error!(context, "Import: No file or folder given.");
return false;
}
real_spec = rs.unwrap();
}
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
}
} else {
@@ -131,16 +139,13 @@ async fn poke_spec(context: &Context, spec: Option<&str>) -> bool {
if name.ends_with(".eml") {
let path_plus_name = format!("{}/{}", &real_spec, name);
println!("Import: {path_plus_name}");
if poke_eml_file(context, Path::new(&path_plus_name))
.await
.is_ok()
{
if poke_eml_file(context, path_plus_name).await.is_ok() {
read_cnt += 1
}
}
}
} else {
eprintln!("Import: Cannot open directory \"{}\".", &real_spec);
error!(context, "Import: Cannot open directory \"{}\".", &real_spec);
return false;
}
}
@@ -268,7 +273,7 @@ async fn log_msglist(context: &Context, msglist: &[MsgId]) -> Result<()> {
async fn log_contactlist(context: &Context, contacts: &[ContactId]) -> Result<()> {
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 name = contact.get_display_name();
let addr = contact.get_addr();
@@ -287,6 +292,15 @@ async fn log_contactlist(context: &Context, contacts: &[ContactId]) -> Result<()
verified_str,
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);
}
@@ -324,7 +338,8 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
send-backup\n\
receive-backup <qr>\n\
export-keys\n\
import-keys <key-file>\n\
import-keys\n\
export-setup\n\
poke [<eml-file>|<folder>|<addr> <key-file>]\n\
reset <flags>\n\
stop\n\
@@ -333,13 +348,14 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
_ => println!(
"==========================Database commands==\n\
info\n\
open <file to open or create>\n\
close\n\
set <configuration-key> [<value>]\n\
get <configuration-key>\n\
oauth2\n\
configure\n\
connect\n\
disconnect\n\
fetch\n\
connectivity\n\
maybenetwork\n\
housekeeping\n\
@@ -347,24 +363,21 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
==============================Chat commands==\n\
listchats [<query>]\n\
listarchived\n\
start-realtime <msg-id>\n\
send-realtime <msg-id> <data>\n\
chat [<chat-id>|0]\n\
createchat <contact-id>\n\
creategroup <name>\n\
createbroadcast <name>\n\
createbroadcast\n\
createprotected <name>\n\
addmember <contact-id>\n\
removemember <contact-id>\n\
groupname <name>\n\
groupimage <image>\n\
groupimage [<file>]\n\
chatinfo\n\
sendlocations <seconds>\n\
setlocation <lat> <lng>\n\
dellocations\n\
getlocations [<contact-id>]\n\
send <text>\n\
sendempty\n\
sendimage <file> [<text>]\n\
sendsticker <file> [<text>]\n\
sendfile <file> [<text>]\n\
@@ -383,7 +396,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
unmute <chat-id>\n\
delchat <chat-id>\n\
accept <chat-id>\n\
blockchat <chat-id>\n\
decline <chat-id>\n\
===========================Message commands==\n\
listmsgs <query>\n\
msginfo <msg-id>\n\
@@ -397,9 +410,11 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
react <msg-id> [<reaction>]\n\
===========================Contact commands==\n\
listcontacts [<query>]\n\
listverified [<query>]\n\
addcontact [<name>] <addr>\n\
contactinfo <contact-id>\n\
delcontact <contact-id>\n\
cleanupcontacts\n\
block <contact-id>\n\
unblock <contact-id>\n\
listblocked\n\
@@ -410,7 +425,6 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
checkqr <qr-content>\n\
joinqr <qr-content>\n\
setqr <qr-content>\n\
createqrsvg <qr-content>\n\
providerinfo <addr>\n\
fileinfo <file>\n\
estimatedeletion <seconds>\n\
@@ -473,9 +487,8 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
}
"send-backup" => {
let provider = BackupProvider::prepare(&context).await?;
let qr = format_backup(&provider.qr())?;
println!("QR code: {qr}");
qr2term::print_qr(qr.as_str())?;
let qr = provider.qr();
println!("QR code: {}", format_backup(&qr)?);
provider.await?;
}
"receive-backup" => {
@@ -489,17 +502,24 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Exported to {}.", dir.to_string_lossy());
}
"import-keys" => {
ensure!(!arg1.is_empty(), "Argument <key-file> missing.");
imex(&context, ImexMode::ImportSelfKeys, arg1.as_ref(), None).await?;
}
"export-setup" => {
let setup_code = create_setup_code(&context);
let file_name = blobdir.join("autocrypt-setup-message.html");
let file_content = render_setup_file(&context, &setup_code).await?;
fs::write(&file_name, file_content).await?;
println!(
"Setup message written to: {}\nSetup code: {}",
file_name.display(),
&setup_code,
);
}
"poke" => {
ensure!(poke_spec(&context, Some(arg1)).await, "Poke failed");
}
"reset" => {
ensure!(
!arg1.is_empty(),
"Argument <bits> missing: 4=private keys, 8=rest but server config"
);
ensure!(!arg1.is_empty(), "Argument <bits> missing: 1=jobs, 2=peerstates, 4=private keys, 8=rest but server config");
let bits: i32 = arg1.parse()?;
ensure!(bits < 16, "<bits> must be lower than 16.");
reset_tables(&context, bits).await;
@@ -621,7 +641,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Location streaming enabled.");
}
println!("{cnt} chats");
eprintln!("{time_needed:?} to create this list");
println!("{time_needed:?} to create this list");
}
"start-realtime" => {
if arg1.is_empty() {
@@ -731,7 +751,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
chat::marknoticed_chat(&context, sel_chat.get_id()).await?;
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."
);
}
@@ -750,8 +770,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
println!("Group#{chat_id} created successfully.");
}
"createbroadcast" => {
ensure!(!arg1.is_empty(), "Argument <name> missing.");
let chat_id = chat::create_broadcast(&context, arg1.to_string()).await?;
let chat_id = chat::create_broadcast_list(&context).await?;
println!("Broadcast#{chat_id} created successfully.");
}
@@ -928,7 +947,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
} else {
Viewtype::File
});
msg.set_file_and_deduplicate(&context, Path::new(arg1), None, None)?;
msg.set_file(arg1, None);
msg.set_text(arg2.to_string());
chat::send_msg(&context, sel_chat.as_ref().unwrap().get_id(), &mut msg).await?;
}
@@ -958,7 +977,9 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"Arguments <msg-id> <json status update> expected"
);
let msg_id = MsgId::new(arg1.parse()?);
context.send_webxdc_status_update(msg_id, arg2).await?;
context
.send_webxdc_status_update(msg_id, arg2, "this is a webxdc status update")
.await?;
}
"videochat" => {
ensure!(sel_chat.is_some(), "No chat selected.");
@@ -985,13 +1006,14 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
},
query,
);
eprintln!("{time_needed:?} to create this list");
println!("{time_needed:?} to create this list");
}
"draft" => {
ensure!(sel_chat.is_some(), "No chat selected.");
if !arg1.is_empty() {
let mut draft = Message::new_text(arg1.to_string());
let mut draft = Message::new(Viewtype::Text);
draft.set_text(arg1.to_string());
sel_chat
.as_ref()
.unwrap()
@@ -1014,7 +1036,8 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
!arg1.is_empty(),
"Please specify text to add as device message."
);
let mut msg = Message::new_text(arg1.to_string());
let mut msg = Message::new(Viewtype::Text);
msg.set_text(arg1.to_string());
chat::add_device_msg(&context, None, Some(&mut msg)).await?;
}
"listmedia" => {
@@ -1148,13 +1171,19 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
let reaction = arg2;
send_reaction(&context, msg_id, reaction).await?;
}
"listcontacts" | "contacts" => {
let contacts = Contact::get_all(&context, DC_GCL_ADD_SELF, Some(arg1)).await?;
"listcontacts" | "contacts" | "listverified" => {
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?;
println!("{} key 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());
println!("{} contacts.", contacts.len());
}
"addcontact" => {
ensure!(!arg1.is_empty(), "Arguments [<name>] <addr> expected.");
@@ -1227,22 +1256,15 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
ensure!(!arg1.is_empty(), "Argument <qr-content> missing.");
match set_config_from_qr(&context, arg1).await {
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" => {
ensure!(!arg1.is_empty(), "Argument <qr-content> missing.");
let svg = create_qr_svg(arg1)?;
let file = dirs::home_dir().unwrap_or_default().join("qr.svg");
fs::write(&file, svg).await?;
println!("{file:#?} written.");
}
"providerinfo" => {
ensure!(!arg1.is_empty(), "Argument <addr> missing.");
let proxy_enabled = context
.get_config_bool(config::Config::ProxyEnabled)
let socks5_enabled = context
.get_config_bool(config::Config::Socks5Enabled)
.await?;
match provider::get_provider_info(&context, arg1, proxy_enabled).await {
match provider::get_provider_info(&context, arg1, socks5_enabled).await {
Some(info) => {
println!("Information for provider belonging to {arg1}:");
println!("status: {}", info.status as u32);
@@ -1261,7 +1283,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
"fileinfo" => {
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)?;
println!("width={width}, height={height}");
} else {

View File

@@ -5,10 +5,14 @@
//! Usage: cargo run --example repl --release -- <databasefile>
//! All further options can be set using the set-command (type ? for help).
#[macro_use]
extern crate deltachat;
use std::borrow::Cow::{self, Borrowed, Owned};
use std::io::{self, Write};
use std::process::Command;
use ansi_term::Color;
use anyhow::{bail, Error};
use deltachat::chat::ChatId;
use deltachat::config;
@@ -18,10 +22,9 @@ use deltachat::qr_code_generator::get_securejoin_qr_svg;
use deltachat::securejoin::*;
use deltachat::EventType;
use log::{error, info, warn};
use nu_ansi_term::Color;
use rustyline::completion::{Completer, FilenameCompleter, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::{CmdKind as HighlightCmdKind, Highlighter, MatchingBracketHighlighter};
use rustyline::highlight::{Highlighter, MatchingBracketHighlighter};
use rustyline::hint::{Hinter, HistoryHinter};
use rustyline::validate::Validator;
use rustyline::{
@@ -40,25 +43,25 @@ fn receive_event(event: EventType) {
match event {
EventType::Info(msg) => {
/* do not show the event as this would fill the screen */
info!("{msg}");
info!("{}", msg);
}
EventType::SmtpConnected(msg) => {
info!("[SMTP_CONNECTED] {msg}");
info!("[SMTP_CONNECTED] {}", msg);
}
EventType::ImapConnected(msg) => {
info!("[IMAP_CONNECTED] {msg}");
info!("[IMAP_CONNECTED] {}", msg);
}
EventType::SmtpMessageSent(msg) => {
info!("[SMTP_MESSAGE_SENT] {msg}");
info!("[SMTP_MESSAGE_SENT] {}", msg);
}
EventType::Warning(msg) => {
warn!("{msg}");
warn!("{}", msg);
}
EventType::Error(msg) => {
error!("{msg}");
error!("{}", msg);
}
EventType::ErrorSelfNotInGroup(msg) => {
error!("[SELF_NOT_IN_GROUP] {msg}");
error!("[SELF_NOT_IN_GROUP] {}", msg);
}
EventType::MsgsChanged { chat_id, msg_id } => {
info!(
@@ -123,7 +126,7 @@ fn receive_event(event: EventType) {
);
}
_ => {
info!("Received {event:?}");
info!("Received {:?}", event);
}
}
}
@@ -149,7 +152,7 @@ impl Completer for DcHelper {
}
}
const IMEX_COMMANDS: [&str; 13] = [
const IMEX_COMMANDS: [&str; 14] = [
"initiate-key-transfer",
"get-setupcodebegin",
"continue-key-transfer",
@@ -160,12 +163,13 @@ const IMEX_COMMANDS: [&str; 13] = [
"receive-backup",
"export-keys",
"import-keys",
"export-setup",
"poke",
"reset",
"stop",
];
const DB_COMMANDS: [&str; 11] = [
const DB_COMMANDS: [&str; 10] = [
"info",
"set",
"get",
@@ -173,17 +177,14 @@ const DB_COMMANDS: [&str; 11] = [
"configure",
"connect",
"disconnect",
"fetch",
"connectivity",
"maybenetwork",
"housekeeping",
];
const CHAT_COMMANDS: [&str; 39] = [
const CHAT_COMMANDS: [&str; 36] = [
"listchats",
"listarchived",
"start-realtime",
"send-realtime",
"chat",
"createchat",
"creategroup",
@@ -199,16 +200,13 @@ const CHAT_COMMANDS: [&str; 39] = [
"dellocations",
"getlocations",
"send",
"sendempty",
"sendimage",
"sendsticker",
"sendfile",
"sendhtml",
"sendsyncmsg",
"sendupdate",
"videochat",
"draft",
"devicemsg",
"listmedia",
"archive",
"unarchive",
@@ -216,46 +214,46 @@ const CHAT_COMMANDS: [&str; 39] = [
"unpin",
"mute",
"unmute",
"protect",
"unprotect",
"delchat",
"accept",
"blockchat",
];
const MESSAGE_COMMANDS: [&str; 10] = [
const MESSAGE_COMMANDS: [&str; 9] = [
"listmsgs",
"msginfo",
"download",
"html",
"listfresh",
"forward",
"resend",
"markseen",
"delmsg",
"download",
"react",
];
const CONTACT_COMMANDS: [&str; 7] = [
const CONTACT_COMMANDS: [&str; 9] = [
"listcontacts",
"listverified",
"addcontact",
"contactinfo",
"delcontact",
"cleanupcontacts",
"block",
"unblock",
"listblocked",
];
const MISC_COMMANDS: [&str; 14] = [
const MISC_COMMANDS: [&str; 11] = [
"getqr",
"getqrsvg",
"getbadqr",
"checkqr",
"joinqr",
"setqr",
"createqrsvg",
"providerinfo",
"fileinfo",
"estimatedeletion",
"clear",
"exit",
"quit",
"help",
"estimatedeletion",
];
impl Hinter for DcHelper {
@@ -301,8 +299,8 @@ impl Highlighter for DcHelper {
self.highlighter.highlight(line, pos)
}
fn highlight_char(&self, line: &str, pos: usize, kind: HighlightCmdKind) -> bool {
self.highlighter.highlight_char(line, pos, kind)
fn highlight_char(&self, line: &str, pos: usize, forced: bool) -> bool {
self.highlighter.highlight_char(line, pos, forced)
}
}
@@ -311,7 +309,7 @@ impl Validator for DcHelper {}
async fn start(args: Vec<String>) -> Result<(), Error> {
if args.len() < 2 {
eprintln!("Error: Bad arguments, expected [db-name].");
println!("Error: Bad arguments, expected [db-name].");
bail!("No db-name specified");
}
let context = ContextBuilder::new(args[1].clone().into())
@@ -326,7 +324,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()
.history_ignore_space(true)
@@ -366,7 +364,7 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
false
}
Err(err) => {
eprintln!("Error: {err:#}");
println!("Error: {err:#}");
true
}
}
@@ -381,7 +379,7 @@ async fn start(args: Vec<String>) -> Result<(), Error> {
break;
}
Err(err) => {
eprintln!("Error: {err:#}");
println!("Error: {err:#}");
break;
}
}
@@ -420,9 +418,6 @@ async fn handle_cmd(
"disconnect" => {
ctx.stop_io().await;
}
"fetch" => {
ctx.background_fetch().await?;
}
"configure" => {
ctx.configure().await?;
}
@@ -452,7 +447,12 @@ async fn handle_cmd(
qr.replace_range(12..22, "0000000000")
}
println!("{qr}");
qr2term::print_qr(qr.as_str())?;
let output = Command::new("qrencode")
.args(["-t", "ansiutf8", qr.as_str(), "-o", "-"])
.output()
.expect("failed to execute process");
io::stdout().write_all(&output.stdout).unwrap();
io::stderr().write_all(&output.stderr).unwrap();
}
}
"getqrsvg" => {

View File

@@ -25,8 +25,7 @@ $ pip install .
## Testing
1. Build `deltachat-rpc-server` with `cargo build -p deltachat-rpc-server`.
2. Install tox `pip install -U tox`
3. Run `CHATMAIL_DOMAIN=nine.testrun.org PATH="../target/debug:$PATH" tox`.
2. Run `CHATMAIL_DOMAIN=nine.testrun.org PATH="../target/debug:$PATH" tox`.
Additional arguments to `tox` are passed to pytest, e.g. `tox -- -s` does not capture test output.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "2.4.0"
version = "1.140.0"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -23,7 +23,9 @@ classifiers = [
"Topic :: Communications :: Email"
]
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
"imap-tools",
]
[tool.setuptools.package-data]
deltachat_rpc_client = [
@@ -66,18 +68,7 @@ lint.select = [
"RUF006" # asyncio-dangling-task
]
lint.ignore = [
"PLC0415" # `import` should be at the top-level of a file
]
line-length = 120
[tool.isort]
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 .account import Account

View File

@@ -1,5 +1,4 @@
import argparse
import os
import re
import sys
from threading import Thread
@@ -90,8 +89,8 @@ def _run_cli(
help="accounts folder (default: current working directory)",
nargs="?",
)
parser.add_argument("--email", action="store", help="email address", default=os.getenv("DELTACHAT_EMAIL"))
parser.add_argument("--password", action="store", help="password", default=os.getenv("DELTACHAT_PASSWORD"))
parser.add_argument("--email", action="store", help="email address")
parser.add_argument("--password", action="store", help="password")
args = parser.parse_args(argv[1:])
with Rpc(accounts_dir=args.accounts_dir, **kwargs) as rpc:
@@ -115,7 +114,7 @@ def _run_cli(
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)
if match:
text = match.group(1)
@@ -124,7 +123,7 @@ def extract_addr(text: str) -> str:
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()
match = re.match(r"group image (changed|deleted) by (.+).", text)
if match:
@@ -143,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]]:
"""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.
"""

View File

@@ -1,5 +1,3 @@
"""Account module."""
from __future__ import annotations
from dataclasses import dataclass
@@ -28,36 +26,18 @@ class Account:
def _rpc(self) -> "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."""
while True:
next_event = AttrDict(self._rpc.wait_for_event(self.id))
if event_type is None or next_event.kind == event_type:
return next_event
return AttrDict(self._rpc.wait_for_event(self.id))
def clear_all_events(self):
"""Remove all queued-up events for a given account.
Useful for tests.
"""
"""Removes all queued-up events for a given account. Useful for tests."""
self._rpc.clear_all_events(self.id)
def remove(self) -> None:
"""Remove the account."""
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:
"""Start the account I/O."""
self._rpc.start_io(self.id)
@@ -87,7 +67,7 @@ class Account:
return self._rpc.get_config(self.id, key)
def update_config(self, **kwargs) -> None:
"""Update config values."""
"""update config values."""
for key, value in kwargs.items():
self.set_config(key, value)
@@ -103,15 +83,9 @@ class Account:
return self.get_config("selfavatar")
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)
def set_config_from_qr(self, qr: str):
"""Set configuration values from a QR code."""
self._rpc.set_config_from_qr(self.id, qr)
@futuremethod
@@ -119,23 +93,15 @@ class Account:
"""Configure an account."""
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 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):
"""Start I/O and wait until IMAP becomes IDLE."""
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.
Calling this method will always result in the same
@@ -143,42 +109,19 @@ class Account:
with that e-mail address, it is unblocked and its display
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.
"""
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):
obj = Contact(self, obj)
if isinstance(obj, Contact):
obj = obj.get_snapshot().address
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:
"""Create a 1:1 chat with another account."""
return self.create_contact(account).create_chat()
def get_device_chat(self) -> Chat:
"""Return device chat."""
return self.device_contact.create_chat()
addr = account.get_config("addr")
contact = self.create_contact(addr)
return contact.create_chat()
def get_contact_by_id(self, contact_id: int) -> Contact:
"""Return Contact instance for the given contact ID."""
@@ -211,8 +154,8 @@ class Account:
def get_contacts(
self,
query: Optional[str] = None,
*,
with_self: bool = False,
verified_only: bool = False,
snapshot: bool = False,
) -> Union[list[Contact], list[AttrDict]]:
"""Get a filtered list of contacts.
@@ -220,9 +163,12 @@ class Account:
:param query: if a string is specified, only return contacts
whose name or e-mail matches 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.
"""
flags = 0
if verified_only:
flags |= ContactFlag.VERIFIED_ONLY
if with_self:
flags |= ContactFlag.ADD_SELF
@@ -234,14 +180,9 @@ class Account:
@property
def self_contact(self) -> Contact:
"""Account's identity as a Contact."""
"""This account's identity as a Contact."""
return Contact(self, SpecialContactId.SELF)
@property
def device_contact(self) -> Chat:
"""Account's device contact."""
return Contact(self, SpecialContactId.DEVICE)
def get_chatlist(
self,
query: Optional[str] = None,
@@ -288,52 +229,17 @@ class Account:
def create_group(self, name: str, protect: bool = False) -> Chat:
"""Create a new group chat.
After creation,
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.
:param protect: If set to 1 the function creates group with protection initially enabled.
Only verified members are allowed in these groups
and end-to-end-encryption is always enabled.
After creation, the group has only self-contact as member and is in unpromoted state.
"""
return Chat(self, self._rpc.create_group_chat(self.id, name, protect))
def create_broadcast(self, name: str) -> Chat:
"""Create a new **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:
"""Return the Chat instance with the given ID."""
return Chat(self, chat_id)
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
and receiving several messages.
@@ -344,16 +250,12 @@ class Account:
"""
return Chat(self, self._rpc.secure_join(self.id, qrdata))
def get_qr_code(self) -> str:
"""Get Setup-Contact QR Code text.
def get_qr_code(self) -> tuple[str, str]:
"""Get Setup-Contact QR Code text and SVG data.
This data needs to be transferred to another Delta Chat account
this data needs to be transferred to another Delta Chat account
in a second channel, typically used by mobiles with QRcode-show + scan UX.
"""
return self._rpc.get_chat_securejoin_qr_code(self.id, None)
def get_qr_code_svg(self) -> tuple[str, str]:
"""Get Setup-Contact QR code text and SVG."""
return self._rpc.get_chat_securejoin_qr_code_svg(self.id, None)
def get_message_by_id(self, msg_id: int) -> Message:
@@ -390,40 +292,34 @@ class Account:
def wait_for_incoming_msg_event(self):
"""Wait for incoming message event and return it."""
return self.wait_for_event(EventType.INCOMING_MSG)
def wait_for_msgs_changed_event(self):
"""Wait for messages changed event and return it."""
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)
while True:
event = self.wait_for_event()
if event.kind == EventType.INCOMING_MSG:
return event
def wait_for_incoming_msg(self):
"""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)
def wait_for_securejoin_inviter_success(self):
"""Wait until SecureJoin process finishes successfully on the inviter side."""
while True:
event = self.wait_for_event()
if event["kind"] == "SecurejoinInviterProgress" and event["progress"] == 1000:
break
def wait_for_securejoin_joiner_success(self):
"""Wait until SecureJoin process finishes successfully on the joiner side."""
while True:
event = self.wait_for_event()
if event["kind"] == "SecurejoinJoinerProgress" and event["progress"] == 1000:
break
def wait_for_reactions_changed(self):
"""Wait for reaction change event."""
return self.wait_for_event(EventType.REACTIONS_CHANGED)
while True:
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."""
@@ -452,7 +348,3 @@ class Account:
"""Import keys."""
passphrase = "" # Importing passphrase-protected keys is currently not supported.
self._rpc.import_self_keys(self.id, str(path), passphrase)
def initiate_autocrypt_key_transfer(self) -> None:
"""Send Autocrypt Setup Message."""
return self._rpc.initiate_autocrypt_key_transfer(self.id)

View File

@@ -1,5 +1,3 @@
"""Chat module."""
from __future__ import annotations
import calendar
@@ -91,19 +89,14 @@ class Chat:
def set_ephemeral_timer(self, timer: int) -> None:
"""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)
def get_encryption_info(self) -> str:
"""Return encryption info for this chat."""
return self._rpc.get_chat_encryption_info(self.account.id, self.id)
def get_qr_code(self) -> str:
"""Get Join-Group QR code text."""
return self._rpc.get_chat_securejoin_qr_code(self.account.id, self.id)
def get_qr_code_svg(self) -> tuple[str, str]:
def get_qr_code(self) -> tuple[str, str]:
"""Get Join-Group QR code text and SVG data."""
return self._rpc.get_chat_securejoin_qr_code_svg(self.account.id, self.id)
@@ -127,7 +120,6 @@ class Chat:
html: Optional[str] = None,
viewtype: Optional[ViewType] = None,
file: Optional[str] = None,
filename: Optional[str] = None,
location: Optional[tuple[float, float]] = None,
override_sender_name: Optional[str] = None,
quoted_msg: Optional[Union[int, Message]] = None,
@@ -141,7 +133,6 @@ class Chat:
"html": html,
"viewtype": viewtype,
"file": file,
"filename": filename,
"location": location,
"overrideSenderName": override_sender_name,
"quotedMessageId": quoted_msg,
@@ -177,14 +168,13 @@ class Chat:
self,
text: Optional[str] = None,
file: Optional[str] = None,
filename: Optional[str] = None,
quoted_msg: Optional[int] = None,
viewtype: Optional[str] = None,
) -> None:
"""Set draft message."""
if isinstance(quoted_msg, Message):
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:
"""Remove draft message."""
@@ -202,12 +192,12 @@ class Chat:
return snapshot
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, info_only, add_daymarker)
return [Message(self.account, msg_id) for msg_id in msgs]
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)
def mark_noticed(self) -> None:
@@ -244,11 +234,6 @@ class Chat:
contacts = self._rpc.get_chat_contacts(self.account.id, self.id)
return [Contact(self.account, contact_id) for contact_id in 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:
"""Set profile image of this chat.

View File

@@ -48,7 +48,6 @@ class Client:
self.add_hooks(hooks or [])
def add_hooks(self, hooks: Iterable[tuple[Callable, Union[type, EventFilter]]]) -> None:
"""Register multiple hooks."""
for hook, event in hooks:
self.add_hook(hook, event)
@@ -78,11 +77,9 @@ class Client:
self._hooks.get(type(event), set()).remove((hook, event))
def is_configured(self) -> bool:
"""Return True if the client is configured."""
return self.account.is_configured()
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():
@@ -201,6 +198,5 @@ class Bot(Client):
"""Simple bot implementation that listens to events of a single account."""
def configure(self, email: str, password: str, **kwargs) -> None:
"""Configure the bot."""
kwargs.setdefault("bot", "1")
super().configure(email, password, **kwargs)

View File

@@ -1,20 +1,14 @@
"""Constants module."""
from enum import Enum, IntEnum
COMMAND_PREFIX = "/"
class ContactFlag(IntEnum):
"""Bit flags for get_contacts() method."""
VERIFIED_ONLY = 0x01
ADD_SELF = 0x02
ADDRESS = 0x04
class ChatlistFlag(IntEnum):
"""Bit flags for get_chatlist() method."""
ARCHIVED_ONLY = 0x01
NO_SPECIALS = 0x02
ADD_ALLDONE_HINT = 0x04
@@ -22,8 +16,6 @@ class ChatlistFlag(IntEnum):
class SpecialContactId(IntEnum):
"""Special contact IDs."""
SELF = 1
INFO = 2 # centered messages as "member added", used in all chats
DEVICE = 5 # messages "update info" in the device-chat
@@ -31,7 +23,7 @@ class SpecialContactId(IntEnum):
class EventType(str, Enum):
"""Core event types."""
"""Core event types"""
INFO = "Info"
SMTP_CONNECTED = "SmtpConnected"
@@ -49,14 +41,12 @@ class EventType(str, Enum):
REACTIONS_CHANGED = "ReactionsChanged"
INCOMING_MSG = "IncomingMsg"
INCOMING_MSG_BUNCH = "IncomingMsgBunch"
INCOMING_REACTION = "IncomingReaction"
MSGS_NOTICED = "MsgsNoticed"
MSG_DELIVERED = "MsgDelivered"
MSG_FAILED = "MsgFailed"
MSG_READ = "MsgRead"
MSG_DELETED = "MsgDeleted"
CHAT_MODIFIED = "ChatModified"
CHAT_DELETED = "ChatDeleted"
CHAT_EPHEMERAL_TIMER_MODIFIED = "ChatEphemeralTimerModified"
CONTACTS_CHANGED = "ContactsChanged"
LOCATION_CHANGED = "LocationChanged"
@@ -71,15 +61,12 @@ class EventType(str, Enum):
WEBXDC_INSTANCE_DELETED = "WebxdcInstanceDeleted"
CHATLIST_CHANGED = "ChatlistChanged"
CHATLIST_ITEM_CHANGED = "ChatlistItemChanged"
ACCOUNTS_CHANGED = "AccountsChanged"
ACCOUNTS_ITEM_CHANGED = "AccountsItemChanged"
CONFIG_SYNCED = "ConfigSynced"
WEBXDC_REALTIME_DATA = "WebxdcRealtimeData"
WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED = "WebxdcRealtimeAdvertisementReceived"
class ChatId(IntEnum):
"""Special chat IDs."""
"""Special chat ids"""
TRASH = 3
ARCHIVED_LINK = 6
@@ -88,47 +75,17 @@ class ChatId(IntEnum):
class ChatType(IntEnum):
"""Chat type."""
"""Chat types"""
UNDEFINED = 0
SINGLE = 100
"""1:1 chat, i.e. a direct chat with a single contact"""
GROUP = 120
MAILINGLIST = 140
OUT_BROADCAST = 160
"""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 = 165
"""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.
"""
BROADCAST = 160
class ChatVisibility(str, Enum):
"""Chat visibility types."""
"""Chat visibility types"""
NORMAL = "Normal"
ARCHIVED = "Archived"
@@ -136,7 +93,7 @@ class ChatVisibility(str, Enum):
class DownloadState(str, Enum):
"""Message download state."""
"""Message download state"""
DONE = "Done"
AVAILABLE = "Available"
@@ -197,14 +154,14 @@ class MessageState(IntEnum):
class MessageId(IntEnum):
"""Special message IDs."""
"""Special message ids"""
DAYMARKER = 9
LAST_SPECIAL = 9
class CertificateChecks(IntEnum):
"""Certificate checks mode."""
"""Certificate checks mode"""
AUTOMATIC = 0
STRICT = 1
@@ -212,7 +169,7 @@ class CertificateChecks(IntEnum):
class Connectivity(IntEnum):
"""Connectivity states."""
"""Connectivity states"""
NOT_CONNECTED = 1000
CONNECTING = 2000
@@ -221,7 +178,7 @@ class Connectivity(IntEnum):
class KeyGenType(IntEnum):
"""Type of the key to generate."""
"""Type of the key to generate"""
DEFAULT = 0
RSA2048 = 1
@@ -231,21 +188,21 @@ class KeyGenType(IntEnum):
# "Lp" means "login parameters"
class LpAuthFlag(IntEnum):
"""Authorization flags."""
"""Authorization flags"""
OAUTH2 = 0x2
NORMAL = 0x4
class MediaQuality(IntEnum):
"""Media quality setting."""
"""Media quality setting"""
BALANCED = 0
WORSE = 1
class ProviderStatus(IntEnum):
"""Provider status according to manual testing."""
"""Provider status according to manual testing"""
OK = 1
PREPARATION = 2
@@ -253,7 +210,7 @@ class ProviderStatus(IntEnum):
class PushNotifyState(IntEnum):
"""Push notifications state."""
"""Push notifications state"""
NOT_CONNECTED = 0
HEARTBEAT = 1
@@ -261,7 +218,7 @@ class PushNotifyState(IntEnum):
class ShowEmails(IntEnum):
"""Show emails mode."""
"""Show emails mode"""
OFF = 0
ACCEPTED_CONTACTS = 1
@@ -269,7 +226,7 @@ class ShowEmails(IntEnum):
class SocketSecurity(IntEnum):
"""Socket security."""
"""Socket security"""
AUTOMATIC = 0
SSL = 1
@@ -278,7 +235,7 @@ class SocketSecurity(IntEnum):
class VideochatType(IntEnum):
"""Video chat URL type."""
"""Video chat URL type"""
UNKNOWN = 0
BASICWEBRTC = 1

View File

@@ -1,5 +1,3 @@
"""Contact module."""
from dataclasses import dataclass
from typing import TYPE_CHECKING
@@ -13,7 +11,8 @@ if TYPE_CHECKING:
@dataclass
class Contact:
"""Contact API.
"""
Contact API.
Essentially a wrapper for RPC, account ID and a contact ID.
"""
@@ -42,9 +41,8 @@ class Contact:
self._rpc.change_contact_name(self.account.id, self.id, name)
def get_encryption_info(self) -> str:
"""Get a multi-line encryption info.
Encryption info contains your fingerprint and the fingerprint of the contact.
"""Get a multi-line encryption info, containing your fingerprint and
the fingerprint of the contact.
"""
return self._rpc.get_contact_encryption_info(self.account.id, self.id)
@@ -64,5 +62,4 @@ class Contact:
)
def make_vcard(self) -> str:
"""Make a vCard for the contact."""
return self.account.make_vcard([self])
return self._rpc.make_vcard(self.account.id, [self.id])

View File

@@ -1,5 +1,3 @@
"""Account manager module."""
from __future__ import annotations
from typing import TYPE_CHECKING
@@ -12,13 +10,12 @@ if TYPE_CHECKING:
class DeltaChat:
"""Delta Chat accounts manager.
"""
Delta Chat accounts manager.
This is the root of the object oriented API.
"""
def __init__(self, rpc: "Rpc") -> None:
"""Initialize account manager."""
self.rpc = rpc
def add_account(self) -> Account:
@@ -40,7 +37,9 @@ class DeltaChat:
self.rpc.stop_io_for_all_accounts()
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()
def get_system_info(self) -> AttrDict:

View File

@@ -1,3 +1,7 @@
"""
Internal Python-level IMAP handling used by the tests.
"""
from __future__ import annotations
import imaplib
@@ -5,13 +9,18 @@ import io
import pathlib
import ssl
from contextlib import contextmanager
from typing import TYPE_CHECKING
import pytest
from imap_tools import AND, Header, MailBox, MailMessage, MailMessageFlags, errors
from imap_tools import (
AND,
Header,
MailBox,
MailBoxTls,
MailMessage,
MailMessageFlags,
errors,
)
if TYPE_CHECKING:
from deltachat_rpc_client import Account
from . import Account, const
FLAGS = b"FLAGS"
FETCH = b"FETCH"
@@ -19,8 +28,6 @@ ALL = "1:*"
class DirectImap:
"""Internal Python-level IMAP handling."""
def __init__(self, account: Account) -> None:
self.account = account
self.logid = account.get_config("displayname") or id(account)
@@ -28,15 +35,28 @@ class DirectImap:
self.connect()
def connect(self):
# Assume the testing server supports TLS on port 993.
host = self.account.get_config("configured_mail_server")
port = 993
port = int(self.account.get_config("configured_mail_port"))
security = int(self.account.get_config("configured_mail_security"))
user = self.account.get_config("addr")
host = user.rsplit("@")[-1]
pw = self.account.get_config("mail_pw")
self.conn = MailBox(host, port, ssl_context=ssl.create_default_context())
if security == const.SocketSecurity.PLAIN:
ssl_context = None
else:
ssl_context = ssl.create_default_context()
# don't check if certificate hostname doesn't match target hostname
ssl_context.check_hostname = False
# don't check if the certificate is trusted by a certificate authority
ssl_context.verify_mode = ssl.CERT_NONE
if security == const.SocketSecurity.STARTTLS:
self.conn = MailBoxTls(host, port, ssl_context=ssl_context)
elif security == const.SocketSecurity.PLAIN or security == const.SocketSecurity.SSL:
self.conn = MailBox(host, port, ssl_context=ssl_context)
self.conn.login(user, pw)
self.select_folder("INBOX")
@@ -204,8 +224,3 @@ class IdleManager:
def done(self):
"""send idle-done to server if we are currently in idle mode."""
return self.direct_imap.conn.idle.stop()
@pytest.fixture
def direct_imap():
return DirectImap

View File

@@ -36,7 +36,7 @@ class EventFilter(ABC):
@abstractmethod
def __hash__(self) -> int:
"""Object's unique hash."""
"""Object's unique hash"""
@abstractmethod
def __eq__(self, other) -> bool:
@@ -52,7 +52,9 @@ class EventFilter(ABC):
@abstractmethod
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):
@@ -80,17 +82,31 @@ class RawEvent(EventFilter):
return False
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:
return False
return self._call_func(event)
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__(
self,
@@ -105,25 +121,6 @@ class NewMessage(EventFilter):
is_info: Optional[bool] = None,
func: Optional[Callable[["AttrDict"], bool]] = 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)
self.is_bot = is_bot
self.is_info = is_info
@@ -162,7 +159,6 @@ class NewMessage(EventFilter):
return False
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:
return False
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
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:
return False
return self._call_func(event)
@@ -236,7 +231,6 @@ class GroupImageChanged(EventFilter):
return False
def filter(self, event: "AttrDict") -> bool:
"""Return True if event is matched."""
if self.deleted is not None and self.deleted != event.image_deleted:
return False
return self._call_func(event)
@@ -262,12 +256,13 @@ class GroupNameChanged(EventFilter):
return False
def filter(self, event: "AttrDict") -> bool:
"""Return True if event is matched."""
return self._call_func(event)
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:
self._hooks: set[tuple[Callable, Union[type, EventFilter]]] = set()

View File

@@ -1,8 +1,6 @@
"""Message module."""
import json
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 .const import EventType
@@ -39,11 +37,6 @@ class Message:
snapshot["message"] = self
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_reactions(self) -> Optional[AttrDict]:
"""Get message reactions."""
reactions = self._rpc.get_message_reactions(self.account.id, self.id)
@@ -52,7 +45,6 @@ class Message:
return None
def get_sender_contact(self) -> Contact:
"""Return sender contact."""
from_id = self.get_snapshot().from_id
return self.account.get_contact_by_id(from_id)
@@ -60,14 +52,6 @@ class Message:
"""Mark the message as seen."""
self._rpc.markseen_msgs(self.account.id, [self.id])
def continue_autocrypt_key_transfer(self, setup_code: str) -> None:
"""Continue the Autocrypt Setup Message key transfer.
This function can be called on received Autocrypt Setup Message
to import the key encrypted with the provided setup code.
"""
self._rpc.continue_autocrypt_key_transfer(self.account.id, self.id, setup_code)
def send_webxdc_status_update(self, update: Union[dict, str], description: str) -> None:
"""Send a webxdc status update. This message must be a webxdc."""
if not isinstance(update, str):
@@ -75,15 +59,9 @@ class Message:
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:
"""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))
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:
"""Get info from a Webxdc message in JSON format."""
return self._rpc.get_webxdc_info(self.account.id, self.id)
def wait_until_delivered(self) -> None:
@@ -95,10 +73,8 @@ class Message:
@futuremethod
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)
@futuremethod
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))

View File

@@ -1,74 +1,65 @@
"""Pytest plugin module."""
from __future__ import annotations
import os
import random
from typing import AsyncGenerator, Optional
import py
import pytest
from . import Account, AttrDict, Bot, Chat, Client, DeltaChat, EventType, Message
from ._utils import futuremethod
from .rpc import Rpc
E2EE_INFO_MSGS = 1
"""
The number of info messages added to new e2ee chats.
Currently this is "End-to-end encryption available".
"""
def get_temp_credentials() -> dict:
domain = os.getenv("CHATMAIL_DOMAIN")
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
password = f"{username}${username}"
addr = f"{username}@{domain}"
return {"email": addr, "password": password}
class ACFactory:
"""Test account factory."""
def __init__(self, deltachat: DeltaChat) -> None:
self.deltachat = deltachat
def get_unconfigured_account(self) -> Account:
"""Create a new unconfigured account."""
account = self.deltachat.add_account()
account.set_config("verified_one_on_one_chats", "1")
return account
def get_unconfigured_bot(self) -> Bot:
"""Create a new unconfigured bot."""
return Bot(self.get_unconfigured_account())
def get_credentials(self) -> (str, str):
"""Generate new credentials for chatmail account."""
domain = os.getenv("CHATMAIL_DOMAIN")
username = "ci-" + "".join(random.choice("2345789acdefghjkmnpqrstuvwxyz") for i in range(6))
return f"{username}@{domain}", f"{username}${username}"
def new_preconfigured_account(self) -> Account:
"""Make a new account with configuration options set, but configuration not started."""
credentials = get_temp_credentials()
account = self.get_unconfigured_account()
account.set_config("addr", credentials["email"])
account.set_config("mail_pw", credentials["password"])
assert not account.is_configured()
return account
@futuremethod
def new_configured_account(self):
"""Create a new configured account."""
addr, password = self.get_credentials()
account = self.get_unconfigured_account()
params = {"addr": addr, "password": password}
yield account.add_or_update_transport.future(params)
account = self.new_preconfigured_account()
yield account.configure.future()
assert account.is_configured()
return account
def new_configured_bot(self) -> Bot:
"""Create a new configured bot."""
addr, password = self.get_credentials()
credentials = get_temp_credentials()
bot = self.get_unconfigured_bot()
bot.configure(addr, password)
bot.configure(credentials["email"], credentials["password"])
return bot
@futuremethod
def get_online_account(self):
"""Create a new account and start I/O."""
account = yield self.new_configured_account.future()
account.bring_online()
return account
def get_online_accounts(self, num: int) -> list[Account]:
"""Create multiple online accounts."""
futures = [self.get_online_account.future() for _ in range(num)]
return [f() for f in futures]
@@ -83,10 +74,6 @@ class ACFactory:
return ac_clone
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)
return ac1.create_chat(ac2)
@@ -98,10 +85,9 @@ class ACFactory:
file: Optional[str] = None,
group: Optional[str] = None,
) -> Message:
"""Send a message."""
if not from_account:
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:
to_chat = from_account.create_group(group)
to_chat.add_contact(to_contact)
@@ -117,7 +103,6 @@ class ACFactory:
file: Optional[str] = None,
group: Optional[str] = None,
) -> AttrDict:
"""Send a message and wait until recipient processes it."""
self.send_message(
to_account=to_client.account,
from_account=from_account,
@@ -129,62 +114,13 @@ class ACFactory:
return to_client.run_until(lambda e: e.kind == EventType.INCOMING_MSG)
@pytest.fixture
@pytest.fixture()
def rpc(tmp_path) -> AsyncGenerator:
"""RPC client fixture."""
rpc_server = Rpc(accounts_dir=str(tmp_path / "accounts"))
with rpc_server:
yield rpc_server
@pytest.fixture
@pytest.fixture()
def acfactory(rpc) -> AsyncGenerator:
"""Return account factory fixture."""
return ACFactory(DeltaChat(rpc))
@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:
print()
print("=" * 10, msg, "=" * 10)
def step(self, msg: str) -> None:
print("-" * 5, "step " + msg, "-" * 5)
def indent(self, msg: str) -> None:
print(" " + msg)
return Printer()

View File

@@ -1,5 +1,3 @@
"""JSON-RPC client module."""
from __future__ import annotations
import itertools
@@ -14,19 +12,16 @@ from typing import Any, Iterator, Optional
class JsonRpcError(Exception):
"""JSON-RPC error."""
pass
class RpcFuture:
"""RPC future waiting for RPC call result."""
def __init__(self, rpc: "Rpc", request_id: int, event: Event):
self.rpc = rpc
self.request_id = request_id
self.event = event
def __call__(self):
"""Wait for the future to return the result."""
self.event.wait()
response = self.rpc.request_results.pop(self.request_id)
if "error" in response:
@@ -37,19 +32,17 @@ class RpcFuture:
class RpcMethod:
"""RPC method."""
def __init__(self, rpc: "Rpc", name: str):
self.rpc = rpc
self.name = name
def __call__(self, *args) -> Any:
"""Call JSON-RPC method synchronously."""
"""Synchronously calls JSON-RPC method."""
future = self.future(*args)
return future()
def future(self, *args) -> Any:
"""Call JSON-RPC method asynchronously."""
"""Asynchronously calls JSON-RPC method."""
request_id = next(self.rpc.id_iterator)
request = {
"jsonrpc": "2.0",
@@ -65,13 +58,8 @@ class RpcMethod:
class Rpc:
"""RPC client."""
def __init__(self, accounts_dir: Optional[str] = None, **kwargs):
"""Initialize RPC client.
The given arguments will be passed to subprocess.Popen().
"""
"""The given arguments will be passed to subprocess.Popen()"""
if accounts_dir:
kwargs["env"] = {
**kwargs.get("env", os.environ),
@@ -93,7 +81,6 @@ class Rpc:
self.events_thread: Thread
def start(self) -> None:
"""Start RPC server subprocess."""
if sys.version_info >= (3, 11):
self.process = subprocess.Popen(
"deltachat-rpc-server",
@@ -143,9 +130,11 @@ class Rpc:
self.close()
def reader_loop(self) -> None:
"""Process JSON-RPC responses from the RPC server process output."""
try:
while line := self.process.stdout.readline():
while True:
line = self.process.stdout.readline()
if not line: # EOF
break
response = json.loads(line)
if "id" in response:
response_id = response["id"]
@@ -161,7 +150,10 @@ class Rpc:
def writer_loop(self) -> None:
"""Writer loop ensuring only a single thread writes requests."""
try:
while request := self.request_queue.get():
while True:
request = self.request_queue.get()
if not request:
break
data = (json.dumps(request) + "\n").encode()
self.process.stdin.write(data)
self.process.stdin.flush()
@@ -171,13 +163,12 @@ class Rpc:
logging.exception("Exception in the writer loop")
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:
self.event_queues[account_id] = Queue()
return self.event_queues[account_id]
def events_loop(self) -> None:
"""Request new events and distributes them between queues."""
"""Requests new events and distributes them between queues."""
try:
while True:
if self.closing:
@@ -193,12 +184,12 @@ class Rpc:
logging.exception("Exception in the event loop")
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)
return queue.get()
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)
try:
while True:

View File

@@ -1,30 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from deltachat_rpc_client import EventType
if TYPE_CHECKING:
from deltachat_rpc_client.pytestplugin import ACFactory
def test_event_on_configuration(acfactory: ACFactory) -> None:
"""
Test if ACCOUNTS_ITEM_CHANGED event is emitted on configure
"""
addr, password = acfactory.get_credentials()
account = acfactory.get_unconfigured_account()
account.clear_all_events()
assert not account.is_configured()
future = account.add_or_update_transport.future({"addr": addr, "password": password})
while True:
event = account.wait_for_event()
if event.kind == EventType.ACCOUNTS_ITEM_CHANGED:
break
assert account.is_configured()
future()
# other tests are written in rust: src/tests/account_events.rs

View File

@@ -48,7 +48,8 @@ def test_delivery_status(acfactory: ACFactory) -> None:
"""
alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob")
bob_addr = bob.get_config("addr")
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice.clear_all_events()
@@ -118,7 +119,8 @@ def test_download_on_demand(acfactory: ACFactory) -> None:
"""
alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob")
bob_addr = bob.get_config("addr")
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_text("hi")
@@ -148,13 +150,18 @@ def test_download_on_demand(acfactory: ACFactory) -> None:
def get_multi_account_test_setup(acfactory: ACFactory) -> [Account, Account, Account]:
alice, bob = acfactory.get_online_accounts(2)
alice_contact_bob = alice.create_contact(bob, "Bob")
bob_addr = bob.get_config("addr")
alice_contact_bob = alice.create_contact(bob_addr, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.send_text("hi")
bob.wait_for_incoming_msg_event()
alice_second_device = alice.clone()
alice_second_device: Account = acfactory.get_unconfigured_account()
alice._rpc.provide_backup.future(alice.id)
backup_code = alice._rpc.get_backup_qr(alice.id)
alice_second_device._rpc.get_backup(alice_second_device.id, backup_code)
alice_second_device.start_io()
alice.clear_all_events()
alice_second_device.clear_all_events()
@@ -203,7 +210,6 @@ def test_multidevice_sync_chat(acfactory: ACFactory) -> None:
alice_second_device.clear_all_events()
alice_chat_bob.pin()
wait_for_chatlist_specific_item(alice_second_device, alice_chat_bob.id)
assert alice_second_device.get_chat_by_id(alice_chat_bob.id).get_basic_snapshot().pinned
alice_second_device.clear_all_events()
alice_chat_bob.mute()

View File

@@ -7,17 +7,15 @@ If you want to debug iroh at rust-trace/log level set
RUST_LOG=iroh_net=trace,iroh_gossip=trace
"""
import logging
import os
import sys
import threading
import time
import pytest
from deltachat_rpc_client import EventType
@pytest.fixture
@pytest.fixture()
def path_to_webxdc(request):
p = request.path.parent.parent.parent.joinpath("test-data/webxdc/chess.xdc")
assert p.exists()
@@ -25,7 +23,9 @@ def path_to_webxdc(request):
def log(msg):
logging.info(msg)
print()
print("*" * 80 + "\n" + msg + "\n", file=sys.stderr)
print()
def setup_realtime_webxdc(ac1, ac2, path_to_webxdc):
@@ -68,7 +68,7 @@ def wait_receive_realtime_data(msg_data_list):
if event.kind == EventType.WEBXDC_REALTIME_DATA:
for i, (msg, data) in enumerate(msg_data_list):
if msg.id == event.msg_id:
assert list(data) == event.data
assert data == event.data
log(f"msg {msg.id}: got correct realtime data {data}")
del msg_data_list[i]
break
@@ -106,15 +106,13 @@ def test_realtime_sequentially(acfactory, path_to_webxdc):
assert snapshot.text == "ping2"
log("sending realtime data ac1 -> ac2")
# Test that 128 KB of data can be sent in a single message.
data = os.urandom(128000)
ac1_webxdc_msg.send_webxdc_realtime_data(data)
ac1_webxdc_msg.send_webxdc_realtime_data(b"foo")
log("ac2: waiting for realtime data")
while 1:
event = ac2.wait_for_event()
if event.kind == EventType.WEBXDC_REALTIME_DATA:
assert event.data == list(data)
assert event.data == list(b"foo")
break
@@ -175,53 +173,14 @@ def test_no_duplicate_messages(acfactory, path_to_webxdc):
threading.Thread(target=thread_run, daemon=True).start()
event = ac2.wait_for_event(EventType.WEBXDC_REALTIME_DATA)
n = int(bytes(event.data).decode())
while 1:
event = ac2.wait_for_event()
if event.kind == EventType.WEBXDC_REALTIME_DATA:
n = int(bytes(event.data).decode())
break
event = ac2.wait_for_event(EventType.WEBXDC_REALTIME_DATA)
assert int(bytes(event.data).decode()) > n
def test_no_reordering(acfactory, path_to_webxdc):
"""Test that sending a lot of realtime messages does not result in reordering."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("webxdc_realtime_enabled", "1")
ac2.set_config("webxdc_realtime_enabled", "1")
ac1_webxdc_msg, ac2_webxdc_msg = setup_realtime_webxdc(ac1, ac2, path_to_webxdc)
setup_thread_send_realtime_data(ac1_webxdc_msg, b"hello")
wait_receive_realtime_data([(ac2_webxdc_msg, b"hello")])
for i in range(200):
ac1_webxdc_msg.send_webxdc_realtime_data([i])
for i in range(200):
while 1:
event = ac2.wait_for_event()
if event.kind == EventType.WEBXDC_REALTIME_DATA and bytes(event.data) != b"hello":
if event.data[0] == i:
break
pytest.fail("Reordering detected")
def test_advertisement_after_chatting(acfactory, path_to_webxdc):
"""Test that realtime advertisement is assigned to the correct message after chatting."""
ac1, ac2 = acfactory.get_online_accounts(2)
ac1.set_config("webxdc_realtime_enabled", "1")
ac2.set_config("webxdc_realtime_enabled", "1")
ac1_ac2_chat = ac1.create_chat(ac2)
ac1_webxdc_msg = ac1_ac2_chat.send_message(text="WebXDC", file=path_to_webxdc)
ac2_webxdc_msg = ac2.wait_for_incoming_msg()
assert ac2_webxdc_msg.get_snapshot().text == "WebXDC"
ac1_ac2_chat.send_text("Hello!")
ac2_hello_msg = ac2.wait_for_incoming_msg()
ac2_hello_msg_snapshot = ac2_hello_msg.get_snapshot()
assert ac2_hello_msg_snapshot.text == "Hello!"
ac2_hello_msg_snapshot.chat.accept()
ac2_webxdc_msg.send_webxdc_realtime_advertisement()
event = ac1.wait_for_event(EventType.WEBXDC_REALTIME_ADVERTISEMENT_RECEIVED)
assert event.msg_id == ac1_webxdc_msg.id
while 1:
event = ac2.wait_for_event()
if event.kind == EventType.WEBXDC_REALTIME_DATA:
assert int(bytes(event.data).decode()) > n
break

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