mirror of
https://github.com/chatmail/core.git
synced 2026-04-06 23:52:11 +03:00
Compare commits
211 Commits
v2.10.0
...
hoc/channe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
380f6e2786 | ||
|
|
43d65cb012 | ||
|
|
8fda2dee52 | ||
|
|
640d81094a | ||
|
|
c5b5d8020b | ||
|
|
dfc969e3c0 | ||
|
|
45bed57055 | ||
|
|
9914233683 | ||
|
|
cc54c68b29 | ||
|
|
e8fff886a0 | ||
|
|
632dd28e5b | ||
|
|
ac98289728 | ||
|
|
23c04c2134 | ||
|
|
6cd499ebc1 | ||
|
|
abd091db4c | ||
|
|
a5d9d43d47 | ||
|
|
dca184f72c | ||
|
|
d967bff702 | ||
|
|
de10f31a3a | ||
|
|
dd11364bef | ||
|
|
3a8a6f6949 | ||
|
|
557702ea74 | ||
|
|
fc52c8de05 | ||
|
|
4c068e835b | ||
|
|
b9ff40c6b5 | ||
|
|
0684810d38 | ||
|
|
18c84e838c | ||
|
|
1cc7ce6e27 | ||
|
|
82bc1bf0b1 | ||
|
|
75bcf8660b | ||
|
|
5e1d945198 | ||
|
|
e047184ede | ||
|
|
f8a46fe3cf | ||
|
|
307a2eb6ec | ||
|
|
ab8aedf06e | ||
|
|
302059cd63 | ||
|
|
ae4b0fdb4e | ||
|
|
b5a54aa6cf | ||
|
|
01d9acbf6a | ||
|
|
60e4899b3a | ||
|
|
8eb5fc528f | ||
|
|
286f913f6e | ||
|
|
6e68eb1c5d | ||
|
|
153ced7141 | ||
|
|
4a9af2b600 | ||
|
|
0c25646ac2 | ||
|
|
019da70c8a | ||
|
|
b6ab13f1de | ||
|
|
53a3e51920 | ||
|
|
4033566b4a | ||
|
|
bed1623dcb | ||
|
|
d4704977bc | ||
|
|
838eed94bc | ||
|
|
9870725d1f | ||
|
|
ba827283be | ||
|
|
1e37cb8c3c | ||
|
|
19159c905f | ||
|
|
51a36d23a2 | ||
|
|
f7844e97c2 | ||
|
|
a3d1e3bc89 | ||
|
|
dc5237f530 | ||
|
|
f66f6f3e92 | ||
|
|
9b49386bc8 | ||
|
|
40f4eea049 | ||
|
|
00ba559562 | ||
|
|
3a648698ee | ||
|
|
61e0d14eed | ||
|
|
2efbbcc669 | ||
|
|
479a5632fb | ||
|
|
9dc590cb35 | ||
|
|
956519cd98 | ||
|
|
90d4856a1c | ||
|
|
792c05fc3e | ||
|
|
3cf7746ceb | ||
|
|
0acc34a882 | ||
|
|
378896eca3 | ||
|
|
265ac4e30b | ||
|
|
8d89dcc65f | ||
|
|
a858709301 | ||
|
|
3d5e97eced | ||
|
|
5da6ca1ec4 | ||
|
|
58d0fd39b5 | ||
|
|
40e3c34f59 | ||
|
|
1377a77ea8 | ||
|
|
db32f1142c | ||
|
|
738f6c1799 | ||
|
|
e1abaebeb5 | ||
|
|
0978a46ab6 | ||
|
|
410048a9e1 | ||
|
|
72336ebb8a | ||
|
|
fca8948e4c | ||
|
|
d431f2ebd3 | ||
|
|
ad0e3179dd | ||
|
|
494ad63a73 | ||
|
|
13bbcbeb0e | ||
|
|
a14b53e3ca | ||
|
|
9474fbff56 | ||
|
|
c4001cc3ff | ||
|
|
548f5a454c | ||
|
|
91110147c3 | ||
|
|
6012595f1a | ||
|
|
504b2d691d | ||
|
|
7e191f6cf9 | ||
|
|
37f6da1cc9 | ||
|
|
df2693f307 | ||
|
|
cdd280a2d3 | ||
|
|
6bb714a6e5 | ||
|
|
b276eda1a2 | ||
|
|
9c747b4cb0 | ||
|
|
326deab025 | ||
|
|
24561cd256 | ||
|
|
5da7e45b2b | ||
|
|
3389e93820 | ||
|
|
789b923bb8 | ||
|
|
547f750073 | ||
|
|
382023de11 | ||
|
|
3781a35989 | ||
|
|
8653fdbd8e | ||
|
|
47bf4da1fe | ||
|
|
ec2056f5e2 | ||
|
|
1991e01641 | ||
|
|
d7e87b6336 | ||
|
|
fde490ba15 | ||
|
|
cf5a16d967 | ||
|
|
e8dde9c63d | ||
|
|
667a935665 | ||
|
|
28cea706fa | ||
|
|
209a990444 | ||
|
|
6365a46fac | ||
|
|
a81496e9ab | ||
|
|
ca05733b9d | ||
|
|
dfb5348a78 | ||
|
|
602e52490c | ||
|
|
740b24e8a4 | ||
|
|
44a09ffd12 | ||
|
|
054c42cbc2 | ||
|
|
34263a70e2 | ||
|
|
7ea6ca35d7 | ||
|
|
a9aad497fc | ||
|
|
7da8489635 | ||
|
|
683561374d | ||
|
|
66c9982822 | ||
|
|
1b6450b210 | ||
|
|
aa8a13adb2 | ||
|
|
5888541c05 | ||
|
|
f893487dc0 | ||
|
|
b84beaf974 | ||
|
|
75a3c55e70 | ||
|
|
854a09e12f | ||
|
|
40412fd4a9 | ||
|
|
57fc084795 | ||
|
|
143ba6d5e7 | ||
|
|
6b338a923c | ||
|
|
e6ab1e3df5 | ||
|
|
5da6976bf9 | ||
|
|
bd15d90e77 | ||
|
|
61633cf23b | ||
|
|
9f1107c0e7 | ||
|
|
ff0d5ce179 | ||
|
|
0bbd910883 | ||
|
|
4258088fb4 | ||
|
|
6372b677d2 | ||
|
|
9af00af70f | ||
|
|
4010c60e7b | ||
|
|
aaa83a8f52 | ||
|
|
776408c564 | ||
|
|
d0cb2110e6 | ||
|
|
11e3480fe8 | ||
|
|
2cd54b72b0 | ||
|
|
c34ccafb2e | ||
|
|
6837874d43 | ||
|
|
3656337d41 | ||
|
|
a89b6321f1 | ||
|
|
ac10103b18 | ||
|
|
b696a242fc | ||
|
|
7e4822c8ca | ||
|
|
a955cb5400 | ||
|
|
2e2cfc4cb3 | ||
|
|
4157d1986f | ||
|
|
d13eb2f580 | ||
|
|
5476f69179 | ||
|
|
dcdf30da35 | ||
|
|
55746c8c19 | ||
|
|
dbdf5f2746 | ||
|
|
b4e28deed3 | ||
|
|
f4a604dcfb | ||
|
|
b3c5787ec8 | ||
|
|
471d0469dd | ||
|
|
113eda575f | ||
|
|
45f1da82fe | ||
|
|
5f45ff77e4 | ||
|
|
1c0201ee3d | ||
|
|
c7340e04ec | ||
|
|
0a32476dc5 | ||
|
|
e02bc6ffb5 | ||
|
|
f41a3970b2 | ||
|
|
6c536f3a9b | ||
|
|
4b24b6a848 | ||
|
|
5f254a929f | ||
|
|
8df1a01ace | ||
|
|
27b5ffb34f | ||
|
|
80af012962 | ||
|
|
615c80bef4 | ||
|
|
f5f4026dbb | ||
|
|
b431206ede | ||
|
|
c4878e9b49 | ||
|
|
aa452971a6 | ||
|
|
2d798f7cfe | ||
|
|
4f1bf1f13c | ||
|
|
2d0b7b5bd8 | ||
|
|
3977580426 |
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -20,7 +20,7 @@ permissions: {}
|
||||
|
||||
env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
RUST_VERSION: 1.88.0
|
||||
RUST_VERSION: 1.89.0
|
||||
|
||||
# Minimum Supported Rust Version
|
||||
MSRV: 1.85.0
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
name: Lint Rust
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
name: cargo deny
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
name: Check provider database
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
env:
|
||||
RUSTDOCFLAGS: -Dwarnings
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
shell: bash
|
||||
if: matrix.rust == 'latest'
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -179,7 +179,7 @@ jobs:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
name: Python lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -244,19 +244,19 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download libdeltachat.a
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: ${{ matrix.os }}-libdeltachat.a
|
||||
path: target/debug
|
||||
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
|
||||
@@ -297,13 +297,13 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
|
||||
@@ -311,7 +311,7 @@ jobs:
|
||||
run: pip install tox
|
||||
|
||||
- name: Download deltachat-rpc-server
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: ${{ matrix.os }}-deltachat-rpc-server
|
||||
path: target/debug
|
||||
|
||||
62
.github/workflows/deltachat-rpc-server.yml
vendored
62
.github/workflows/deltachat-rpc-server.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
arch: [aarch64, armv7l, armv6l, i686, x86_64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
arch: [win32, win64]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
arch: [arm64-v8a, armeabi-v7a]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -132,74 +132,74 @@ jobs:
|
||||
contents: write
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
- name: Download Linux aarch64 binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-aarch64-linux
|
||||
path: deltachat-rpc-server-aarch64-linux.d
|
||||
|
||||
- name: Download Linux armv7l binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-armv7l-linux
|
||||
path: deltachat-rpc-server-armv7l-linux.d
|
||||
|
||||
- name: Download Linux armv6l binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-armv6l-linux
|
||||
path: deltachat-rpc-server-armv6l-linux.d
|
||||
|
||||
- name: Download Linux i686 binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-i686-linux
|
||||
path: deltachat-rpc-server-i686-linux.d
|
||||
|
||||
- name: Download Linux x86_64 binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-x86_64-linux
|
||||
path: deltachat-rpc-server-x86_64-linux.d
|
||||
|
||||
- name: Download Win32 binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-win32
|
||||
path: deltachat-rpc-server-win32.d
|
||||
|
||||
- name: Download Win64 binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-win64
|
||||
path: deltachat-rpc-server-win64.d
|
||||
|
||||
- name: Download macOS binary for x86_64
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-x86_64-macos
|
||||
path: deltachat-rpc-server-x86_64-macos.d
|
||||
|
||||
- name: Download macOS binary for aarch64
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-aarch64-macos
|
||||
path: deltachat-rpc-server-aarch64-macos.d
|
||||
|
||||
- name: Download Android binary for arm64-v8a
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-arm64-v8a-android
|
||||
path: deltachat-rpc-server-arm64-v8a-android.d
|
||||
|
||||
- name: Download Android binary for armeabi-v7a
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-armeabi-v7a-android
|
||||
path: deltachat-rpc-server-armeabi-v7a-android.d
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
|
||||
# Python 3.11 is needed for tomllib used in scripts/wheel-rpc-server.py
|
||||
- name: Install python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
@@ -285,76 +285,76 @@ jobs:
|
||||
# Needed to publish the binaries to the release.
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Download Linux aarch64 binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-aarch64-linux
|
||||
path: deltachat-rpc-server-aarch64-linux.d
|
||||
|
||||
- name: Download Linux armv7l binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-armv7l-linux
|
||||
path: deltachat-rpc-server-armv7l-linux.d
|
||||
|
||||
- name: Download Linux armv6l binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-armv6l-linux
|
||||
path: deltachat-rpc-server-armv6l-linux.d
|
||||
|
||||
- name: Download Linux i686 binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-i686-linux
|
||||
path: deltachat-rpc-server-i686-linux.d
|
||||
|
||||
- name: Download Linux x86_64 binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-x86_64-linux
|
||||
path: deltachat-rpc-server-x86_64-linux.d
|
||||
|
||||
- name: Download Win32 binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-win32
|
||||
path: deltachat-rpc-server-win32.d
|
||||
|
||||
- name: Download Win64 binary
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-win64
|
||||
path: deltachat-rpc-server-win64.d
|
||||
|
||||
- name: Download macOS binary for x86_64
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-x86_64-macos
|
||||
path: deltachat-rpc-server-x86_64-macos.d
|
||||
|
||||
- name: Download macOS binary for aarch64
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-aarch64-macos
|
||||
path: deltachat-rpc-server-aarch64-macos.d
|
||||
|
||||
- name: Download Android binary for arm64-v8a
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-arm64-v8a-android
|
||||
path: deltachat-rpc-server-arm64-v8a-android.d
|
||||
|
||||
- name: Download Android binary for armeabi-v7a
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: deltachat-rpc-server-armeabi-v7a-android
|
||||
path: deltachat-rpc-server-armeabi-v7a-android.d
|
||||
@@ -401,7 +401,7 @@ jobs:
|
||||
deltachat-rpc-server/npm-package/*.tgz
|
||||
|
||||
# Configure Node.js for publishing.
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
@@ -14,12 +14,12 @@ jobs:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
4
.github/workflows/jsonrpc.yml
vendored
4
.github/workflows/jsonrpc.yml
vendored
@@ -16,12 +16,12 @@ jobs:
|
||||
build_and_test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
- name: Use Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Add Rust cache
|
||||
|
||||
6
.github/workflows/nix.yml
vendored
6
.github/workflows/nix.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
name: check flake formatting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
#- deltachat-rpc-server-x86_64-android
|
||||
#- deltachat-rpc-server-x86-android
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
# - deltachat-rpc-server-aarch64-darwin
|
||||
# - deltachat-rpc-server-x86_64-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
2
.github/workflows/repl.yml
vendored
2
.github/workflows/repl.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
name: Build REPL example
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
|
||||
10
.github/workflows/upload-docs.yml
vendored
10
.github/workflows/upload-docs.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
@@ -72,13 +72,13 @@ jobs:
|
||||
working-directory: ./deltachat-jsonrpc/typescript
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
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@v5
|
||||
with:
|
||||
node-version: '18'
|
||||
- name: npm install
|
||||
|
||||
2
.github/workflows/upload-ffi-docs.yml
vendored
2
.github/workflows/upload-ffi-docs.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
show-progress: false
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/zizmor-scan.yml
vendored
2
.github/workflows/zizmor-scan.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
129
CHANGELOG.md
129
CHANGELOG.md
@@ -1,5 +1,132 @@
|
||||
# Changelog
|
||||
|
||||
## [2.13.0] - 2025-09-09
|
||||
|
||||
### API-Changes
|
||||
|
||||
- [**breaking**] Remove `is_profile_verified` APIs.
|
||||
- [**breaking**] Remove deprecated `is_protection_broken`.
|
||||
- [**breaking**] Remove `e2ee_enabled` preference.
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Add call ringing API ([#6650](https://github.com/chatmail/core/pull/6650), [#7174](https://github.com/chatmail/core/pull/7174), [#7175](https://github.com/chatmail/core/pull/7175), [#7179](https://github.com/chatmail/core/pull/7179))
|
||||
- Warn for outdated versions after 6 months instead of 1 year ([#7144](https://github.com/chatmail/core/pull/7144)).
|
||||
- Do not set "unknown sender for this chat" error.
|
||||
- Do not replace messages with an error on verification failure.
|
||||
- Support receiving Autocrypt-Gossip with `_verified` attribute.
|
||||
- Withdraw all QR codes when one is withdrawn.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Don't reverify contacts by SELF on receipt of a message from another device.
|
||||
- Don't verify contacts by others having an unknown verifier.
|
||||
- Update verifier_id if it's "unknown" and new verifier has known verifier.
|
||||
- Mark message as failed if it can't be sent ([#7143](https://github.com/chatmail/core/pull/7143)).
|
||||
- Add "Messages are end-to-end encrypted." to non-protected groups.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Fix for SecurejoinInviterProgress with progress == 600.
|
||||
- STYLE.md: Prefer BTreeMap and BTreeSet over hash variants.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Update provider database.
|
||||
- Update dependencies.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Check that verifier is verified in turn.
|
||||
- Remove unused `EncryptPreference::Reset`.
|
||||
- Remove `Aheader::new`.
|
||||
|
||||
### Tests
|
||||
|
||||
- Add another TimeShiftFalsePositiveNote ([#7142](https://github.com/chatmail/core/pull/7142)).
|
||||
- Add TestContext.create_chat_id.
|
||||
|
||||
## [2.12.0] - 2025-08-26
|
||||
|
||||
### API-Changes
|
||||
|
||||
- api!(python): remove remaining broken API for reactions
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Use Group ID for chat color generation instead of the name for encrypted groups.
|
||||
- Use key fingerprints instead of addresses for key-contacts color generation.
|
||||
- Replace HSLuv colors with OKLCh.
|
||||
- `wal_checkpoint()`: Do `wal_checkpoint(PASSIVE)` and `wal_checkpoint(FULL)` before `wal_checkpoint(TRUNCATE)`.
|
||||
- Assign messages to key-contacts based on Issuer Fingerprint.
|
||||
- Create_group_ex(): Log and replace invalid chat name with "…".
|
||||
|
||||
### Fixes
|
||||
|
||||
- Do not create a group if the sender includes self in the `To` field.
|
||||
- Do not reverify already verified contacts via gossip.
|
||||
- `get_connectivity()`: Get rid of locking SchedulerState::inner ([#7124](https://github.com/chatmail/core/pull/7124)).
|
||||
- Make reaction message hidden only if there are no other parts.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Do not return `Result` from `valid_signature_fingerprints()`.
|
||||
- Make `ConnectivityStore` use a non-async lock ([#7129](https://github.com/chatmail/core/pull/7129)).
|
||||
|
||||
### Documentation
|
||||
|
||||
- Remove broken link from documentation comments.
|
||||
- Remove the comment about Color Vision Deficiency correction.
|
||||
|
||||
## [2.11.0] - 2025-08-13
|
||||
|
||||
### Features / Changes
|
||||
|
||||
- Contact::lookup_id_by_addr_ex: Prefer returning key-contact.
|
||||
- Contact::lookup_id_by_addr_ex: Prefer returning accepted contacts.
|
||||
- Better string when using disappearing messages of one year (365..367 days, so it can be tweaked later).
|
||||
- Do not require resent messages to be from the same chat.
|
||||
- `lookup_key_contact_by_address()`: Allow looking up ContactId::SELF without chat id.
|
||||
- `get_securejoin_qr()`: Log error if group doesn't have grpid.
|
||||
- `receive_imf::add_parts()`: Get rid of extra `Chat::load_from_db()` calls.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Ignore case when trying to detect 'invalid unencrypted mail' and add an info-message.
|
||||
- Run wal_checkpoint during housekeeping ([#6089](https://github.com/chatmail/core/pull/6089)).
|
||||
- Allow receiving empty files.
|
||||
- Set correct sent_timestamp for saved outgoing messages.
|
||||
- Do not remove query parameters from URLs.
|
||||
- Log and set imex progress error ([#7091](https://github.com/chatmail/core/pull/7091)).
|
||||
- Do not add key-contacts to unencrypted groups.
|
||||
- Do not reset `GuaranteeE2ee` in the database when resending messages.
|
||||
- Assign messages to a group if there is a `Chat-Group-Name`.
|
||||
- Take `Chat-Group-Name` into account when matching ad hoc groups.
|
||||
- Don't break long group names with non-ASCII characters.
|
||||
- Add messages that can't be verified as `DownloadState::Available` ([#7059](https://github.com/chatmail/core/pull/7059)).
|
||||
|
||||
### Tests
|
||||
|
||||
- Log the number of the test account if there are multiple alices ([#7087](https://github.com/chatmail/core/pull/7087)).
|
||||
|
||||
### CI
|
||||
|
||||
- Update Rust to 1.89.0.
|
||||
|
||||
### Refactor
|
||||
|
||||
- Rename icon-address-contact to icon-unencrypted.
|
||||
- Skip loading the contact of 1:1 unencrypted chat to show the avatar.
|
||||
- Chat::is_encrypted(): Make one query instead of two for 1:1 chats.
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- cargo: Bump toml from 0.8.23 to 0.9.4.
|
||||
- cargo: Bump human-panic from 2.0.2 to 2.0.3.
|
||||
- deny.toml: Add exception for duplicate toml_datetime 0.6.11 dependency.
|
||||
- deps: Bump actions/checkout from 4 to 5.
|
||||
- deps: Bump actions/download-artifact from 4 to 5.
|
||||
|
||||
## [2.10.0] - 2025-08-04
|
||||
|
||||
### Features / Changes
|
||||
@@ -6596,3 +6723,5 @@ https://github.com/chatmail/core/pulls?q=is%3Apr+is%3Aclosed
|
||||
[2.8.0]: https://github.com/chatmail/core/compare/v2.7.0..v2.8.0
|
||||
[2.9.0]: https://github.com/chatmail/core/compare/v2.8.0..v2.9.0
|
||||
[2.10.0]: https://github.com/chatmail/core/compare/v2.9.0..v2.10.0
|
||||
[2.11.0]: https://github.com/chatmail/core/compare/v2.10.0..v2.11.0
|
||||
[2.12.0]: https://github.com/chatmail/core/compare/v2.11.0..v2.12.0
|
||||
|
||||
347
Cargo.lock
generated
347
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
23
Cargo.toml
23
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat"
|
||||
version = "2.10.0"
|
||||
version = "2.13.0"
|
||||
edition = "2024"
|
||||
license = "MPL-2.0"
|
||||
rust-version = "1.85"
|
||||
@@ -49,9 +49,11 @@ async-native-tls = { version = "0.5", default-features = false, features = ["run
|
||||
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 }
|
||||
blake3 = "1.8.2"
|
||||
brotli = { version = "8", default-features=false, features = ["std"] }
|
||||
bytes = "1"
|
||||
chrono = { workspace = true, features = ["alloc", "clock", "std"] }
|
||||
colorutils-rs = { version = "0.7.5", default-features = false }
|
||||
data-encoding = "2.9.0"
|
||||
escaper = "0.1"
|
||||
fast-socks5 = "0.10"
|
||||
@@ -69,7 +71,7 @@ iroh-gossip = { version = "0.35", default-features = false, features = ["net"] }
|
||||
iroh = { version = "0.35", default-features = false }
|
||||
kamadak-exif = "0.6.1"
|
||||
libc = { workspace = true }
|
||||
mail-builder = { version = "0.4.3", default-features = false }
|
||||
mail-builder = { version = "0.4.4", default-features = false }
|
||||
mailparse = { workspace = true }
|
||||
mime = "0.3.17"
|
||||
num_cpus = "1.17"
|
||||
@@ -85,7 +87,6 @@ quoted_printable = "0.5"
|
||||
rand = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
rusqlite = { workspace = true, features = ["sqlcipher"] }
|
||||
rust-hsluv = "0.1"
|
||||
rustls-pki-types = "1.12.0"
|
||||
rustls = { version = "0.23.22", default-features = false }
|
||||
sanitize-filename = { workspace = true }
|
||||
@@ -107,12 +108,11 @@ tokio-stream = { version = "0.1.17", 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"] }
|
||||
toml = "0.8"
|
||||
toml = "0.9"
|
||||
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.
|
||||
@@ -157,6 +157,11 @@ name = "receive_emails"
|
||||
required-features = ["internals"]
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "benchmark_decrypting"
|
||||
required-features = ["internals"]
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "get_chat_msgs"
|
||||
harness = false
|
||||
@@ -182,11 +187,11 @@ deltachat-contact-tools = { path = "deltachat-contact-tools" }
|
||||
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
|
||||
deltachat = { path = ".", default-features = false }
|
||||
futures = "0.3.31"
|
||||
futures-lite = "2.6.0"
|
||||
futures-lite = "2.6.1"
|
||||
libc = "0.2"
|
||||
log = "0.4"
|
||||
mailparse = "0.16.1"
|
||||
nu-ansi-term = "0.46"
|
||||
nu-ansi-term = "0.50"
|
||||
num-traits = "0.2"
|
||||
rand = "0.8"
|
||||
regex = "1.10"
|
||||
@@ -194,10 +199,10 @@ rusqlite = "0.36"
|
||||
sanitize-filename = "0.5"
|
||||
serde = "1.0"
|
||||
serde_json = "1"
|
||||
tempfile = "3.20.0"
|
||||
tempfile = "3.21.0"
|
||||
thiserror = "2"
|
||||
tokio = "1"
|
||||
tokio-util = "0.7.14"
|
||||
tokio-util = "0.7.16"
|
||||
tracing-subscriber = "0.3"
|
||||
yerpc = "0.6.4"
|
||||
|
||||
|
||||
12
STYLE.md
12
STYLE.md
@@ -112,6 +112,18 @@ Follow
|
||||
<https://doc.rust-lang.org/core/error/index.html#common-message-styles>
|
||||
for `.expect` message style.
|
||||
|
||||
## BTreeMap vs HashMap
|
||||
|
||||
Prefer [BTreeMap](https://doc.rust-lang.org/std/collections/struct.BTreeMap.html)
|
||||
over [HashMap](https://doc.rust-lang.org/std/collections/struct.HashMap.html)
|
||||
and [BTreeSet](https://doc.rust-lang.org/std/collections/struct.BTreeSet.html)
|
||||
over [HashSet](https://doc.rust-lang.org/std/collections/struct.HashSet.html)
|
||||
as iterating over these structures returns items in deterministic order.
|
||||
|
||||
Non-deterministic code may result in difficult to reproduce bugs,
|
||||
flaky tests, regression tests that miss bugs
|
||||
or different behavior on different devices when processing the same messages.
|
||||
|
||||
## Logging
|
||||
|
||||
For logging, use `info!`, `warn!` and `error!` macros.
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
199
benches/benchmark_decrypting.rs
Normal file
199
benches/benchmark_decrypting.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
//! Benchmarks for message decryption,
|
||||
//! comparing decryption of symmetrically-encrypted messages
|
||||
//! to decryption of asymmetrically-encrypted messages.
|
||||
//!
|
||||
//! Call with
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo bench --bench benchmark_decrypting --features="internals"
|
||||
//! ```
|
||||
//!
|
||||
//! or, if you want to only run e.g. the 'Decrypt a symmetrically encrypted message' benchmark:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo bench --bench benchmark_decrypting --features="internals" -- 'Decrypt a symmetrically encrypted message'
|
||||
//! ```
|
||||
//!
|
||||
//! You can also pass a substring.
|
||||
//! So, you can run all 'Decrypt and parse' benchmarks with:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo bench --bench benchmark_decrypting --features="internals" -- 'Decrypt and parse'
|
||||
//! ```
|
||||
//!
|
||||
//! Symmetric decryption has to try out all known secrets,
|
||||
//! You can benchmark this by adapting the `NUM_SECRETS` variable.
|
||||
|
||||
use std::hint::black_box;
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use deltachat::internals_for_benchmarks::create_broadcast_shared_secret;
|
||||
use deltachat::internals_for_benchmarks::create_dummy_keypair;
|
||||
use deltachat::internals_for_benchmarks::save_broadcast_shared_secret;
|
||||
use deltachat::{
|
||||
Events,
|
||||
chat::ChatId,
|
||||
config::Config,
|
||||
context::Context,
|
||||
internals_for_benchmarks::key_from_asc,
|
||||
internals_for_benchmarks::parse_and_get_text,
|
||||
internals_for_benchmarks::store_self_keypair,
|
||||
pgp::{KeyPair, decrypt, pk_encrypt, symm_encrypt_message},
|
||||
stock_str::StockStrings,
|
||||
};
|
||||
use rand::{Rng, thread_rng};
|
||||
use tempfile::tempdir;
|
||||
|
||||
const NUM_SECRETS: usize = 500;
|
||||
|
||||
async fn create_context() -> Context {
|
||||
let dir = tempdir().unwrap();
|
||||
let dbfile = dir.path().join("db.sqlite");
|
||||
let context = Context::new(dbfile.as_path(), 100, Events::new(), StockStrings::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
context
|
||||
.set_config(Config::ConfiguredAddr, Some("bob@example.net"))
|
||||
.await
|
||||
.unwrap();
|
||||
let secret = key_from_asc(include_str!("../test-data/key/bob-secret.asc")).unwrap();
|
||||
let public = secret.signed_public_key();
|
||||
let key_pair = KeyPair { public, secret };
|
||||
store_self_keypair(&context, &key_pair)
|
||||
.await
|
||||
.expect("Failed to save key");
|
||||
|
||||
context
|
||||
}
|
||||
|
||||
fn criterion_benchmark(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("Decrypt");
|
||||
|
||||
// ===========================================================================================
|
||||
// Benchmarks for decryption only, without any other parsing
|
||||
// ===========================================================================================
|
||||
|
||||
group.sample_size(10);
|
||||
|
||||
group.bench_function("Decrypt a symmetrically encrypted message", |b| {
|
||||
let plain = generate_plaintext();
|
||||
let secrets = generate_secrets();
|
||||
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
let secret = secrets[NUM_SECRETS / 2].clone();
|
||||
symm_encrypt_message(
|
||||
plain.clone(),
|
||||
black_box(&secret),
|
||||
create_dummy_keypair("alice@example.org").unwrap().secret,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
b.iter(|| {
|
||||
let mut msg =
|
||||
decrypt(encrypted.clone().into_bytes(), &[], black_box(&secrets)).unwrap();
|
||||
let decrypted = msg.as_data_vec().unwrap();
|
||||
|
||||
assert_eq!(black_box(decrypted), plain);
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("Decrypt a public-key encrypted message", |b| {
|
||||
let plain = generate_plaintext();
|
||||
let key_pair = create_dummy_keypair("alice@example.org").unwrap();
|
||||
let secrets = generate_secrets();
|
||||
let encrypted = tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
pk_encrypt(
|
||||
plain.clone(),
|
||||
vec![black_box(key_pair.public.clone())],
|
||||
Some(key_pair.secret.clone()),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
b.iter(|| {
|
||||
let mut msg = decrypt(
|
||||
encrypted.clone().into_bytes(),
|
||||
std::slice::from_ref(&key_pair.secret),
|
||||
black_box(&secrets),
|
||||
)
|
||||
.unwrap();
|
||||
let decrypted = msg.as_data_vec().unwrap();
|
||||
|
||||
assert_eq!(black_box(decrypted), plain);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================================
|
||||
// Benchmarks for the whole parsing pipeline, incl. decryption (but excl. receive_imf())
|
||||
// ===========================================================================================
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let mut secrets = generate_secrets();
|
||||
|
||||
// "secret" is the shared secret that was used to encrypt text_symmetrically_encrypted.eml.
|
||||
// Put it into the middle of our secrets:
|
||||
secrets[NUM_SECRETS / 2] = "secret".to_string();
|
||||
|
||||
let context = rt.block_on(async {
|
||||
let context = create_context().await;
|
||||
for (i, secret) in secrets.iter().enumerate() {
|
||||
save_broadcast_shared_secret(&context, ChatId::new(10 + i as u32), secret)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
context
|
||||
});
|
||||
|
||||
group.bench_function("Decrypt and parse a symmetrically encrypted message", |b| {
|
||||
b.to_async(&rt).iter(|| {
|
||||
let ctx = context.clone();
|
||||
async move {
|
||||
let text = parse_and_get_text(
|
||||
&ctx,
|
||||
include_bytes!("../test-data/message/text_symmetrically_encrypted.eml"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(text, "Symmetrically encrypted message");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("Decrypt and parse a public-key encrypted message", |b| {
|
||||
b.to_async(&rt).iter(|| {
|
||||
let ctx = context.clone();
|
||||
async move {
|
||||
let text = parse_and_get_text(
|
||||
&ctx,
|
||||
include_bytes!("../test-data/message/text_from_alice_encrypted.eml"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(text, "hi");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn generate_secrets() -> Vec<String> {
|
||||
let secrets: Vec<String> = (0..NUM_SECRETS)
|
||||
.map(|_| create_broadcast_shared_secret())
|
||||
.collect();
|
||||
secrets
|
||||
}
|
||||
|
||||
fn generate_plaintext() -> Vec<u8> {
|
||||
let mut plain: Vec<u8> = vec![0; 500];
|
||||
thread_rng().fill(&mut plain[..]);
|
||||
plain
|
||||
}
|
||||
|
||||
criterion_group!(benches, criterion_benchmark);
|
||||
criterion_main!(benches);
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat_ffi"
|
||||
version = "2.10.0"
|
||||
version = "2.13.0"
|
||||
description = "Deltachat FFI"
|
||||
edition = "2018"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -415,7 +415,6 @@ char* dc_get_blobdir (const dc_context_t* context);
|
||||
* As for `displayname` and `selfstatus`, also the avatar is sent to the recipients.
|
||||
* To save traffic, however, the avatar is attached only as needed
|
||||
* and also recoded to a reasonable size.
|
||||
* - `e2ee_enabled` = 0=no end-to-end-encryption, 1=prefer end-to-end-encryption (default)
|
||||
* - `mdns_enabled` = 0=do not send or request read receipts,
|
||||
* 1=send and request read receipts
|
||||
* default=send and request read receipts, only send but not request if `bot` is set
|
||||
@@ -1215,6 +1214,103 @@ void dc_set_webxdc_integration (dc_context_t* context, const char* f
|
||||
uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t chat_id);
|
||||
|
||||
|
||||
/**
|
||||
* Start an outgoing call.
|
||||
* This sends a message of type #DC_MSG_CALL with all relevant information to the callee,
|
||||
* who will get informed by an #DC_EVENT_INCOMING_CALL event and rings.
|
||||
*
|
||||
* Possible actions during ringing:
|
||||
*
|
||||
* - caller cancels the call using dc_end_call():
|
||||
* callee receives #DC_EVENT_CALL_ENDED and has a "Missed Call"
|
||||
*
|
||||
* - callee accepts using dc_accept_incoming_call():
|
||||
* caller receives #DC_EVENT_OUTGOING_CALL_ACCEPTED.
|
||||
* callee's devices receive #DC_EVENT_INCOMING_CALL_ACCEPTED, call starts
|
||||
*
|
||||
* - callee declines using dc_end_call():
|
||||
* caller receives #DC_EVENT_CALL_ENDED and has a "Declinced Call".
|
||||
* callee's other devices receive #DC_EVENT_CALL_ENDED and have a "Cancelled Call",
|
||||
*
|
||||
* - callee is already in a call:
|
||||
* in this case, UI may decide to show a notification instead of ringing.
|
||||
* otherwise, this is same as timeout
|
||||
*
|
||||
* - timeout:
|
||||
* after 1 minute without action,
|
||||
* caller and callee receive #DC_EVENT_CALL_ENDED
|
||||
* to prevent endless ringing of callee
|
||||
* in case caller got offline without being able to send cancellation message.
|
||||
* for caller, this is a "Cancelled Call";
|
||||
* for callee, this is a "Missed Call"
|
||||
*
|
||||
* Actions during the call:
|
||||
*
|
||||
* - caller ends the call using dc_end_call():
|
||||
* callee receives #DC_EVENT_CALL_ENDED
|
||||
*
|
||||
* - callee ends the call using dc_end_call():
|
||||
* caller receives #DC_EVENT_CALL_ENDED
|
||||
*
|
||||
* Note, that the events are for updating the call screen,
|
||||
* possible status messages are added and updated as usual, including the known events.
|
||||
* In the UI, the sorted chatlist is used as an overview about calls as well as messages.
|
||||
* To place a call with a contact that has no chat yet, use dc_create_chat_by_contact_id() first.
|
||||
*
|
||||
* UI will usually allow only one call at the same time,
|
||||
* this has to be tracked by UI across profile, the core does not track this.
|
||||
*
|
||||
* @memberof dc_context_t
|
||||
* @param context The context object.
|
||||
* @param chat_id The chat to place a call for.
|
||||
* This needs to be a one-to-one chat.
|
||||
* @param place_call_info any data that other devices receive
|
||||
* in #DC_EVENT_INCOMING_CALL.
|
||||
* @return ID of the system message announcing the call.
|
||||
*/
|
||||
uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info);
|
||||
|
||||
|
||||
/**
|
||||
* Accept incoming call.
|
||||
*
|
||||
* This implicitly accepts the contact request, if not yet done.
|
||||
* All affected devices will receive
|
||||
* either #DC_EVENT_OUTGOING_CALL_ACCEPTED or #DC_EVENT_INCOMING_CALL_ACCEPTED.
|
||||
*
|
||||
* If the call is already accepted or ended, nothing happens.
|
||||
*
|
||||
* @memberof dc_context_t
|
||||
* @param context The context object.
|
||||
* @param msg_id The ID of the call to accept.
|
||||
* This is the ID reported by #DC_EVENT_INCOMING_CALL
|
||||
* and equals to the ID of the corresponding info message.
|
||||
* @param accept_call_info any data that other devices receive
|
||||
* in #DC_EVENT_OUTGOING_CALL_ACCEPTED.
|
||||
* @return 1=success, 0=error
|
||||
*/
|
||||
int dc_accept_incoming_call (dc_context_t* context, uint32_t msg_id, const char* accept_call_info);
|
||||
|
||||
|
||||
/**
|
||||
* End incoming or outgoing call.
|
||||
*
|
||||
* For unaccepted calls ended by the caller, this is a "cancellation".
|
||||
* Unaccepted calls ended by the callee are a "decline".
|
||||
* If the call was accepted, this is a "hangup".
|
||||
*
|
||||
* All participant devices get informed about the ended call via #DC_EVENT_CALL_ENDED.
|
||||
*
|
||||
* If the call is already ended, nothing happens.
|
||||
*
|
||||
* @memberof dc_context_t
|
||||
* @param context The context object.
|
||||
* @param msg_id the ID of the call.
|
||||
* @return 1=success, 0=error
|
||||
*/
|
||||
int dc_end_call (dc_context_t* context, uint32_t msg_id);
|
||||
|
||||
|
||||
/**
|
||||
* Save a draft for a chat in the database.
|
||||
*
|
||||
@@ -3859,28 +3955,6 @@ int dc_chat_is_protected (const dc_chat_t* chat);
|
||||
int dc_chat_is_encrypted (const dc_chat_t *chat);
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the chat was protected, and then an incoming message broke this protection.
|
||||
*
|
||||
* This function is only useful if the UI enabled the `verified_one_on_one_chats` feature flag,
|
||||
* otherwise it will return false for all chats.
|
||||
*
|
||||
* 1:1 chats are automatically set as protected when a contact is verified.
|
||||
* When a message comes in that is not encrypted / signed correctly,
|
||||
* the chat is automatically set as unprotected again.
|
||||
* dc_chat_is_protection_broken() will return true until dc_accept_chat() is called.
|
||||
*
|
||||
* The UI should let the user confirm that this is OK with a message like
|
||||
* `Bob sent a message from another device. Tap to learn more` and then call dc_accept_chat().
|
||||
*
|
||||
* @deprecated 2025-07 chats protection cannot break any longer
|
||||
* @memberof dc_chat_t
|
||||
* @param chat The chat object.
|
||||
* @return 1=chat protection broken, 0=otherwise.
|
||||
*/
|
||||
int dc_chat_is_protection_broken (const dc_chat_t* chat);
|
||||
|
||||
|
||||
/**
|
||||
* Check if locations are sent to the chat
|
||||
* at the time the object was created using dc_get_chat().
|
||||
@@ -5638,6 +5712,12 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot);
|
||||
#define DC_MSG_VIDEOCHAT_INVITATION 70
|
||||
|
||||
|
||||
/**
|
||||
* Message indicating an incoming or outgoing call.
|
||||
*/
|
||||
#define DC_MSG_CALL 71
|
||||
|
||||
|
||||
/**
|
||||
* The message is a webxdc instance.
|
||||
*
|
||||
@@ -6482,7 +6562,7 @@ void dc_event_unref(dc_event_t* event);
|
||||
* @param data1 (int) The ID of the contact that wants to join.
|
||||
* @param data2 (int) The progress as:
|
||||
* 300=vg-/vc-request received, typically shown as "bob@addr joins".
|
||||
* 600=vg-/vc-request-with-auth received, vg-member-added/vc-contact-confirm sent, typically shown as "bob@addr verified".
|
||||
* 600=vg-/vc-request-with-auth received and verified, 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.
|
||||
*/
|
||||
@@ -6636,6 +6716,61 @@ void dc_event_unref(dc_event_t* event);
|
||||
*/
|
||||
#define DC_EVENT_CHANNEL_OVERFLOW 2400
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Incoming call.
|
||||
* UI will usually start ringing,
|
||||
* or show a notification if there is already a call in some profile.
|
||||
*
|
||||
* Together with this event,
|
||||
* a message of type #DC_MSG_CALL is added to the corresponding chat;
|
||||
* this message is announced and updated by the usual even as #DC_EVENT_MSGS_CHANGED.
|
||||
*
|
||||
* If user takes action, dc_accept_incoming_call() or dc_end_call() should be called.
|
||||
*
|
||||
* Otherwise, ringing should end on #DC_EVENT_CALL_ENDED
|
||||
* or #DC_EVENT_INCOMING_CALL_ACCEPTED
|
||||
*
|
||||
* @param data1 (int) msg_id ID of the message referring to the call.
|
||||
* @param data2 (char*) place_call_info, text passed to dc_place_outgoing_call()
|
||||
*/
|
||||
#define DC_EVENT_INCOMING_CALL 2550
|
||||
|
||||
/**
|
||||
* The callee accepted an incoming call on this or another device using dc_accept_incoming_call().
|
||||
* The caller gets the event #DC_EVENT_OUTGOING_CALL_ACCEPTED at the same time.
|
||||
*
|
||||
* The event is sent unconditionally when the corresponding message is received.
|
||||
* UI should only take action in case call UI was opened before, otherwise the event should be ignored.
|
||||
*
|
||||
* @param data1 (int) msg_id ID of the message referring to the call
|
||||
*/
|
||||
#define DC_EVENT_INCOMING_CALL_ACCEPTED 2560
|
||||
|
||||
/**
|
||||
* A call placed using dc_place_outgoing_call() was accepted by the callee using dc_accept_incoming_call().
|
||||
*
|
||||
* The event is sent unconditionally when the corresponding message is received.
|
||||
* UI should only take action in case call UI was opened before, otherwise the event should be ignored.
|
||||
*
|
||||
* @param data1 (int) msg_id ID of the message referring to the call
|
||||
* @param data2 (char*) accept_call_info, text passed to dc_accept_incoming_call()
|
||||
*/
|
||||
#define DC_EVENT_OUTGOING_CALL_ACCEPTED 2570
|
||||
|
||||
/**
|
||||
* An incoming or outgoing call was ended using dc_end_call().
|
||||
* Moreover, the event is sent when the call was not accepted within 1 minute timeout.
|
||||
*
|
||||
* The event is sent unconditionally when the corresponding message is received.
|
||||
* UI should only take action in case call UI was opened before, otherwise the event should be ignored.
|
||||
*
|
||||
* @param data1 (int) msg_id ID of the message referring to the call
|
||||
*/
|
||||
#define DC_EVENT_CALL_ENDED 2580
|
||||
|
||||
|
||||
/**
|
||||
* @}
|
||||
*/
|
||||
@@ -7056,6 +7191,8 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// "Unknown sender for this chat. See 'info' for more details."
|
||||
///
|
||||
/// Use as message text if assigning the message to a chat is not totally correct.
|
||||
///
|
||||
/// @deprecated 2025-08-18
|
||||
#define DC_STR_UNKNOWN_SENDER_FOR_CHAT 72
|
||||
|
||||
/// "Message from %1$s"
|
||||
@@ -7598,6 +7735,18 @@ void dc_event_unref(dc_event_t* event);
|
||||
/// `%2$s` will be replaced by name and address of the contact.
|
||||
#define DC_STR_EPHEMERAL_TIMER_WEEKS_BY_OTHER 157
|
||||
|
||||
/// "You set message deletion timer to 1 year."
|
||||
///
|
||||
/// Used in status messages.
|
||||
#define DC_STR_EPHEMERAL_TIMER_1_YEAR_BY_YOU 158
|
||||
|
||||
/// "Message deletion timer is set to 1 year by %1$s."
|
||||
///
|
||||
/// `%1$s` will be replaced by name and address of the contact.
|
||||
///
|
||||
/// Used in status messages.
|
||||
#define DC_STR_EPHEMERAL_TIMER_1_YEAR_BY_OTHER 159
|
||||
|
||||
/// "Scan to set up second device for %1$s"
|
||||
///
|
||||
/// `%1$s` will be replaced by name and address of the account.
|
||||
|
||||
@@ -375,7 +375,7 @@ pub unsafe extern "C" fn dc_get_connectivity(context: *const dc_context_t) -> li
|
||||
return 0;
|
||||
}
|
||||
let ctx = &*context;
|
||||
block_on(ctx.get_connectivity()) as u32 as libc::c_int
|
||||
ctx.get_connectivity() as u32 as libc::c_int
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
@@ -556,6 +556,10 @@ pub unsafe extern "C" fn dc_event_get_id(event: *mut dc_event_t) -> libc::c_int
|
||||
EventType::AccountsChanged => 2302,
|
||||
EventType::AccountsItemChanged => 2303,
|
||||
EventType::EventChannelOverflow { .. } => 2400,
|
||||
EventType::IncomingCall { .. } => 2550,
|
||||
EventType::IncomingCallAccepted { .. } => 2560,
|
||||
EventType::OutgoingCallAccepted { .. } => 2570,
|
||||
EventType::CallEnded { .. } => 2580,
|
||||
#[allow(unreachable_patterns)]
|
||||
#[cfg(test)]
|
||||
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),
|
||||
@@ -619,7 +623,11 @@ pub unsafe extern "C" fn dc_event_get_data1_int(event: *mut dc_event_t) -> libc:
|
||||
EventType::WebxdcRealtimeData { msg_id, .. }
|
||||
| EventType::WebxdcStatusUpdate { msg_id, .. }
|
||||
| EventType::WebxdcRealtimeAdvertisementReceived { msg_id }
|
||||
| EventType::WebxdcInstanceDeleted { msg_id, .. } => msg_id.to_u32() as libc::c_int,
|
||||
| EventType::WebxdcInstanceDeleted { msg_id, .. }
|
||||
| EventType::IncomingCall { msg_id, .. }
|
||||
| EventType::IncomingCallAccepted { msg_id, .. }
|
||||
| EventType::OutgoingCallAccepted { msg_id, .. }
|
||||
| EventType::CallEnded { msg_id, .. } => msg_id.to_u32() as libc::c_int,
|
||||
EventType::ChatlistItemChanged { chat_id } => {
|
||||
chat_id.unwrap_or_default().to_u32() as libc::c_int
|
||||
}
|
||||
@@ -671,6 +679,10 @@ pub unsafe extern "C" fn dc_event_get_data2_int(event: *mut dc_event_t) -> libc:
|
||||
| EventType::ChatModified(_)
|
||||
| EventType::ChatDeleted { .. }
|
||||
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
|
||||
| EventType::IncomingCall { .. }
|
||||
| EventType::IncomingCallAccepted { .. }
|
||||
| EventType::OutgoingCallAccepted { .. }
|
||||
| EventType::CallEnded { .. }
|
||||
| EventType::EventChannelOverflow { .. } => 0,
|
||||
EventType::MsgsChanged { msg_id, .. }
|
||||
| EventType::ReactionsChanged { msg_id, .. }
|
||||
@@ -767,8 +779,21 @@ pub unsafe extern "C" fn dc_event_get_data2_str(event: *mut dc_event_t) -> *mut
|
||||
| EventType::ChatlistChanged
|
||||
| EventType::AccountsChanged
|
||||
| EventType::AccountsItemChanged
|
||||
| EventType::WebxdcRealtimeAdvertisementReceived { .. }
|
||||
| EventType::EventChannelOverflow { .. } => ptr::null_mut(),
|
||||
| EventType::IncomingCallAccepted { .. }
|
||||
| EventType::WebxdcRealtimeAdvertisementReceived { .. } => ptr::null_mut(),
|
||||
EventType::IncomingCall {
|
||||
place_call_info, ..
|
||||
} => {
|
||||
let data2 = place_call_info.to_c_string().unwrap_or_default();
|
||||
data2.into_raw()
|
||||
}
|
||||
EventType::OutgoingCallAccepted {
|
||||
accept_call_info, ..
|
||||
} => {
|
||||
let data2 = accept_call_info.to_c_string().unwrap_or_default();
|
||||
data2.into_raw()
|
||||
}
|
||||
EventType::CallEnded { .. } | EventType::EventChannelOverflow { .. } => ptr::null_mut(),
|
||||
EventType::ConfigureProgress { comment, .. } => {
|
||||
if let Some(comment) = comment {
|
||||
comment.to_c_string().unwrap_or_default().into_raw()
|
||||
@@ -1167,6 +1192,61 @@ pub unsafe extern "C" fn dc_init_webxdc_integration(
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_place_outgoing_call(
|
||||
context: *mut dc_context_t,
|
||||
chat_id: u32,
|
||||
place_call_info: *const libc::c_char,
|
||||
) -> u32 {
|
||||
if context.is_null() || chat_id == 0 {
|
||||
eprintln!("ignoring careless call to dc_place_outgoing_call()");
|
||||
return 0;
|
||||
}
|
||||
let ctx = &*context;
|
||||
let chat_id = ChatId::new(chat_id);
|
||||
let place_call_info = to_string_lossy(place_call_info);
|
||||
|
||||
block_on(ctx.place_outgoing_call(chat_id, place_call_info))
|
||||
.context("Failed to place call")
|
||||
.log_err(ctx)
|
||||
.map(|msg_id| msg_id.to_u32())
|
||||
.unwrap_or_log_default(ctx, "Failed to place call")
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accept_incoming_call(
|
||||
context: *mut dc_context_t,
|
||||
msg_id: u32,
|
||||
accept_call_info: *const libc::c_char,
|
||||
) -> libc::c_int {
|
||||
if context.is_null() || msg_id == 0 {
|
||||
eprintln!("ignoring careless call to dc_accept_incoming_call()");
|
||||
return 0;
|
||||
}
|
||||
let ctx = &*context;
|
||||
let msg_id = MsgId::new(msg_id);
|
||||
let accept_call_info = to_string_lossy(accept_call_info);
|
||||
|
||||
block_on(ctx.accept_incoming_call(msg_id, accept_call_info))
|
||||
.context("Failed to accept call")
|
||||
.is_ok() as libc::c_int
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_end_call(context: *mut dc_context_t, msg_id: u32) -> libc::c_int {
|
||||
if context.is_null() || msg_id == 0 {
|
||||
eprintln!("ignoring careless call to dc_end_call()");
|
||||
return 0;
|
||||
}
|
||||
let ctx = &*context;
|
||||
let msg_id = MsgId::new(msg_id);
|
||||
|
||||
block_on(ctx.end_call(msg_id))
|
||||
.context("Failed to end call")
|
||||
.log_err(ctx)
|
||||
.is_ok() as libc::c_int
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_set_draft(
|
||||
context: *mut dc_context_t,
|
||||
@@ -3165,16 +3245,6 @@ pub unsafe extern "C" fn dc_chat_is_encrypted(chat: *mut dc_chat_t) -> libc::c_i
|
||||
.unwrap_or_log_default(&ffi_chat.context, "Failed dc_chat_is_encrypted") as libc::c_int
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_chat_is_protection_broken(chat: *mut dc_chat_t) -> libc::c_int {
|
||||
if chat.is_null() {
|
||||
eprintln!("ignoring careless call to dc_chat_is_protection_broken()");
|
||||
return 0;
|
||||
}
|
||||
let ffi_chat = &*chat;
|
||||
ffi_chat.chat.is_protection_broken() as libc::c_int
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_chat_is_sending_locations(chat: *mut dc_chat_t) -> libc::c_int {
|
||||
if chat.is_null() {
|
||||
|
||||
@@ -45,6 +45,7 @@ impl Lot {
|
||||
Self::Qr(qr) => match qr {
|
||||
Qr::AskVerifyContact { .. } => None,
|
||||
Qr::AskVerifyGroup { grpname, .. } => Some(Cow::Borrowed(grpname)),
|
||||
Qr::AskJoinBroadcast { broadcast_name, .. } => Some(Cow::Borrowed(broadcast_name)),
|
||||
Qr::FprOk { .. } => None,
|
||||
Qr::FprMismatch { .. } => None,
|
||||
Qr::FprWithoutAddr { fingerprint, .. } => Some(Cow::Borrowed(fingerprint)),
|
||||
@@ -99,6 +100,7 @@ impl Lot {
|
||||
Self::Qr(qr) => match qr {
|
||||
Qr::AskVerifyContact { .. } => LotState::QrAskVerifyContact,
|
||||
Qr::AskVerifyGroup { .. } => LotState::QrAskVerifyGroup,
|
||||
Qr::AskJoinBroadcast { .. } => LotState::QrAskJoinBroadcast,
|
||||
Qr::FprOk { .. } => LotState::QrFprOk,
|
||||
Qr::FprMismatch { .. } => LotState::QrFprMismatch,
|
||||
Qr::FprWithoutAddr { .. } => LotState::QrFprWithoutAddr,
|
||||
@@ -126,6 +128,7 @@ impl Lot {
|
||||
Self::Qr(qr) => match qr {
|
||||
Qr::AskVerifyContact { contact_id, .. } => contact_id.to_u32(),
|
||||
Qr::AskVerifyGroup { .. } => Default::default(),
|
||||
Qr::AskJoinBroadcast { .. } => Default::default(),
|
||||
Qr::FprOk { contact_id } => contact_id.to_u32(),
|
||||
Qr::FprMismatch { contact_id } => contact_id.unwrap_or_default().to_u32(),
|
||||
Qr::FprWithoutAddr { .. } => Default::default(),
|
||||
@@ -169,6 +172,9 @@ pub enum LotState {
|
||||
/// text1=groupname
|
||||
QrAskVerifyGroup = 202,
|
||||
|
||||
/// text1=broadcast_name
|
||||
QrAskJoinBroadcast = 204,
|
||||
|
||||
/// id=contact
|
||||
QrFprOk = 210,
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-jsonrpc"
|
||||
version = "2.10.0"
|
||||
version = "2.13.0"
|
||||
description = "DeltaChat JSON-RPC API"
|
||||
edition = "2021"
|
||||
license = "MPL-2.0"
|
||||
|
||||
@@ -999,7 +999,7 @@ impl CommandApi {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create a new **broadcast channel**
|
||||
/// Create a new, outgoing **broadcast channel**
|
||||
/// (called "Channel" in the UI).
|
||||
///
|
||||
/// Broadcast channels are similar to groups on the sending device,
|
||||
@@ -1908,7 +1908,7 @@ impl CommandApi {
|
||||
/// If the connectivity changes, a #DC_EVENT_CONNECTIVITY_CHANGED will be emitted.
|
||||
async fn get_connectivity(&self, account_id: u32) -> Result<u32> {
|
||||
let ctx = self.get_context(account_id).await?;
|
||||
Ok(ctx.get_connectivity().await as u32)
|
||||
Ok(ctx.get_connectivity() as u32)
|
||||
}
|
||||
|
||||
/// Get an overview of the current connectivity, and possibly more statistics.
|
||||
|
||||
@@ -71,8 +71,6 @@ pub struct FullChat {
|
||||
fresh_message_counter: usize,
|
||||
// is_group - please check over chat.type in frontend instead
|
||||
is_contact_request: bool,
|
||||
/// Deprecated 2025-07. Chats protection cannot break any longer.
|
||||
is_protection_broken: bool,
|
||||
|
||||
is_device_chat: bool,
|
||||
self_in_group: bool,
|
||||
@@ -147,7 +145,6 @@ impl FullChat {
|
||||
color,
|
||||
fresh_message_counter,
|
||||
is_contact_request: chat.is_contact_request(),
|
||||
is_protection_broken: chat.is_protection_broken(),
|
||||
is_device_chat: chat.is_device_talk(),
|
||||
self_in_group: contact_ids.contains(&ContactId::SELF),
|
||||
is_muted: chat.is_muted(),
|
||||
@@ -218,8 +215,6 @@ pub struct BasicChat {
|
||||
is_self_talk: bool,
|
||||
color: String,
|
||||
is_contact_request: bool,
|
||||
/// Deprecated 2025-07. Chats protection cannot break any longer.
|
||||
is_protection_broken: bool,
|
||||
|
||||
is_device_chat: bool,
|
||||
is_muted: bool,
|
||||
@@ -249,7 +244,6 @@ impl BasicChat {
|
||||
is_self_talk: chat.is_self_talk(),
|
||||
color,
|
||||
is_contact_request: chat.is_contact_request(),
|
||||
is_protection_broken: chat.is_protection_broken(),
|
||||
is_device_chat: chat.is_device_talk(),
|
||||
is_muted: chat.is_muted(),
|
||||
})
|
||||
|
||||
@@ -38,12 +38,6 @@ pub struct ContactObject {
|
||||
/// See [`Self::verifier_id`]/`Contact.verifierId` for a guidance how to display these information.
|
||||
is_verified: bool,
|
||||
|
||||
/// True if the contact profile title should have a green checkmark.
|
||||
///
|
||||
/// This indicates whether 1:1 chat has a green checkmark
|
||||
/// or will have a green checkmark if created.
|
||||
is_profile_verified: bool,
|
||||
|
||||
/// The contact ID that verified a contact.
|
||||
///
|
||||
/// As verifier may be unknown,
|
||||
@@ -87,7 +81,6 @@ impl ContactObject {
|
||||
None => None,
|
||||
};
|
||||
let is_verified = contact.is_verified(context).await?;
|
||||
let is_profile_verified = contact.is_profile_verified(context).await?;
|
||||
|
||||
let verifier_id = contact
|
||||
.get_verifier_id(context)
|
||||
@@ -109,7 +102,6 @@ impl ContactObject {
|
||||
is_key_contact: contact.is_key_contact(),
|
||||
e2ee_avail: contact.e2ee_avail(context).await?,
|
||||
is_verified,
|
||||
is_profile_verified,
|
||||
verifier_id,
|
||||
last_seen: contact.last_seen(),
|
||||
was_seen_recently: contact.was_seen_recently(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use deltachat::{Event as CoreEvent, EventType as CoreEventType};
|
||||
use num_traits::ToPrimitive;
|
||||
use serde::Serialize;
|
||||
use typescript_type_def::TypeDef;
|
||||
|
||||
@@ -303,9 +304,14 @@ pub enum EventType {
|
||||
/// ID of the contact that wants to join.
|
||||
contact_id: u32,
|
||||
|
||||
/// The type of the joined chat.
|
||||
/// This can take the same values
|
||||
/// as `BasicChat.chatType` ([`crate::api::types::chat::BasicChat::chat_type`]).
|
||||
chat_type: 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".
|
||||
/// 600=vg-/vc-request-with-auth received and verified, 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,
|
||||
@@ -416,6 +422,35 @@ pub enum EventType {
|
||||
/// Number of events skipped.
|
||||
n: u64,
|
||||
},
|
||||
|
||||
/// Incoming call.
|
||||
IncomingCall {
|
||||
/// ID of the info message referring to the call.
|
||||
msg_id: u32,
|
||||
/// User-defined info as passed to place_outgoing_call()
|
||||
place_call_info: String,
|
||||
},
|
||||
|
||||
/// Incoming call accepted.
|
||||
/// This is esp. interesting to stop ringing on other devices.
|
||||
IncomingCallAccepted {
|
||||
/// ID of the info message referring to the call.
|
||||
msg_id: u32,
|
||||
},
|
||||
|
||||
/// Outgoing call accepted.
|
||||
OutgoingCallAccepted {
|
||||
/// ID of the info message referring to the call.
|
||||
msg_id: u32,
|
||||
/// User-defined info passed to dc_accept_incoming_call(
|
||||
accept_call_info: String,
|
||||
},
|
||||
|
||||
/// Call ended.
|
||||
CallEnded {
|
||||
/// ID of the info message referring to the call.
|
||||
msg_id: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<CoreEventType> for EventType {
|
||||
@@ -522,9 +557,11 @@ impl From<CoreEventType> for EventType {
|
||||
},
|
||||
CoreEventType::SecurejoinInviterProgress {
|
||||
contact_id,
|
||||
chat_type,
|
||||
progress,
|
||||
} => SecurejoinInviterProgress {
|
||||
contact_id: contact_id.to_u32(),
|
||||
chat_type: chat_type.to_u32().unwrap_or(0),
|
||||
progress,
|
||||
},
|
||||
CoreEventType::SecurejoinJoinerProgress {
|
||||
@@ -566,6 +603,26 @@ impl From<CoreEventType> for EventType {
|
||||
CoreEventType::EventChannelOverflow { n } => EventChannelOverflow { n },
|
||||
CoreEventType::AccountsChanged => AccountsChanged,
|
||||
CoreEventType::AccountsItemChanged => AccountsItemChanged,
|
||||
CoreEventType::IncomingCall {
|
||||
msg_id,
|
||||
place_call_info,
|
||||
} => IncomingCall {
|
||||
msg_id: msg_id.to_u32(),
|
||||
place_call_info,
|
||||
},
|
||||
CoreEventType::IncomingCallAccepted { msg_id } => IncomingCallAccepted {
|
||||
msg_id: msg_id.to_u32(),
|
||||
},
|
||||
CoreEventType::OutgoingCallAccepted {
|
||||
msg_id,
|
||||
accept_call_info,
|
||||
} => OutgoingCallAccepted {
|
||||
msg_id: msg_id.to_u32(),
|
||||
accept_call_info,
|
||||
},
|
||||
CoreEventType::CallEnded { msg_id } => CallEnded {
|
||||
msg_id: msg_id.to_u32(),
|
||||
},
|
||||
#[allow(unreachable_patterns)]
|
||||
#[cfg(test)]
|
||||
_ => unreachable!("This is just to silence a rust_analyzer false-positive"),
|
||||
|
||||
@@ -324,6 +324,9 @@ pub enum MessageViewtype {
|
||||
/// Message is an invitation to a videochat.
|
||||
VideochatInvitation,
|
||||
|
||||
/// Message is a call.
|
||||
Call,
|
||||
|
||||
/// Message is an webxdc instance.
|
||||
Webxdc,
|
||||
|
||||
@@ -346,6 +349,7 @@ impl From<Viewtype> for MessageViewtype {
|
||||
Viewtype::Video => MessageViewtype::Video,
|
||||
Viewtype::File => MessageViewtype::File,
|
||||
Viewtype::VideochatInvitation => MessageViewtype::VideochatInvitation,
|
||||
Viewtype::Call => MessageViewtype::Call,
|
||||
Viewtype::Webxdc => MessageViewtype::Webxdc,
|
||||
Viewtype::Vcard => MessageViewtype::Vcard,
|
||||
}
|
||||
@@ -365,6 +369,7 @@ impl From<MessageViewtype> for Viewtype {
|
||||
MessageViewtype::Video => Viewtype::Video,
|
||||
MessageViewtype::File => Viewtype::File,
|
||||
MessageViewtype::VideochatInvitation => Viewtype::VideochatInvitation,
|
||||
MessageViewtype::Call => Viewtype::Call,
|
||||
MessageViewtype::Webxdc => Viewtype::Webxdc,
|
||||
MessageViewtype::Vcard => Viewtype::Vcard,
|
||||
}
|
||||
@@ -437,6 +442,9 @@ pub enum SystemMessageType {
|
||||
|
||||
/// This message contains a users iroh node address.
|
||||
IrohNodeAddr,
|
||||
|
||||
CallAccepted,
|
||||
CallEnded,
|
||||
}
|
||||
|
||||
impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
|
||||
@@ -463,6 +471,8 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
|
||||
SystemMessage::IrohNodeAddr => SystemMessageType::IrohNodeAddr,
|
||||
SystemMessage::SecurejoinWait => SystemMessageType::SecurejoinWait,
|
||||
SystemMessage::SecurejoinWaitTimeout => SystemMessageType::SecurejoinWaitTimeout,
|
||||
SystemMessage::CallAccepted => SystemMessageType::CallAccepted,
|
||||
SystemMessage::CallEnded => SystemMessageType::CallEnded,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,23 @@ pub enum QrObject {
|
||||
/// Authentication code.
|
||||
authcode: String,
|
||||
},
|
||||
/// Ask the user whether to join the broadcast channel.
|
||||
AskJoinBroadcast {
|
||||
/// Chat name.
|
||||
broadcast_name: String,
|
||||
/// A string of random characters,
|
||||
/// uniquely identifying this broadcast channel in the database.
|
||||
/// Called `grpid` for historic reasons:
|
||||
/// The id of multi-user chats is always called `grpid` in the database
|
||||
/// because groups were once the only multi-user chats.
|
||||
grpid: String,
|
||||
/// ID of the contact who owns the channel and created the QR code.
|
||||
contact_id: u32,
|
||||
/// Fingerprint of the contact's key as scanned from the QR code.
|
||||
fingerprint: String,
|
||||
|
||||
authcode: String,
|
||||
},
|
||||
/// Contact fingerprint is verified.
|
||||
///
|
||||
/// Ask the user if they want to start chatting.
|
||||
@@ -207,6 +224,23 @@ impl From<Qr> for QrObject {
|
||||
authcode,
|
||||
}
|
||||
}
|
||||
Qr::AskJoinBroadcast {
|
||||
broadcast_name,
|
||||
grpid,
|
||||
contact_id,
|
||||
fingerprint,
|
||||
authcode,
|
||||
} => {
|
||||
let contact_id = contact_id.to_u32();
|
||||
let fingerprint = fingerprint.to_string();
|
||||
QrObject::AskJoinBroadcast {
|
||||
broadcast_name,
|
||||
grpid,
|
||||
contact_id,
|
||||
fingerprint,
|
||||
authcode,
|
||||
}
|
||||
}
|
||||
Qr::FprOk { contact_id } => {
|
||||
let contact_id = contact_id.to_u32();
|
||||
QrObject::FprOk { contact_id }
|
||||
|
||||
@@ -54,5 +54,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "dist/deltachat.d.ts",
|
||||
"version": "2.10.0"
|
||||
"version": "2.13.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-repl"
|
||||
version = "2.10.0"
|
||||
version = "2.13.0"
|
||||
license = "MPL-2.0"
|
||||
edition = "2021"
|
||||
repository = "https://github.com/chatmail/core"
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat-rpc-client"
|
||||
version = "2.10.0"
|
||||
version = "2.13.0"
|
||||
description = "Python client for Delta Chat core JSON-RPC interface"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
@@ -324,7 +324,7 @@ class Account:
|
||||
return Chat(self, self._rpc.create_group_chat(self.id, name, protect))
|
||||
|
||||
def create_broadcast(self, name: str) -> Chat:
|
||||
"""Create a new **broadcast channel**
|
||||
"""Create a new, outgoing **broadcast channel**
|
||||
(called "Channel" in the UI).
|
||||
|
||||
Broadcast channels are similar to groups on the sending device,
|
||||
|
||||
@@ -93,6 +93,17 @@ class Message:
|
||||
if event.kind == EventType.MSG_DELIVERED and event.msg_id == self.id:
|
||||
break
|
||||
|
||||
def resend(self) -> None:
|
||||
"""Resend messages and make information available for newly added chat members.
|
||||
Resending sends out the original message, however, recipients and webxdc-status may differ.
|
||||
Clients that already have the original message can still ignore the resent message as
|
||||
they have tracked the state by dedicated updates.
|
||||
|
||||
Some messages cannot be resent, eg. info-messages, drafts, already pending messages,
|
||||
or messages that are not sent by SELF.
|
||||
"""
|
||||
self._rpc.resend_messages(self.account.id, [self.id])
|
||||
|
||||
@futuremethod
|
||||
def send_webxdc_realtime_advertisement(self):
|
||||
"""Send an advertisement to join the realtime channel."""
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
import pytest
|
||||
|
||||
from deltachat_rpc_client import Chat, EventType, SpecialContactId
|
||||
from deltachat_rpc_client.const import ChatType
|
||||
from deltachat_rpc_client.rpc import JsonRpcError
|
||||
|
||||
|
||||
@@ -112,6 +113,132 @@ def test_qr_securejoin(acfactory, protect):
|
||||
fiona.wait_for_securejoin_joiner_success()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("all_devices_online", [True, False])
|
||||
def test_qr_securejoin_broadcast(acfactory, all_devices_online):
|
||||
alice, bob, fiona = acfactory.get_online_accounts(3)
|
||||
|
||||
alice2 = alice.clone()
|
||||
bob2 = bob.clone()
|
||||
|
||||
if all_devices_online:
|
||||
alice2.start_io()
|
||||
bob2.start_io()
|
||||
|
||||
logging.info("===================== Alice creates a broadcast =====================")
|
||||
alice_chat = alice.create_broadcast("Broadcast channel for everyone!")
|
||||
snapshot = alice_chat.get_basic_snapshot()
|
||||
assert not snapshot.is_unpromoted # Broadcast channels are never unpromoted
|
||||
|
||||
logging.info("===================== Bob joins the broadcast =====================")
|
||||
|
||||
qr_code = alice_chat.get_qr_code()
|
||||
bob.secure_join(qr_code)
|
||||
alice.wait_for_securejoin_inviter_success()
|
||||
bob.wait_for_securejoin_joiner_success()
|
||||
alice_chat.send_text("Hello everyone!")
|
||||
|
||||
def wait_for_group_messages(ac):
|
||||
snapshot = ac.wait_for_incoming_msg().get_snapshot()
|
||||
assert snapshot.text == f"Member Me added by {alice.get_config('addr')}."
|
||||
|
||||
snapshot = ac.wait_for_incoming_msg().get_snapshot()
|
||||
assert snapshot.text == "Hello everyone!"
|
||||
|
||||
def check_account(ac, contact, inviter_side, please_wait_info_msg=False):
|
||||
# Check that the chat partner is verified.
|
||||
contact_snapshot = contact.get_snapshot()
|
||||
assert contact_snapshot.is_verified
|
||||
|
||||
chat = ac.get_chatlist()[0]
|
||||
chat_msgs = chat.get_messages()
|
||||
|
||||
if please_wait_info_msg:
|
||||
first_msg = chat_msgs.pop(0).get_snapshot()
|
||||
assert first_msg.text == "Establishing guaranteed end-to-end encryption, please wait…"
|
||||
assert first_msg.is_info
|
||||
|
||||
encrypted_msg = chat_msgs[0].get_snapshot()
|
||||
assert encrypted_msg.text == "Messages are end-to-end encrypted."
|
||||
assert encrypted_msg.is_info
|
||||
|
||||
member_added_msg = chat_msgs[1].get_snapshot()
|
||||
if inviter_side:
|
||||
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
|
||||
else:
|
||||
assert member_added_msg.text == f"Member Me added by {contact_snapshot.display_name}."
|
||||
assert member_added_msg.is_info
|
||||
|
||||
hello_msg = chat_msgs[2].get_snapshot()
|
||||
assert hello_msg.text == "Hello everyone!"
|
||||
assert not hello_msg.is_info
|
||||
assert hello_msg.show_padlock
|
||||
assert hello_msg.error is None
|
||||
|
||||
assert len(chat_msgs) == 3
|
||||
|
||||
chat_snapshot = chat.get_full_snapshot()
|
||||
assert chat_snapshot.is_encrypted
|
||||
assert chat_snapshot.name == "Broadcast channel for everyone!"
|
||||
if inviter_side:
|
||||
assert chat_snapshot.chat_type == ChatType.OUT_BROADCAST
|
||||
else:
|
||||
assert chat_snapshot.chat_type == ChatType.IN_BROADCAST
|
||||
assert chat_snapshot.can_send == inviter_side
|
||||
|
||||
chat_contacts = chat_snapshot.contact_ids
|
||||
assert contact.id in chat_contacts
|
||||
if inviter_side:
|
||||
assert len(chat_contacts) == 1
|
||||
else:
|
||||
assert len(chat_contacts) == 2
|
||||
assert SpecialContactId.SELF in chat_contacts
|
||||
assert chat_snapshot.self_in_group
|
||||
|
||||
wait_for_group_messages(bob)
|
||||
|
||||
check_account(alice, alice.create_contact(bob), inviter_side=True)
|
||||
check_account(bob, bob.create_contact(alice), inviter_side=False, please_wait_info_msg=True)
|
||||
|
||||
logging.info("===================== Test Alice's second device =====================")
|
||||
|
||||
# Start second Alice device, if it wasn't started already.
|
||||
alice2.start_io()
|
||||
alice2.wait_for_securejoin_inviter_success()
|
||||
|
||||
while True:
|
||||
msg_id = alice2.wait_for_msgs_changed_event().msg_id
|
||||
if msg_id:
|
||||
snapshot = alice2.get_message_by_id(msg_id).get_snapshot()
|
||||
if snapshot.text == "Hello everyone!":
|
||||
break
|
||||
|
||||
check_account(alice2, alice2.create_contact(bob), inviter_side=True)
|
||||
|
||||
logging.info("===================== Test Bob's second device =====================")
|
||||
|
||||
# Start second Bob device, if it wasn't started already.
|
||||
bob2.start_io()
|
||||
bob2.wait_for_securejoin_joiner_success()
|
||||
wait_for_group_messages(bob2)
|
||||
check_account(bob2, bob2.create_contact(alice), inviter_side=False)
|
||||
|
||||
# The QR code token is synced, so alice2 must be able to handle join requests.
|
||||
logging.info("===================== Fiona joins the group via alice2 =====================")
|
||||
alice.stop_io()
|
||||
fiona.secure_join(qr_code)
|
||||
alice2.wait_for_securejoin_inviter_success()
|
||||
fiona.wait_for_securejoin_joiner_success()
|
||||
|
||||
snapshot = fiona.wait_for_incoming_msg().get_snapshot()
|
||||
assert snapshot.text == f"Member Me added by {alice.get_config('addr')}."
|
||||
|
||||
alice2.get_chatlist()[0].get_messages()[2].resend()
|
||||
snapshot = fiona.wait_for_incoming_msg().get_snapshot()
|
||||
assert snapshot.text == "Hello everyone!"
|
||||
|
||||
check_account(fiona, fiona.create_contact(alice), inviter_side=False, please_wait_info_msg=True)
|
||||
|
||||
|
||||
def test_qr_securejoin_contact_request(acfactory) -> None:
|
||||
"""Alice invites Bob to a group when Bob's chat with Alice is in a contact request mode."""
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
@@ -11,7 +11,7 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
from deltachat_rpc_client import Contact, EventType, Message, events
|
||||
from deltachat_rpc_client.const import ChatType, DownloadState, MessageState
|
||||
from deltachat_rpc_client.const import DownloadState, MessageState
|
||||
from deltachat_rpc_client.pytestplugin import E2EE_INFO_MSGS
|
||||
from deltachat_rpc_client.rpc import JsonRpcError
|
||||
|
||||
@@ -876,34 +876,103 @@ def test_delete_deltachat_folder(acfactory, direct_imap):
|
||||
assert "DeltaChat" in ac1_direct_imap.list_folders()
|
||||
|
||||
|
||||
def test_broadcast(acfactory):
|
||||
@pytest.mark.parametrize("all_devices_online", [True, False])
|
||||
def test_leave_broadcast(acfactory, all_devices_online):
|
||||
alice, bob = acfactory.get_online_accounts(2)
|
||||
|
||||
alice_chat = alice.create_broadcast("My great channel")
|
||||
snapshot = alice_chat.get_basic_snapshot()
|
||||
assert snapshot.name == "My great channel"
|
||||
assert snapshot.is_unpromoted
|
||||
assert snapshot.is_encrypted
|
||||
assert snapshot.chat_type == ChatType.OUT_BROADCAST
|
||||
bob2 = bob.clone()
|
||||
|
||||
alice_contact_bob = alice.create_contact(bob, "Bob")
|
||||
alice_chat.add_contact(alice_contact_bob)
|
||||
if all_devices_online:
|
||||
bob2.start_io()
|
||||
|
||||
alice_msg = alice_chat.send_message(text="hello").get_snapshot()
|
||||
assert alice_msg.text == "hello"
|
||||
assert alice_msg.show_padlock
|
||||
logging.info("===================== Alice creates a broadcast =====================")
|
||||
alice_chat = alice.create_broadcast("Broadcast channel for everyone!")
|
||||
|
||||
bob_msg = bob.wait_for_incoming_msg().get_snapshot()
|
||||
assert bob_msg.text == "hello"
|
||||
assert bob_msg.show_padlock
|
||||
assert bob_msg.error is None
|
||||
logging.info("===================== Bob joins the broadcast =====================")
|
||||
qr_code = alice_chat.get_qr_code()
|
||||
bob.secure_join(qr_code)
|
||||
alice.wait_for_securejoin_inviter_success()
|
||||
bob.wait_for_securejoin_joiner_success()
|
||||
|
||||
bob_chat = bob.get_chat_by_id(bob_msg.chat_id)
|
||||
bob_chat_snapshot = bob_chat.get_basic_snapshot()
|
||||
assert bob_chat_snapshot.name == "My great channel"
|
||||
assert not bob_chat_snapshot.is_unpromoted
|
||||
assert bob_chat_snapshot.is_encrypted
|
||||
assert bob_chat_snapshot.chat_type == ChatType.IN_BROADCAST
|
||||
assert bob_chat_snapshot.is_contact_request
|
||||
alice_bob_contact = alice.create_contact(bob)
|
||||
alice_contacts = alice_chat.get_contacts()
|
||||
assert len(alice_contacts) == 1 # 1 recipient
|
||||
assert alice_contacts[0].id == alice_bob_contact.id
|
||||
|
||||
assert not bob_chat.can_send()
|
||||
member_added_msg = bob.wait_for_incoming_msg()
|
||||
assert member_added_msg.get_snapshot().text == f"Member Me added by {alice.get_config('addr')}."
|
||||
|
||||
def get_broadcast(ac):
|
||||
chat = ac.get_chatlist()[0]
|
||||
assert chat.get_basic_snapshot().name == "Broadcast channel for everyone!"
|
||||
return chat
|
||||
|
||||
def check_account(ac, contact, inviter_side, please_wait_info_msg=False):
|
||||
chat = get_broadcast(ac)
|
||||
contact_snapshot = contact.get_snapshot()
|
||||
chat_msgs = chat.get_messages()
|
||||
|
||||
if please_wait_info_msg:
|
||||
first_msg = chat_msgs.pop(0).get_snapshot()
|
||||
assert first_msg.text == "Establishing guaranteed end-to-end encryption, please wait…"
|
||||
assert first_msg.is_info
|
||||
|
||||
encrypted_msg = chat_msgs.pop(0).get_snapshot()
|
||||
assert encrypted_msg.text == "Messages are end-to-end encrypted."
|
||||
assert encrypted_msg.is_info
|
||||
|
||||
member_added_msg = chat_msgs.pop(0).get_snapshot()
|
||||
if inviter_side:
|
||||
assert member_added_msg.text == f"Member {contact_snapshot.display_name} added."
|
||||
else:
|
||||
assert member_added_msg.text == f"Member Me added by {contact_snapshot.display_name}."
|
||||
assert member_added_msg.is_info
|
||||
|
||||
if not inviter_side:
|
||||
leave_msg = chat_msgs.pop(0).get_snapshot()
|
||||
assert leave_msg.text == "You left."
|
||||
|
||||
assert len(chat_msgs) == 0
|
||||
|
||||
chat_snapshot = chat.get_full_snapshot()
|
||||
|
||||
# On Alice's side, SELF is not in the list of contact ids
|
||||
# because OutBroadcast chats never contain SELF in the list.
|
||||
# On Bob's side, SELF is not in the list because he left.
|
||||
if inviter_side:
|
||||
assert len(chat_snapshot.contact_ids) == 0
|
||||
else:
|
||||
assert chat_snapshot.contact_ids == [contact.id]
|
||||
|
||||
logging.info("===================== Bob leaves the broadcast =====================")
|
||||
bob_chat = get_broadcast(bob)
|
||||
assert bob_chat.get_full_snapshot().self_in_group
|
||||
assert len(bob_chat.get_contacts()) == 2 # Alice and Bob
|
||||
|
||||
bob_chat.leave()
|
||||
assert not bob_chat.get_full_snapshot().self_in_group
|
||||
# After Bob left, only Alice will be left in Bob's memberlist
|
||||
assert len(bob_chat.get_contacts()) == 1
|
||||
|
||||
check_account(bob, bob.create_contact(alice), inviter_side=False, please_wait_info_msg=True)
|
||||
|
||||
logging.info("===================== Test Alice's device =====================")
|
||||
while len(alice_chat.get_contacts()) != 0: # After Bob left, there will be 0 recipients
|
||||
alice.wait_for_event(EventType.CHAT_MODIFIED)
|
||||
|
||||
check_account(alice, alice.create_contact(bob), inviter_side=True)
|
||||
|
||||
logging.info("===================== Test Bob's second device =====================")
|
||||
# Start second Bob device, if it wasn't started already.
|
||||
bob2.start_io()
|
||||
|
||||
member_added_msg = bob2.wait_for_incoming_msg()
|
||||
assert member_added_msg.get_snapshot().text == f"Member Me added by {alice.get_config('addr')}."
|
||||
|
||||
bob2_chat = get_broadcast(bob2)
|
||||
|
||||
# After Bob left, only Alice will be left in Bob's memberlist
|
||||
while len(bob2_chat.get_contacts()) != 1:
|
||||
bob2.wait_for_event(EventType.CHAT_MODIFIED)
|
||||
|
||||
check_account(bob2, bob2.create_contact(alice), inviter_side=False)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deltachat-rpc-server"
|
||||
version = "2.10.0"
|
||||
version = "2.13.0"
|
||||
description = "DeltaChat JSON-RPC server"
|
||||
edition = "2021"
|
||||
readme = "README.md"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"type": "module",
|
||||
"types": "index.d.ts",
|
||||
"version": "2.10.0"
|
||||
"version": "2.13.0"
|
||||
}
|
||||
|
||||
@@ -38,8 +38,6 @@ skip = [
|
||||
{ name = "rand", version = "0.8.5" },
|
||||
{ name = "redox_syscall", version = "0.3.5" },
|
||||
{ name = "redox_syscall", version = "0.4.1" },
|
||||
{ name = "regex-automata", version = "0.1.10" },
|
||||
{ name = "regex-syntax", version = "0.6.29" },
|
||||
{ name = "rustix", version = "0.38.44" },
|
||||
{ name = "serdect", version = "0.2.0" },
|
||||
{ name = "spin", version = "0.9.8" },
|
||||
@@ -48,6 +46,7 @@ skip = [
|
||||
{ name = "syn", version = "1.0.109" },
|
||||
{ name = "thiserror-impl", version = "1.0.69" },
|
||||
{ name = "thiserror", version = "1.0.69" },
|
||||
{ name = "toml_datetime", version = "0.6.11" },
|
||||
{ name = "wasi", version = "0.11.0+wasi-snapshot-preview1" },
|
||||
{ name = "windows" },
|
||||
{ name = "windows_aarch64_gnullvm" },
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "deltachat"
|
||||
version = "2.10.0"
|
||||
version = "2.13.0"
|
||||
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.8"
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Optional, Union
|
||||
from . import const, props
|
||||
from .capi import ffi, lib
|
||||
from .cutil import as_dc_charpointer, from_dc_charpointer, from_optional_dc_charpointer
|
||||
from .reactions import Reactions
|
||||
|
||||
|
||||
class Message:
|
||||
@@ -164,17 +163,6 @@ class Message:
|
||||
),
|
||||
)
|
||||
|
||||
def send_reaction(self, reaction: str):
|
||||
"""Send a reaction to message and return the resulting Message instance."""
|
||||
msg_id = lib.dc_send_reaction(self.account._dc_context, self.id, as_dc_charpointer(reaction))
|
||||
if msg_id == 0:
|
||||
raise ValueError("reaction could not be send")
|
||||
return Message.from_db(self.account, msg_id)
|
||||
|
||||
def get_reactions(self) -> Reactions:
|
||||
"""Get :class:`deltachat.reactions.Reactions` to the message."""
|
||||
return Reactions.from_msg(self)
|
||||
|
||||
def is_system_message(self):
|
||||
"""return True if this message is a system/info message."""
|
||||
return bool(lib.dc_msg_is_info(self._dc_msg))
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""The Reactions object."""
|
||||
|
||||
from .capi import ffi, lib
|
||||
from .cutil import from_dc_charpointer, iter_array
|
||||
|
||||
|
||||
class Reactions:
|
||||
"""Reactions object.
|
||||
|
||||
You obtain instances of it through :class:`deltachat.message.Message`.
|
||||
"""
|
||||
|
||||
def __init__(self, account, dc_reactions) -> None:
|
||||
assert isinstance(account._dc_context, ffi.CData)
|
||||
assert isinstance(dc_reactions, ffi.CData)
|
||||
assert dc_reactions != ffi.NULL
|
||||
self.account = account
|
||||
self._dc_reactions = dc_reactions
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Reactions dc_reactions={self._dc_reactions}>"
|
||||
|
||||
@classmethod
|
||||
def from_msg(cls, msg):
|
||||
assert msg.id > 0
|
||||
return cls(
|
||||
msg.account,
|
||||
ffi.gc(lib.dc_get_msg_reactions(msg.account._dc_context, msg.id), lib.dc_reactions_unref),
|
||||
)
|
||||
|
||||
def get_contacts(self) -> list:
|
||||
"""Get list of contacts reacted to the message.
|
||||
|
||||
:returns: list of :class:`deltachat.contact.Contact` objects for this reaction.
|
||||
"""
|
||||
from .contact import Contact
|
||||
|
||||
dc_array = ffi.gc(lib.dc_reactions_get_contacts(self._dc_reactions), lib.dc_array_unref)
|
||||
return list(iter_array(dc_array, lambda x: Contact(self.account, x)))
|
||||
|
||||
def get_by_contact(self, contact) -> str:
|
||||
"""Get a string containing space-separated reactions of a single :class:`deltachat.contact.Contact`."""
|
||||
return from_dc_charpointer(lib.dc_reactions_get_by_contact_id(self._dc_reactions, contact.id))
|
||||
@@ -160,32 +160,6 @@ def test_html_message(acfactory, lp):
|
||||
assert html_text in msg2.html
|
||||
|
||||
|
||||
def test_videochat_invitation_message(acfactory, lp):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
chat = acfactory.get_accepted_chat(ac1, ac2)
|
||||
text = "You are invited to a video chat, click https://meet.jit.si/WxEGad0gGzX to join."
|
||||
|
||||
lp.sec("ac1: prepare and send text message to ac2")
|
||||
msg1 = chat.send_text("message0")
|
||||
assert not msg1.is_videochat_invitation()
|
||||
|
||||
lp.sec("wait for ac2 to receive message")
|
||||
msg2 = ac2._evtracker.wait_next_incoming_message()
|
||||
assert msg2.text == "message0"
|
||||
assert not msg2.is_videochat_invitation()
|
||||
|
||||
lp.sec("ac1: prepare and send videochat invitation to ac2")
|
||||
msg1 = Message.new_empty(ac1, "videochat")
|
||||
msg1.set_text(text)
|
||||
msg1 = chat.send_msg(msg1)
|
||||
assert msg1.is_videochat_invitation()
|
||||
|
||||
lp.sec("wait for ac2 to receive message")
|
||||
msg2 = ac2._evtracker.wait_next_incoming_message()
|
||||
assert msg2.text == text
|
||||
assert msg2.is_videochat_invitation()
|
||||
|
||||
|
||||
def test_webxdc_message(acfactory, data, lp):
|
||||
ac1, ac2 = acfactory.get_online_accounts(2)
|
||||
chat = acfactory.get_accepted_chat(ac1, ac2)
|
||||
@@ -432,7 +406,7 @@ def test_forward_messages(acfactory, lp):
|
||||
lp.sec("ac2: check new chat has a forwarded message")
|
||||
assert chat3.is_promoted()
|
||||
messages = chat3.get_messages()
|
||||
assert len(messages) == 2
|
||||
assert len(messages) == 3
|
||||
msg = messages[-1]
|
||||
assert msg.is_forwarded()
|
||||
ac2.delete_messages(messages)
|
||||
|
||||
@@ -663,4 +663,4 @@ class TestOfflineChat:
|
||||
|
||||
lp.sec("check message count of only system messages (without daymarkers)")
|
||||
sysmessages = [x for x in chat.get_messages() if x.is_system_message()]
|
||||
assert len(sysmessages) == 3
|
||||
assert len(sysmessages) == 4
|
||||
|
||||
@@ -1 +1 @@
|
||||
2025-08-04
|
||||
2025-09-09
|
||||
@@ -7,7 +7,7 @@ set -euo pipefail
|
||||
#
|
||||
# Avoid using rustup here as it depends on reading /proc/self/exe and
|
||||
# has problems running under QEMU.
|
||||
RUST_VERSION=1.88.0
|
||||
RUST_VERSION=1.89.0
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu
|
||||
|
||||
@@ -6,7 +6,7 @@ set -euo pipefail
|
||||
export TZ=UTC
|
||||
|
||||
# Provider database revision.
|
||||
REV=77cbf92a8565fdf1bcaba10fa93c1455c750a1e9
|
||||
REV=1cce91c1f1065b47e4f307d6fe2f4cca68c74d2e
|
||||
|
||||
CORE_ROOT="$PWD"
|
||||
TMP="$(mktemp -d)"
|
||||
|
||||
@@ -17,7 +17,6 @@ pub enum EncryptPreference {
|
||||
#[default]
|
||||
NoPreference = 0,
|
||||
Mutual = 1,
|
||||
Reset = 20,
|
||||
}
|
||||
|
||||
impl fmt::Display for EncryptPreference {
|
||||
@@ -25,7 +24,6 @@ impl fmt::Display for EncryptPreference {
|
||||
match *self {
|
||||
EncryptPreference::Mutual => write!(fmt, "mutual"),
|
||||
EncryptPreference::NoPreference => write!(fmt, "nopreference"),
|
||||
EncryptPreference::Reset => write!(fmt, "reset"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,21 +46,13 @@ pub struct Aheader {
|
||||
pub addr: String,
|
||||
pub public_key: SignedPublicKey,
|
||||
pub prefer_encrypt: EncryptPreference,
|
||||
}
|
||||
|
||||
impl Aheader {
|
||||
/// Creates new autocrypt header
|
||||
pub fn new(
|
||||
addr: String,
|
||||
public_key: SignedPublicKey,
|
||||
prefer_encrypt: EncryptPreference,
|
||||
) -> Self {
|
||||
Aheader {
|
||||
addr,
|
||||
public_key,
|
||||
prefer_encrypt,
|
||||
}
|
||||
}
|
||||
// Whether `_verified` attribute is present.
|
||||
//
|
||||
// `_verified` attribute is an extension to `Autocrypt-Gossip`
|
||||
// header that is used to tell that the sender
|
||||
// marked this key as verified.
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
impl fmt::Display for Aheader {
|
||||
@@ -71,6 +61,9 @@ impl fmt::Display for Aheader {
|
||||
if self.prefer_encrypt == EncryptPreference::Mutual {
|
||||
write!(fmt, " prefer-encrypt=mutual;")?;
|
||||
}
|
||||
if self.verified {
|
||||
write!(fmt, " _verified=1;")?;
|
||||
}
|
||||
|
||||
// adds a whitespace every 78 characters, this allows
|
||||
// email crate to wrap the lines according to RFC 5322
|
||||
@@ -125,6 +118,8 @@ impl FromStr for Aheader {
|
||||
.and_then(|raw| raw.parse().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let verified = attributes.remove("_verified").is_some();
|
||||
|
||||
// Autocrypt-Level0: unknown attributes starting with an underscore can be safely ignored
|
||||
// Autocrypt-Level0: unknown attribute, treat the header as invalid
|
||||
if attributes.keys().any(|k| !k.starts_with('_')) {
|
||||
@@ -135,6 +130,7 @@ impl FromStr for Aheader {
|
||||
addr,
|
||||
public_key,
|
||||
prefer_encrypt,
|
||||
verified,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -152,10 +148,11 @@ mod tests {
|
||||
|
||||
assert_eq!(h.addr, "me@mail.com");
|
||||
assert_eq!(h.prefer_encrypt, EncryptPreference::Mutual);
|
||||
assert_eq!(h.verified, false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// EncryptPreference::Reset is an internal value, parser should never return it
|
||||
// Non-standard values of prefer-encrypt such as `reset` are treated as no preference.
|
||||
#[test]
|
||||
fn test_from_str_reset() -> Result<()> {
|
||||
let raw = format!("addr=reset@example.com; prefer-encrypt=reset; keydata={RAWKEY}");
|
||||
@@ -245,11 +242,12 @@ mod tests {
|
||||
assert!(
|
||||
format!(
|
||||
"{}",
|
||||
Aheader::new(
|
||||
"test@example.com".to_string(),
|
||||
SignedPublicKey::from_base64(RAWKEY).unwrap(),
|
||||
EncryptPreference::Mutual
|
||||
)
|
||||
Aheader {
|
||||
addr: "test@example.com".to_string(),
|
||||
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
|
||||
prefer_encrypt: EncryptPreference::Mutual,
|
||||
verified: false
|
||||
}
|
||||
)
|
||||
.contains("prefer-encrypt=mutual;")
|
||||
);
|
||||
@@ -260,11 +258,12 @@ mod tests {
|
||||
assert!(
|
||||
!format!(
|
||||
"{}",
|
||||
Aheader::new(
|
||||
"test@example.com".to_string(),
|
||||
SignedPublicKey::from_base64(RAWKEY).unwrap(),
|
||||
EncryptPreference::NoPreference
|
||||
)
|
||||
Aheader {
|
||||
addr: "test@example.com".to_string(),
|
||||
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
|
||||
prefer_encrypt: EncryptPreference::NoPreference,
|
||||
verified: false
|
||||
}
|
||||
)
|
||||
.contains("prefer-encrypt")
|
||||
);
|
||||
@@ -273,13 +272,27 @@ mod tests {
|
||||
assert!(
|
||||
format!(
|
||||
"{}",
|
||||
Aheader::new(
|
||||
"TeSt@eXaMpLe.cOm".to_string(),
|
||||
SignedPublicKey::from_base64(RAWKEY).unwrap(),
|
||||
EncryptPreference::Mutual
|
||||
)
|
||||
Aheader {
|
||||
addr: "TeSt@eXaMpLe.cOm".to_string(),
|
||||
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
|
||||
prefer_encrypt: EncryptPreference::Mutual,
|
||||
verified: false
|
||||
}
|
||||
)
|
||||
.contains("test@example.com")
|
||||
);
|
||||
|
||||
assert!(
|
||||
format!(
|
||||
"{}",
|
||||
Aheader {
|
||||
addr: "test@example.com".to_string(),
|
||||
public_key: SignedPublicKey::from_base64(RAWKEY).unwrap(),
|
||||
prefer_encrypt: EncryptPreference::NoPreference,
|
||||
verified: true
|
||||
}
|
||||
)
|
||||
.contains("_verified")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
373
src/calls.rs
Normal file
373
src/calls.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
//! # Handle calls.
|
||||
//!
|
||||
//! Internally, calls are bound a user-visible message initializing the call.
|
||||
//! This means, the "Call ID" is a "Message ID" - similar to Webxdc IDs.
|
||||
use crate::chat::{Chat, ChatId, send_msg};
|
||||
use crate::constants::Chattype;
|
||||
use crate::contact::ContactId;
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::headerdef::HeaderDef;
|
||||
use crate::log::info;
|
||||
use crate::message::{self, Message, MsgId, Viewtype};
|
||||
use crate::mimeparser::{MimeMessage, SystemMessage};
|
||||
use crate::param::Param;
|
||||
use crate::tools::time;
|
||||
use anyhow::{Result, ensure};
|
||||
use std::time::Duration;
|
||||
use tokio::task;
|
||||
use tokio::time::sleep;
|
||||
|
||||
/// How long callee's or caller's phone ring.
|
||||
///
|
||||
/// For the callee, this is to prevent endless ringing
|
||||
/// in case the initial "call" is received, but then the caller went offline.
|
||||
/// Moreover, this prevents outdated calls to ring
|
||||
/// in case the initial "call" message arrives delayed.
|
||||
///
|
||||
/// For the caller, this means they should also not wait longer,
|
||||
/// as the callee won't start the call afterwards.
|
||||
const RINGING_SECONDS: i64 = 60;
|
||||
|
||||
/// For persisting parameters in the call, we use Param::Arg*
|
||||
const CALL_ACCEPTED_TIMESTAMP: Param = Param::Arg;
|
||||
const CALL_ENDED_TIMESTAMP: Param = Param::Arg4;
|
||||
|
||||
/// Information about the status of a call.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CallInfo {
|
||||
/// User-defined text as given to place_outgoing_call()
|
||||
pub place_call_info: String,
|
||||
|
||||
/// User-defined text as given to accept_incoming_call()
|
||||
pub accept_call_info: String,
|
||||
|
||||
/// Message referring to the call.
|
||||
/// Data are persisted along with the message using Param::Arg*
|
||||
pub msg: Message,
|
||||
}
|
||||
|
||||
impl CallInfo {
|
||||
fn is_incoming(&self) -> bool {
|
||||
self.msg.from_id != ContactId::SELF
|
||||
}
|
||||
|
||||
fn is_stale(&self) -> bool {
|
||||
self.remaining_ring_seconds() <= 0
|
||||
}
|
||||
|
||||
fn remaining_ring_seconds(&self) -> i64 {
|
||||
let remaining_seconds = self.msg.timestamp_sent + RINGING_SECONDS - time();
|
||||
remaining_seconds.clamp(0, RINGING_SECONDS)
|
||||
}
|
||||
|
||||
async fn update_text(&self, context: &Context, text: &str) -> Result<()> {
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"UPDATE msgs SET txt=?, txt_normalized=? WHERE id=?",
|
||||
(text, message::normalize_text(text), self.msg.id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_text_duration(&self, context: &Context) -> Result<()> {
|
||||
let minutes = self.get_duration_seconds() / 60;
|
||||
let duration = match minutes {
|
||||
0 => "<1 minute".to_string(),
|
||||
1 => "1 minute".to_string(),
|
||||
n => format!("{} minutes", n),
|
||||
};
|
||||
|
||||
if self.is_incoming() {
|
||||
self.update_text(context, &format!("Incoming call\n{duration}"))
|
||||
.await?;
|
||||
} else {
|
||||
self.update_text(context, &format!("Outgoing call\n{duration}"))
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark calls as accepted.
|
||||
/// This is needed for all devices where a stale-timer runs, to prevent accepted calls being terminated as stale.
|
||||
async fn mark_as_accepted(&mut self, context: &Context) -> Result<()> {
|
||||
self.msg.param.set_i64(CALL_ACCEPTED_TIMESTAMP, time());
|
||||
self.msg.update_param(context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_accepted(&self) -> bool {
|
||||
self.msg.param.exists(CALL_ACCEPTED_TIMESTAMP)
|
||||
}
|
||||
|
||||
async fn mark_as_ended(&mut self, context: &Context) -> Result<()> {
|
||||
self.msg.param.set_i64(CALL_ENDED_TIMESTAMP, time());
|
||||
self.msg.update_param(context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_ended(&self) -> bool {
|
||||
self.msg.param.exists(CALL_ENDED_TIMESTAMP)
|
||||
}
|
||||
|
||||
fn get_duration_seconds(&self) -> i64 {
|
||||
if let (Some(start), Some(end)) = (
|
||||
self.msg.param.get_i64(CALL_ACCEPTED_TIMESTAMP),
|
||||
self.msg.param.get_i64(CALL_ENDED_TIMESTAMP),
|
||||
) {
|
||||
let seconds = end - start;
|
||||
if seconds <= 0 {
|
||||
return 1;
|
||||
}
|
||||
return seconds;
|
||||
}
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
impl Context {
|
||||
/// Start an outgoing call.
|
||||
pub async fn place_outgoing_call(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
place_call_info: String,
|
||||
) -> Result<MsgId> {
|
||||
let chat = Chat::load_from_db(self, chat_id).await?;
|
||||
ensure!(chat.typ == Chattype::Single && !chat.is_self_talk());
|
||||
|
||||
let mut call = Message {
|
||||
viewtype: Viewtype::Call,
|
||||
text: "Outgoing call".into(),
|
||||
..Default::default()
|
||||
};
|
||||
call.param.set(Param::WebrtcRoom, &place_call_info);
|
||||
call.id = send_msg(self, chat_id, &mut call).await?;
|
||||
|
||||
let wait = RINGING_SECONDS;
|
||||
task::spawn(Context::emit_end_call_if_unaccepted(
|
||||
self.clone(),
|
||||
wait.try_into()?,
|
||||
call.id,
|
||||
));
|
||||
|
||||
Ok(call.id)
|
||||
}
|
||||
|
||||
/// Accept an incoming call.
|
||||
pub async fn accept_incoming_call(
|
||||
&self,
|
||||
call_id: MsgId,
|
||||
accept_call_info: String,
|
||||
) -> Result<()> {
|
||||
let mut call: CallInfo = self.load_call_by_id(call_id).await?;
|
||||
ensure!(call.is_incoming());
|
||||
if call.is_accepted() || call.is_ended() {
|
||||
info!(self, "Call already accepted/ended");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
call.mark_as_accepted(self).await?;
|
||||
let chat = Chat::load_from_db(self, call.msg.chat_id).await?;
|
||||
if chat.is_contact_request() {
|
||||
chat.id.accept(self).await?;
|
||||
}
|
||||
|
||||
// send an acceptance message around: to the caller as well as to the other devices of the callee
|
||||
let mut msg = Message {
|
||||
viewtype: Viewtype::Text,
|
||||
text: "[Call accepted]".into(),
|
||||
..Default::default()
|
||||
};
|
||||
msg.param.set_cmd(SystemMessage::CallAccepted);
|
||||
msg.hidden = true;
|
||||
msg.param
|
||||
.set(Param::WebrtcAccepted, accept_call_info.to_string());
|
||||
msg.set_quote(self, Some(&call.msg)).await?;
|
||||
msg.id = send_msg(self, call.msg.chat_id, &mut msg).await?;
|
||||
self.emit_event(EventType::IncomingCallAccepted {
|
||||
msg_id: call.msg.id,
|
||||
});
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel, decline or hangup an incoming or outgoing call.
|
||||
pub async fn end_call(&self, call_id: MsgId) -> Result<()> {
|
||||
let mut call: CallInfo = self.load_call_by_id(call_id).await?;
|
||||
if call.is_ended() {
|
||||
info!(self, "Call already ended");
|
||||
return Ok(());
|
||||
}
|
||||
call.mark_as_ended(self).await?;
|
||||
|
||||
if !call.is_accepted() {
|
||||
if call.is_incoming() {
|
||||
call.update_text(self, "Declined call").await?;
|
||||
} else {
|
||||
call.update_text(self, "Cancelled call").await?;
|
||||
}
|
||||
} else {
|
||||
call.update_text_duration(self).await?;
|
||||
}
|
||||
|
||||
let mut msg = Message {
|
||||
viewtype: Viewtype::Text,
|
||||
text: "[Call ended]".into(),
|
||||
..Default::default()
|
||||
};
|
||||
msg.param.set_cmd(SystemMessage::CallEnded);
|
||||
msg.hidden = true;
|
||||
msg.set_quote(self, Some(&call.msg)).await?;
|
||||
msg.id = send_msg(self, call.msg.chat_id, &mut msg).await?;
|
||||
|
||||
self.emit_event(EventType::CallEnded {
|
||||
msg_id: call.msg.id,
|
||||
});
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn emit_end_call_if_unaccepted(
|
||||
context: Context,
|
||||
wait: u64,
|
||||
call_id: MsgId,
|
||||
) -> Result<()> {
|
||||
sleep(Duration::from_secs(wait)).await;
|
||||
let mut call = context.load_call_by_id(call_id).await?;
|
||||
if !call.is_accepted() && !call.is_ended() {
|
||||
call.mark_as_ended(&context).await?;
|
||||
if call.is_incoming() {
|
||||
call.update_text(&context, "Missed call").await?;
|
||||
} else {
|
||||
call.update_text(&context, "Cancelled call").await?;
|
||||
}
|
||||
context.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
context.emit_event(EventType::CallEnded {
|
||||
msg_id: call.msg.id,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_call_msg(
|
||||
&self,
|
||||
call_id: MsgId,
|
||||
mime_message: &MimeMessage,
|
||||
from_id: ContactId,
|
||||
) -> Result<()> {
|
||||
if mime_message.is_call() {
|
||||
let call = self.load_call_by_id(call_id).await?;
|
||||
if call.is_incoming() {
|
||||
if call.is_stale() {
|
||||
call.update_text(self, "Missed call").await?;
|
||||
self.emit_incoming_msg(call.msg.chat_id, call_id); // notify missed call
|
||||
} else {
|
||||
call.update_text(self, "Incoming call").await?;
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified
|
||||
self.emit_event(EventType::IncomingCall {
|
||||
msg_id: call.msg.id,
|
||||
place_call_info: call.place_call_info.to_string(),
|
||||
});
|
||||
let wait = call.remaining_ring_seconds();
|
||||
task::spawn(Context::emit_end_call_if_unaccepted(
|
||||
self.clone(),
|
||||
wait.try_into()?,
|
||||
call.msg.id,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
call.update_text(self, "Outgoing call").await?;
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
}
|
||||
} else {
|
||||
match mime_message.is_system_message {
|
||||
SystemMessage::CallAccepted => {
|
||||
let mut call = self.load_call_by_id(call_id).await?;
|
||||
if call.is_ended() || call.is_accepted() {
|
||||
info!(self, "CallAccepted received for accepted/ended call");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
call.mark_as_accepted(self).await?;
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
if call.is_incoming() {
|
||||
self.emit_event(EventType::IncomingCallAccepted {
|
||||
msg_id: call.msg.id,
|
||||
});
|
||||
} else {
|
||||
let accept_call_info = mime_message
|
||||
.get_header(HeaderDef::ChatWebrtcAccepted)
|
||||
.unwrap_or_default();
|
||||
self.emit_event(EventType::OutgoingCallAccepted {
|
||||
msg_id: call.msg.id,
|
||||
accept_call_info: accept_call_info.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
SystemMessage::CallEnded => {
|
||||
let mut call = self.load_call_by_id(call_id).await?;
|
||||
if call.is_ended() {
|
||||
// may happen eg. if a a message is missed
|
||||
info!(self, "CallEnded received for ended call");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
call.mark_as_ended(self).await?;
|
||||
if !call.is_accepted() {
|
||||
if call.is_incoming() {
|
||||
if from_id == ContactId::SELF {
|
||||
call.update_text(self, "Declined call").await?;
|
||||
} else {
|
||||
call.update_text(self, "Missed call").await?;
|
||||
}
|
||||
} else {
|
||||
// outgoing
|
||||
if from_id == ContactId::SELF {
|
||||
call.update_text(self, "Cancelled call").await?;
|
||||
} else {
|
||||
call.update_text(self, "Declined call").await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
call.update_text_duration(self).await?;
|
||||
}
|
||||
|
||||
self.emit_msgs_changed(call.msg.chat_id, call_id);
|
||||
self.emit_event(EventType::CallEnded {
|
||||
msg_id: call.msg.id,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_call_by_id(&self, call_id: MsgId) -> Result<CallInfo> {
|
||||
let call = Message::load_from_db(self, call_id).await?;
|
||||
self.load_call_by_message(call)
|
||||
}
|
||||
|
||||
fn load_call_by_message(&self, call: Message) -> Result<CallInfo> {
|
||||
ensure!(call.viewtype == Viewtype::Call);
|
||||
|
||||
Ok(CallInfo {
|
||||
place_call_info: call
|
||||
.param
|
||||
.get(Param::WebrtcRoom)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
accept_call_info: call
|
||||
.param
|
||||
.get(Param::WebrtcAccepted)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
msg: call,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod calls_tests;
|
||||
415
src/calls/calls_tests.rs
Normal file
415
src/calls/calls_tests.rs
Normal file
@@ -0,0 +1,415 @@
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::test_utils::{TestContext, TestContextManager};
|
||||
|
||||
struct CallSetup {
|
||||
pub alice: TestContext,
|
||||
pub alice2: TestContext,
|
||||
pub alice_call: Message,
|
||||
pub alice2_call: Message,
|
||||
pub bob: TestContext,
|
||||
pub bob2: TestContext,
|
||||
pub bob_call: Message,
|
||||
pub bob2_call: Message,
|
||||
}
|
||||
|
||||
async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()> {
|
||||
assert_eq!(Message::load_from_db(t, call_id).await?.text, text);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn setup_call() -> Result<CallSetup> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = tcm.alice().await;
|
||||
let alice2 = tcm.alice().await;
|
||||
let bob = tcm.bob().await;
|
||||
let bob2 = tcm.bob().await;
|
||||
for t in [&alice, &alice2, &bob, &bob2] {
|
||||
t.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
}
|
||||
|
||||
// Alice creates a chat with Bob and places an outgoing call there.
|
||||
// Alice's other device sees the same message as an outgoing call.
|
||||
let alice_chat = alice.create_chat(&bob).await;
|
||||
let test_msg_id = alice
|
||||
.place_outgoing_call(alice_chat.id, "place-info-123".to_string())
|
||||
.await?;
|
||||
let sent1 = alice.pop_sent_msg().await;
|
||||
assert_eq!(sent1.sender_msg_id, test_msg_id);
|
||||
let alice_call = Message::load_from_db(&alice, sent1.sender_msg_id).await?;
|
||||
let alice2_call = alice2.recv_msg(&sent1).await;
|
||||
for (t, m) in [(&alice, &alice_call), (&alice2, &alice2_call)] {
|
||||
assert!(!m.is_info());
|
||||
assert_eq!(m.viewtype, Viewtype::Call);
|
||||
let info = t.load_call_by_id(m.id).await?;
|
||||
assert!(!info.is_incoming());
|
||||
assert!(!info.is_accepted());
|
||||
assert_eq!(info.place_call_info, "place-info-123");
|
||||
assert_text(t, m.id, "Outgoing call").await?;
|
||||
}
|
||||
|
||||
// Bob receives the message referring to the call on two devices;
|
||||
// it is an incoming call from the view of Bob
|
||||
let bob_call = bob.recv_msg(&sent1).await;
|
||||
let bob2_call = bob2.recv_msg(&sent1).await;
|
||||
for (t, m) in [(&bob, &bob_call), (&bob2, &bob2_call)] {
|
||||
assert!(!m.is_info());
|
||||
assert_eq!(m.viewtype, Viewtype::Call);
|
||||
t.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::IncomingCall { .. }))
|
||||
.await;
|
||||
let info = t.load_call_by_id(m.id).await?;
|
||||
assert!(info.is_incoming());
|
||||
assert!(!info.is_accepted());
|
||||
assert_eq!(info.place_call_info, "place-info-123");
|
||||
assert_text(t, m.id, "Incoming call").await?;
|
||||
}
|
||||
|
||||
Ok(CallSetup {
|
||||
alice,
|
||||
alice2,
|
||||
alice_call,
|
||||
alice2_call,
|
||||
bob,
|
||||
bob2,
|
||||
bob_call,
|
||||
bob2_call,
|
||||
})
|
||||
}
|
||||
|
||||
async fn accept_call() -> Result<CallSetup> {
|
||||
let CallSetup {
|
||||
alice,
|
||||
alice2,
|
||||
alice_call,
|
||||
alice2_call,
|
||||
bob,
|
||||
bob2,
|
||||
bob_call,
|
||||
bob2_call,
|
||||
} = setup_call().await?;
|
||||
|
||||
// Bob accepts the incoming call
|
||||
bob.accept_incoming_call(bob_call.id, "accept-info-456".to_string())
|
||||
.await?;
|
||||
assert_text(&bob, bob_call.id, "Incoming call").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
|
||||
.await;
|
||||
let sent2 = bob.pop_sent_msg().await;
|
||||
let info = bob.load_call_by_id(bob_call.id).await?;
|
||||
assert!(info.is_accepted());
|
||||
assert_eq!(info.place_call_info, "place-info-123");
|
||||
|
||||
bob2.recv_msg_trash(&sent2).await;
|
||||
assert_text(&bob, bob_call.id, "Incoming call").await?;
|
||||
bob2.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::IncomingCallAccepted { .. }))
|
||||
.await;
|
||||
let info = bob2.load_call_by_id(bob2_call.id).await?;
|
||||
assert!(info.is_accepted());
|
||||
|
||||
// Alice receives the acceptance message
|
||||
alice.recv_msg_trash(&sent2).await;
|
||||
assert_text(&alice, alice_call.id, "Outgoing call").await?;
|
||||
let ev = alice
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
|
||||
.await;
|
||||
assert_eq!(
|
||||
ev,
|
||||
EventType::OutgoingCallAccepted {
|
||||
msg_id: alice2_call.id,
|
||||
accept_call_info: "accept-info-456".to_string()
|
||||
}
|
||||
);
|
||||
let info = alice.load_call_by_id(alice_call.id).await?;
|
||||
assert!(info.is_accepted());
|
||||
assert_eq!(info.place_call_info, "place-info-123");
|
||||
|
||||
alice2.recv_msg_trash(&sent2).await;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing call").await?;
|
||||
alice2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::OutgoingCallAccepted { .. }))
|
||||
.await;
|
||||
|
||||
Ok(CallSetup {
|
||||
alice,
|
||||
alice2,
|
||||
alice_call,
|
||||
alice2_call,
|
||||
bob,
|
||||
bob2,
|
||||
bob_call,
|
||||
bob2_call,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_accept_call_callee_ends() -> Result<()> {
|
||||
// Alice calls Bob, Bob accepts
|
||||
let CallSetup {
|
||||
alice,
|
||||
alice_call,
|
||||
alice2,
|
||||
alice2_call,
|
||||
bob,
|
||||
bob2,
|
||||
bob_call,
|
||||
bob2_call,
|
||||
..
|
||||
} = accept_call().await?;
|
||||
|
||||
// Bob has accepted the call and also ends it
|
||||
bob.end_call(bob_call.id).await?;
|
||||
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
let sent3 = bob.pop_sent_msg().await;
|
||||
|
||||
bob2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
|
||||
bob2.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
|
||||
// Alice receives the ending message
|
||||
alice.recv_msg_trash(&sent3).await;
|
||||
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
|
||||
alice
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
|
||||
alice2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
|
||||
alice2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_accept_call_caller_ends() -> Result<()> {
|
||||
// Alice calls Bob, Bob accepts
|
||||
let CallSetup {
|
||||
alice,
|
||||
alice_call,
|
||||
alice2,
|
||||
alice2_call,
|
||||
bob,
|
||||
bob2,
|
||||
bob_call,
|
||||
bob2_call,
|
||||
..
|
||||
} = accept_call().await?;
|
||||
|
||||
// Bob has accepted the call but Alice ends it
|
||||
alice.end_call(alice_call.id).await?;
|
||||
assert_text(&alice, alice_call.id, "Outgoing call\n<1 minute").await?;
|
||||
alice
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
let sent3 = alice.pop_sent_msg().await;
|
||||
|
||||
alice2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&alice2, alice2_call.id, "Outgoing call\n<1 minute").await?;
|
||||
alice2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
|
||||
// Bob receives the ending message
|
||||
bob.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob, bob_call.id, "Incoming call\n<1 minute").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
|
||||
bob2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob2, bob2_call.id, "Incoming call\n<1 minute").await?;
|
||||
bob2.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_callee_rejects_call() -> Result<()> {
|
||||
// Alice calls Bob
|
||||
let CallSetup {
|
||||
alice,
|
||||
alice2,
|
||||
alice_call,
|
||||
alice2_call,
|
||||
bob,
|
||||
bob2,
|
||||
bob_call,
|
||||
bob2_call,
|
||||
..
|
||||
} = setup_call().await?;
|
||||
|
||||
// Bob has accepted Alice before, but does not want to talk with Alice
|
||||
bob_call.chat_id.accept(&bob).await?;
|
||||
bob.end_call(bob_call.id).await?;
|
||||
assert_text(&bob, bob_call.id, "Declined call").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
let sent3 = bob.pop_sent_msg().await;
|
||||
|
||||
bob2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob2, bob2_call.id, "Declined call").await?;
|
||||
bob2.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
|
||||
// Alice receives decline message
|
||||
alice.recv_msg_trash(&sent3).await;
|
||||
assert_text(&alice, alice_call.id, "Declined call").await?;
|
||||
alice
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
|
||||
alice2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&alice2, alice2_call.id, "Declined call").await?;
|
||||
alice2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_caller_cancels_call() -> Result<()> {
|
||||
// Alice calls Bob
|
||||
let CallSetup {
|
||||
alice,
|
||||
alice2,
|
||||
alice_call,
|
||||
alice2_call,
|
||||
bob,
|
||||
bob2,
|
||||
bob_call,
|
||||
bob2_call,
|
||||
..
|
||||
} = setup_call().await?;
|
||||
|
||||
// Alice changes their mind before Bob picks up
|
||||
alice.end_call(alice_call.id).await?;
|
||||
assert_text(&alice, alice_call.id, "Cancelled call").await?;
|
||||
alice
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
let sent3 = alice.pop_sent_msg().await;
|
||||
|
||||
alice2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&alice2, alice2_call.id, "Cancelled call").await?;
|
||||
alice2
|
||||
.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
|
||||
// Bob receives the ending message
|
||||
bob.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob, bob_call.id, "Missed call").await?;
|
||||
bob.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
|
||||
bob2.recv_msg_trash(&sent3).await;
|
||||
assert_text(&bob2, bob2_call.id, "Missed call").await?;
|
||||
bob2.evtracker
|
||||
.get_matching(|evt| matches!(evt, EventType::CallEnded { .. }))
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_is_stale_call() -> Result<()> {
|
||||
// a call started now is not stale
|
||||
let call_info = CallInfo {
|
||||
msg: Message {
|
||||
timestamp_sent: time(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!call_info.is_stale());
|
||||
let remaining_seconds = call_info.remaining_ring_seconds();
|
||||
assert!(remaining_seconds == RINGING_SECONDS || remaining_seconds == RINGING_SECONDS - 1);
|
||||
|
||||
// call started 5 seconds ago, this is not stale as well
|
||||
let call_info = CallInfo {
|
||||
msg: Message {
|
||||
timestamp_sent: time() - 5,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!call_info.is_stale());
|
||||
let remaining_seconds = call_info.remaining_ring_seconds();
|
||||
assert!(remaining_seconds == RINGING_SECONDS - 5 || remaining_seconds == RINGING_SECONDS - 6);
|
||||
|
||||
// a call started one hour ago is clearly stale
|
||||
let call_info = CallInfo {
|
||||
msg: Message {
|
||||
timestamp_sent: time() - 3600,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert!(call_info.is_stale());
|
||||
assert_eq!(call_info.remaining_ring_seconds(), 0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_mark_calls() -> Result<()> {
|
||||
let CallSetup {
|
||||
alice, alice_call, ..
|
||||
} = setup_call().await?;
|
||||
|
||||
let mut call_info: CallInfo = alice.load_call_by_id(alice_call.id).await?;
|
||||
assert!(!call_info.is_accepted());
|
||||
assert!(!call_info.is_ended());
|
||||
call_info.mark_as_accepted(&alice).await?;
|
||||
assert!(call_info.is_accepted());
|
||||
assert!(!call_info.is_ended());
|
||||
|
||||
let mut call_info: CallInfo = alice.load_call_by_id(alice_call.id).await?;
|
||||
assert!(call_info.is_accepted());
|
||||
assert!(!call_info.is_ended());
|
||||
|
||||
call_info.mark_as_ended(&alice).await?;
|
||||
assert!(call_info.is_accepted());
|
||||
assert!(call_info.is_ended());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_udpate_call_text() -> Result<()> {
|
||||
let CallSetup {
|
||||
alice, alice_call, ..
|
||||
} = setup_call().await?;
|
||||
|
||||
let call_info = alice.load_call_by_id(alice_call.id).await?;
|
||||
call_info.update_text(&alice, "foo bar").await?;
|
||||
|
||||
let alice_call = Message::load_from_db(&alice, alice_call.id).await?;
|
||||
assert_eq!(alice_call.get_text(), "foo bar");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
475
src/chat.rs
475
src/chat.rs
@@ -43,9 +43,9 @@ use crate::smtp::send_msg_to_smtp;
|
||||
use crate::stock_str;
|
||||
use crate::sync::{self, Sync::*, SyncData};
|
||||
use crate::tools::{
|
||||
IsNoneOrEmpty, SystemTime, buf_compress, create_id, create_outgoing_rfc724_mid,
|
||||
create_smeared_timestamp, create_smeared_timestamps, get_abs_path, gm2local_offset,
|
||||
smeared_time, time, truncate_msg_text,
|
||||
IsNoneOrEmpty, SystemTime, buf_compress, create_broadcast_shared_secret, create_id,
|
||||
create_outgoing_rfc724_mid, create_smeared_timestamp, create_smeared_timestamps, get_abs_path,
|
||||
gm2local_offset, smeared_time, time, truncate_msg_text,
|
||||
};
|
||||
use crate::webxdc::StatusUpdateSerial;
|
||||
use crate::{chatlist_events, imap};
|
||||
@@ -600,11 +600,23 @@ impl ChatId {
|
||||
|| chat.is_device_talk()
|
||||
|| chat.is_self_talk()
|
||||
|| (!chat.can_send(context).await? && !chat.is_contact_request())
|
||||
// For chattype InBrodacast, the info message is added when the member-added message is received
|
||||
// by directly calling add_encrypted_msg()
|
||||
|| chat.typ == Chattype::InBroadcast
|
||||
|| chat.blocked == Blocked::Yes
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.add_encrypted_msg(context, timestamp_sort).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn add_encrypted_msg(
|
||||
self,
|
||||
context: &Context,
|
||||
timestamp_sort: i64,
|
||||
) -> Result<()> {
|
||||
let text = stock_str::messages_e2e_encrypted(context).await;
|
||||
add_info_msg_with_cmd(
|
||||
context,
|
||||
@@ -1725,8 +1737,9 @@ impl Chat {
|
||||
pub(crate) async fn is_self_in_chat(&self, context: &Context) -> Result<bool> {
|
||||
match self.typ {
|
||||
Chattype::Single | Chattype::OutBroadcast | Chattype::Mailinglist => Ok(true),
|
||||
Chattype::Group => is_contact_in_chat(context, self.id, ContactId::SELF).await,
|
||||
Chattype::InBroadcast => Ok(false),
|
||||
Chattype::Group | Chattype::InBroadcast => {
|
||||
is_contact_in_chat(context, self.id, ContactId::SELF).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1771,6 +1784,12 @@ impl Chat {
|
||||
return Ok(Some(get_device_icon(context).await?));
|
||||
} else if self.is_self_talk() {
|
||||
return Ok(Some(get_saved_messages_icon(context).await?));
|
||||
} else if !self.is_encrypted(context).await? {
|
||||
// This is an unencrypted chat, show a special avatar that marks it as such.
|
||||
return Ok(Some(get_abs_path(
|
||||
context,
|
||||
Path::new(&get_unencrypted_icon(context).await?),
|
||||
)));
|
||||
} else if self.typ == Chattype::Single {
|
||||
// For 1:1 chats, we always use the same avatar as for the contact
|
||||
// This is before the `self.is_encrypted()` check, because that function
|
||||
@@ -1780,12 +1799,6 @@ impl Chat {
|
||||
let contact = Contact::get_by_id(context, *contact_id).await?;
|
||||
return contact.get_profile_image(context).await;
|
||||
}
|
||||
} else if !self.is_encrypted(context).await? {
|
||||
// This is an address-contact chat, show a special avatar that marks it as such
|
||||
return Ok(Some(get_abs_path(
|
||||
context,
|
||||
Path::new(&get_address_contact_icon(context).await?),
|
||||
)));
|
||||
} else if let Some(image_rel) = self.param.get(Param::ProfileImage) {
|
||||
// Load the group avatar, or the device-chat / saved-messages icon
|
||||
if !image_rel.is_empty() {
|
||||
@@ -1797,8 +1810,9 @@ impl Chat {
|
||||
|
||||
/// Returns chat avatar color.
|
||||
///
|
||||
/// For 1:1 chats, the color is calculated from the contact's address.
|
||||
/// For group chats the color is calculated from the chat name.
|
||||
/// For 1:1 chats, the color is calculated from the contact's address
|
||||
/// for address-contacts and from the OpenPGP key fingerprint for key-contacts.
|
||||
/// For group chats the color is calculated from the grpid, if present, or the chat name.
|
||||
pub async fn get_color(&self, context: &Context) -> Result<u32> {
|
||||
let mut color = 0;
|
||||
|
||||
@@ -1809,6 +1823,8 @@ impl Chat {
|
||||
color = contact.get_color();
|
||||
}
|
||||
}
|
||||
} else if !self.grpid.is_empty() {
|
||||
color = str_to_color(&self.grpid);
|
||||
} else {
|
||||
color = str_to_color(&self.name);
|
||||
}
|
||||
@@ -1886,16 +1902,25 @@ impl Chat {
|
||||
let is_encrypted = self.is_protected()
|
||||
|| match self.typ {
|
||||
Chattype::Single => {
|
||||
let chat_contact_ids = get_chat_contacts(context, self.id).await?;
|
||||
if let Some(contact_id) = chat_contact_ids.first() {
|
||||
if *contact_id == ContactId::DEVICE {
|
||||
true
|
||||
} else {
|
||||
let contact = Contact::get_by_id(context, *contact_id).await?;
|
||||
contact.is_key_contact()
|
||||
}
|
||||
} else {
|
||||
true
|
||||
match context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT cc.contact_id, c.fingerprint<>''
|
||||
FROM chats_contacts cc LEFT JOIN contacts c
|
||||
ON c.id=cc.contact_id
|
||||
WHERE cc.chat_id=?
|
||||
",
|
||||
(self.id,),
|
||||
|row| {
|
||||
let id: ContactId = row.get(0)?;
|
||||
let is_key: bool = row.get(1)?;
|
||||
Ok((id, is_key))
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some((id, is_key)) => is_key || id == ContactId::DEVICE,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
Chattype::Group => {
|
||||
@@ -1908,11 +1933,6 @@ impl Chat {
|
||||
Ok(is_encrypted)
|
||||
}
|
||||
|
||||
/// Deprecated 2025-07. Returns false.
|
||||
pub fn is_protection_broken(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns true if location streaming is enabled in the chat.
|
||||
pub fn is_sending_locations(&self) -> bool {
|
||||
self.is_sending_locations
|
||||
@@ -1950,7 +1970,7 @@ impl Chat {
|
||||
}
|
||||
|
||||
/// Adds missing values to the msg object,
|
||||
/// writes the record to the database and returns its msg_id.
|
||||
/// writes the record to the database.
|
||||
///
|
||||
/// If `update_msg_id` is set, that record is reused;
|
||||
/// if `update_msg_id` is None, a new record is created.
|
||||
@@ -1959,7 +1979,7 @@ impl Chat {
|
||||
context: &Context,
|
||||
msg: &mut Message,
|
||||
update_msg_id: Option<MsgId>,
|
||||
) -> Result<MsgId> {
|
||||
) -> Result<()> {
|
||||
let mut to_id = 0;
|
||||
let mut location_id = 0;
|
||||
|
||||
@@ -2237,7 +2257,7 @@ impl Chat {
|
||||
.await?;
|
||||
}
|
||||
context.scheduler.interrupt_ephemeral_task().await;
|
||||
Ok(msg.id)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends a `SyncAction` synchronising chat contacts to other devices.
|
||||
@@ -2490,11 +2510,13 @@ pub(crate) async fn get_archive_icon(context: &Context) -> Result<PathBuf> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_address_contact_icon(context: &Context) -> Result<PathBuf> {
|
||||
/// Returns path to the icon
|
||||
/// indicating unencrypted chats and address-contacts.
|
||||
pub(crate) async fn get_unencrypted_icon(context: &Context) -> Result<PathBuf> {
|
||||
get_asset_icon(
|
||||
context,
|
||||
"icon-address-contact",
|
||||
include_bytes!("../assets/icon-address-contact.png"),
|
||||
"icon-unencrypted",
|
||||
include_bytes!("../assets/icon-unencrypted.png"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2682,7 +2704,10 @@ impl ChatIdBlocked {
|
||||
}
|
||||
|
||||
async fn prepare_msg_blob(context: &Context, msg: &mut Message) -> Result<()> {
|
||||
if msg.viewtype == Viewtype::Text || msg.viewtype == Viewtype::VideochatInvitation {
|
||||
if msg.viewtype == Viewtype::Text
|
||||
|| msg.viewtype == Viewtype::VideochatInvitation
|
||||
|| msg.viewtype == Viewtype::Call
|
||||
{
|
||||
// the caller should check if the message text is empty
|
||||
} else if msg.viewtype.has_file() {
|
||||
let viewtype_orig = msg.viewtype;
|
||||
@@ -2821,8 +2846,9 @@ pub async fn is_contact_in_chat(
|
||||
) -> Result<bool> {
|
||||
// this function works for group and for normal chats, however, it is more useful
|
||||
// for group chats.
|
||||
// ContactId::SELF may be used to check, if the user itself is in a group
|
||||
// chat (ContactId::SELF is not added to normal chats)
|
||||
// ContactId::SELF may be used to check whether oneself
|
||||
// is in a group or incoming broadcast chat
|
||||
// (ContactId::SELF is not added to 1:1 chats or outgoing broadcast channels)
|
||||
|
||||
let exists = context
|
||||
.sql
|
||||
@@ -2912,13 +2938,20 @@ async fn prepare_send_msg(
|
||||
// Allow to send "Member removed" messages so we can leave the group/broadcast.
|
||||
// Necessary checks should be made anyway before removing contact
|
||||
// from the chat.
|
||||
CantSendReason::NotAMember | CantSendReason::InBroadcast => {
|
||||
msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup
|
||||
CantSendReason::NotAMember => msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup,
|
||||
CantSendReason::InBroadcast => {
|
||||
matches!(
|
||||
msg.param.get_cmd(),
|
||||
SystemMessage::MemberRemovedFromGroup | SystemMessage::SecurejoinMessage
|
||||
)
|
||||
}
|
||||
CantSendReason::MissingKey => {
|
||||
msg.param
|
||||
.get_bool(Param::ForcePlaintext)
|
||||
.unwrap_or_default()
|
||||
// "vb-request-with-auth" is symmetrically encrypted, no need for the public key:
|
||||
|| msg.is_vb_request_with_auth()
|
||||
}
|
||||
CantSendReason::MissingKey => msg
|
||||
.param
|
||||
.get_bool(Param::ForcePlaintext)
|
||||
.unwrap_or_default(),
|
||||
_ => false,
|
||||
};
|
||||
if let Some(reason) = chat.why_cant_send_ex(context, &skip_fn).await? {
|
||||
@@ -2960,8 +2993,7 @@ async fn prepare_send_msg(
|
||||
if !msg.hidden {
|
||||
chat_id.unarchive_if_not_muted(context, msg.state).await?;
|
||||
}
|
||||
msg.id = chat.prepare_msg_raw(context, msg, update_msg_id).await?;
|
||||
msg.chat_id = chat_id;
|
||||
chat.prepare_msg_raw(context, msg, update_msg_id).await?;
|
||||
|
||||
let row_ids = create_send_msg_jobs(context, msg)
|
||||
.await
|
||||
@@ -2974,6 +3006,10 @@ async fn prepare_send_msg(
|
||||
|
||||
/// Constructs jobs for sending a message and inserts them into the appropriate table.
|
||||
///
|
||||
/// Updates the message `GuaranteeE2ee` parameter and persists it
|
||||
/// in the database depending on whether the message
|
||||
/// is added to the outgoing queue as encrypted or not.
|
||||
///
|
||||
/// Returns row ids if `smtp` table jobs were created or an empty `Vec` otherwise.
|
||||
///
|
||||
/// The caller has to interrupt SMTP loop or otherwise process new rows.
|
||||
@@ -2985,7 +3021,16 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
|
||||
}
|
||||
|
||||
let needs_encryption = msg.param.get_bool(Param::GuaranteeE2ee).unwrap_or_default();
|
||||
let mimefactory = MimeFactory::from_msg(context, msg.clone()).await?;
|
||||
let mimefactory = match MimeFactory::from_msg(context, msg.clone()).await {
|
||||
Ok(mf) => mf,
|
||||
Err(err) => {
|
||||
// Mark message as failed
|
||||
message::set_msg_failed(context, msg, &err.to_string())
|
||||
.await
|
||||
.ok();
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let attach_selfavatar = mimefactory.attach_selfavatar;
|
||||
let mut recipients = mimefactory.recipients();
|
||||
|
||||
@@ -3008,6 +3053,9 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
|
||||
if (context.get_config_bool(Config::BccSelf).await?
|
||||
|| msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage)
|
||||
&& (context.get_config_delete_server_after().await? != Some(0) || !recipients.is_empty())
|
||||
// `vb-request-with-auth` messages are symmetrically encrypted
|
||||
// with a secret which the other device doesn't have:
|
||||
&& !msg.is_vb_request_with_auth()
|
||||
{
|
||||
recipients.push(from);
|
||||
}
|
||||
@@ -3067,13 +3115,20 @@ pub(crate) async fn create_send_msg_jobs(context: &Context, msg: &mut Message) -
|
||||
}
|
||||
}
|
||||
|
||||
if rendered_msg.is_encrypted && !needs_encryption {
|
||||
if rendered_msg.is_encrypted {
|
||||
msg.param.set_int(Param::GuaranteeE2ee, 1);
|
||||
msg.update_param(context).await?;
|
||||
} else {
|
||||
msg.param.remove(Param::GuaranteeE2ee);
|
||||
}
|
||||
|
||||
msg.subject.clone_from(&rendered_msg.subject);
|
||||
msg.update_subject(context).await?;
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"UPDATE msgs SET subject=?, param=? WHERE id=?",
|
||||
(&msg.subject, msg.param.to_string(), msg.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let chunk_size = context.get_max_smtp_rcpt_to().await?;
|
||||
let trans_fn = |t: &mut rusqlite::Transaction| {
|
||||
let mut row_ids = Vec::<i64>::new();
|
||||
@@ -3138,6 +3193,7 @@ pub async fn send_edit_request(context: &Context, msg_id: MsgId, new_text: Strin
|
||||
original_msg.viewtype != Viewtype::VideochatInvitation,
|
||||
"Cannot edit videochat invitations"
|
||||
);
|
||||
ensure!(original_msg.viewtype != Viewtype::Call, "Cannot edit calls");
|
||||
ensure!(
|
||||
!original_msg.text.is_empty(), // avoid complexity in UI element changes. focus is typos and rewordings
|
||||
"Cannot add text"
|
||||
@@ -3675,8 +3731,13 @@ pub async fn create_group_ex(
|
||||
encryption: Option<ProtectionStatus>,
|
||||
name: &str,
|
||||
) -> Result<ChatId> {
|
||||
let chat_name = sanitize_single_line(name);
|
||||
ensure!(!chat_name.is_empty(), "Invalid chat name");
|
||||
let mut chat_name = sanitize_single_line(name);
|
||||
if chat_name.is_empty() {
|
||||
// We can't just fail because the user would lose the work already done in the UI like
|
||||
// selecting members.
|
||||
error!(context, "Invalid chat name: {name}.");
|
||||
chat_name = "…".to_string();
|
||||
}
|
||||
|
||||
let grpid = match encryption {
|
||||
Some(_) => create_id(),
|
||||
@@ -3701,11 +3762,19 @@ pub async fn create_group_ex(
|
||||
chatlist_events::emit_chatlist_changed(context);
|
||||
chatlist_events::emit_chatlist_item_changed(context, chat_id);
|
||||
|
||||
if encryption == Some(ProtectionStatus::Protected) {
|
||||
let protect = ProtectionStatus::Protected;
|
||||
chat_id
|
||||
.set_protection_for_timestamp_sort(context, protect, timestamp, None)
|
||||
.await?;
|
||||
match encryption {
|
||||
Some(ProtectionStatus::Protected) => {
|
||||
let protect = ProtectionStatus::Protected;
|
||||
chat_id
|
||||
.set_protection_for_timestamp_sort(context, protect, timestamp, None)
|
||||
.await?;
|
||||
}
|
||||
Some(ProtectionStatus::Unprotected) => {
|
||||
// Add "Messages are end-to-end encrypted." message
|
||||
// even to unprotected chats.
|
||||
chat_id.maybe_add_encrypted_msg(context, timestamp).await?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
if !context.get_config_bool(Config::Bot).await?
|
||||
@@ -3718,7 +3787,7 @@ pub async fn create_group_ex(
|
||||
Ok(chat_id)
|
||||
}
|
||||
|
||||
/// Create a new **broadcast channel**
|
||||
/// Create a new, outgoing **broadcast channel**
|
||||
/// (called "Channel" in the UI).
|
||||
///
|
||||
/// Broadcast channels are similar to groups on the sending device,
|
||||
@@ -3735,60 +3804,92 @@ pub async fn create_group_ex(
|
||||
/// Returns the created chat's id.
|
||||
pub async fn create_broadcast(context: &Context, chat_name: String) -> Result<ChatId> {
|
||||
let grpid = create_id();
|
||||
create_broadcast_ex(context, Sync, grpid, chat_name).await
|
||||
let secret = create_broadcast_shared_secret();
|
||||
create_out_broadcast_ex(context, Sync, grpid, chat_name, secret).await
|
||||
}
|
||||
|
||||
pub(crate) async fn create_broadcast_ex(
|
||||
const SQL_INSERT_BROADCAST_SECRET: &str =
|
||||
"INSERT INTO broadcasts_shared_secrets (chat_id, secret) VALUES (?, ?)
|
||||
ON CONFLICT(chat_id) DO UPDATE SET secret=excluded.secret";
|
||||
|
||||
pub(crate) async fn create_out_broadcast_ex(
|
||||
context: &Context,
|
||||
sync: sync::Sync,
|
||||
grpid: String,
|
||||
chat_name: String,
|
||||
secret: String,
|
||||
) -> Result<ChatId> {
|
||||
let row_id = {
|
||||
let chat_name = &chat_name;
|
||||
let grpid = &grpid;
|
||||
let trans_fn = |t: &mut rusqlite::Transaction| {
|
||||
let cnt = t.execute("UPDATE chats SET name=? WHERE grpid=?", (chat_name, grpid))?;
|
||||
ensure!(cnt <= 1, "{cnt} chats exist with grpid {grpid}");
|
||||
if cnt == 1 {
|
||||
return Ok(t.query_row(
|
||||
"SELECT id FROM chats WHERE grpid=? AND type=?",
|
||||
(grpid, Chattype::OutBroadcast),
|
||||
|row| {
|
||||
let id: isize = row.get(0)?;
|
||||
Ok(id)
|
||||
},
|
||||
)?);
|
||||
}
|
||||
t.execute(
|
||||
"INSERT INTO chats \
|
||||
(type, name, grpid, param, created_timestamp) \
|
||||
VALUES(?, ?, ?, \'U=1\', ?);",
|
||||
(
|
||||
Chattype::OutBroadcast,
|
||||
&chat_name,
|
||||
&grpid,
|
||||
create_smeared_timestamp(context),
|
||||
),
|
||||
)?;
|
||||
Ok(t.last_insert_rowid().try_into()?)
|
||||
};
|
||||
context.sql.transaction(trans_fn).await?
|
||||
let chat_name = sanitize_single_line(&chat_name);
|
||||
if chat_name.is_empty() {
|
||||
bail!("Invalid broadcast channel name: {chat_name}.");
|
||||
}
|
||||
|
||||
let timestamp = create_smeared_timestamp(context);
|
||||
let trans_fn = |t: &mut rusqlite::Transaction| -> Result<ChatId> {
|
||||
let cnt: u32 = t.query_row(
|
||||
"SELECT COUNT(*) FROM chats WHERE grpid=?",
|
||||
(&grpid,),
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
ensure!(cnt == 0, "{cnt} chats exist with grpid {grpid}");
|
||||
|
||||
t.execute(
|
||||
"INSERT INTO chats \
|
||||
(type, name, grpid, created_timestamp) \
|
||||
VALUES(?, ?, ?, ?);",
|
||||
(Chattype::OutBroadcast, &chat_name, &grpid, timestamp),
|
||||
)?;
|
||||
let chat_id = ChatId::new(t.last_insert_rowid().try_into()?);
|
||||
|
||||
t.execute(SQL_INSERT_BROADCAST_SECRET, (chat_id, &secret))?;
|
||||
Ok(chat_id)
|
||||
};
|
||||
let chat_id = ChatId::new(u32::try_from(row_id)?);
|
||||
let chat_id = context.sql.transaction(trans_fn).await?;
|
||||
chat_id.maybe_add_encrypted_msg(context, timestamp).await?;
|
||||
|
||||
context.emit_msgs_changed_without_ids();
|
||||
chatlist_events::emit_chatlist_changed(context);
|
||||
chatlist_events::emit_chatlist_item_changed(context, chat_id);
|
||||
|
||||
if sync.into() {
|
||||
let id = SyncId::Grpid(grpid);
|
||||
let action = SyncAction::CreateBroadcast(chat_name);
|
||||
let action = SyncAction::CreateOutBroadcast {
|
||||
chat_name,
|
||||
shared_secret: secret,
|
||||
};
|
||||
self::sync(context, id, action).await.log_err(context).ok();
|
||||
}
|
||||
|
||||
Ok(chat_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn load_broadcast_shared_secret(
|
||||
context: &Context,
|
||||
chat_id: ChatId,
|
||||
) -> Result<Option<String>> {
|
||||
context
|
||||
.sql
|
||||
.query_get_value(
|
||||
"SELECT secret FROM broadcasts_shared_secrets WHERE chat_id=?",
|
||||
(chat_id,),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_broadcast_shared_secret(
|
||||
context: &Context,
|
||||
chat_id: ChatId,
|
||||
secret: &str,
|
||||
) -> Result<()> {
|
||||
info!(context, "Saving broadcast secret for chat {chat_id}");
|
||||
context
|
||||
.sql
|
||||
.execute(SQL_INSERT_BROADCAST_SECRET, (chat_id, secret))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set chat contacts in the `chats_contacts` table.
|
||||
pub(crate) async fn update_chat_contacts_table(
|
||||
context: &Context,
|
||||
@@ -3876,6 +3977,30 @@ pub(crate) async fn remove_from_chat_contacts_table(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a contact from the chat
|
||||
/// without leaving a trace.
|
||||
///
|
||||
/// Note that if we call this function,
|
||||
/// and then receive a message from another device
|
||||
/// that doesn't know that this this member was removed
|
||||
/// then the group membership algorithm will wrongly re-add this member.
|
||||
pub(crate) async fn remove_from_chat_contacts_table_without_trace(
|
||||
context: &Context,
|
||||
chat_id: ChatId,
|
||||
contact_id: ContactId,
|
||||
) -> Result<()> {
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"DELETE FROM chats_contacts
|
||||
WHERE chat_id=? AND contact_id=?",
|
||||
(chat_id, contact_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds a contact to the chat.
|
||||
/// If the group is promoted, also sends out a system message to all group members
|
||||
pub async fn add_contact_to_chat(
|
||||
@@ -3903,8 +4028,8 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
// this also makes sure, no contacts are added to special or normal chats
|
||||
let mut chat = Chat::load_from_db(context, chat_id).await?;
|
||||
ensure!(
|
||||
chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast,
|
||||
"{} is not a group/broadcast where one can add members",
|
||||
chat.typ == Chattype::Group || (from_handshake && chat.typ == Chattype::OutBroadcast),
|
||||
"{} is not a group where one can add members",
|
||||
chat_id
|
||||
);
|
||||
ensure!(
|
||||
@@ -3912,7 +4037,6 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
"invalid contact_id {} for adding to group",
|
||||
contact_id
|
||||
);
|
||||
ensure!(!chat.is_mailing_list(), "Mailing lists can't be changed");
|
||||
ensure!(
|
||||
chat.typ != Chattype::OutBroadcast || contact_id != ContactId::SELF,
|
||||
"Cannot add SELF to broadcast channel."
|
||||
@@ -3963,21 +4087,33 @@ pub(crate) async fn add_contact_to_chat_ex(
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
if is_contact_in_chat(context, chat_id, contact_id).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
add_to_chat_contacts_table(context, time(), chat_id, &[contact_id]).await?;
|
||||
}
|
||||
if chat.typ == Chattype::Group && chat.is_promoted() {
|
||||
if chat.is_promoted() {
|
||||
msg.viewtype = Viewtype::Text;
|
||||
|
||||
let contact_addr = contact.get_addr().to_lowercase();
|
||||
msg.text = stock_str::msg_add_member_local(context, contact.id, ContactId::SELF).await;
|
||||
let added_by = if from_handshake && chat.typ == Chattype::OutBroadcast {
|
||||
// The contact was added via a QR code rather than explicit user action,
|
||||
// so it could be confusing to say 'You added member Alice'.
|
||||
// And in a broadcast, SELF is the only one who can add members,
|
||||
// so, no information is lost by just writing 'Member Alice added' instead.
|
||||
ContactId::UNDEFINED
|
||||
} else {
|
||||
ContactId::SELF
|
||||
};
|
||||
msg.text = stock_str::msg_add_member_local(context, contact.id, added_by).await;
|
||||
msg.param.set_cmd(SystemMessage::MemberAddedToGroup);
|
||||
msg.param.set(Param::Arg, contact_addr);
|
||||
msg.param.set_int(Param::Arg2, from_handshake.into());
|
||||
msg.param
|
||||
.set_int(Param::ContactAddedRemoved, contact.id.to_u32() as i32);
|
||||
if chat.typ == Chattype::OutBroadcast {
|
||||
let secret = load_broadcast_shared_secret(context, chat_id)
|
||||
.await?
|
||||
.context("Failed to find broadcast shared secret")?;
|
||||
msg.param.set(Param::Arg3, secret);
|
||||
}
|
||||
send_msg(context, chat_id, &mut msg).await?;
|
||||
|
||||
sync = Nosync;
|
||||
@@ -4132,7 +4268,17 @@ pub async fn remove_contact_from_chat(
|
||||
);
|
||||
|
||||
let chat = Chat::load_from_db(context, chat_id).await?;
|
||||
if chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast {
|
||||
if chat.typ == Chattype::InBroadcast {
|
||||
ensure!(
|
||||
contact_id == ContactId::SELF,
|
||||
"Cannot remove other member from incoming broadcast channel"
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(
|
||||
chat.typ,
|
||||
Chattype::Group | Chattype::OutBroadcast | Chattype::InBroadcast
|
||||
) {
|
||||
if !chat.is_self_in_chat(context).await? {
|
||||
let err_msg = format!(
|
||||
"Cannot remove contact {contact_id} from chat {chat_id}: self not in group."
|
||||
@@ -4145,24 +4291,25 @@ pub async fn remove_contact_from_chat(
|
||||
if chat.is_promoted() {
|
||||
remove_from_chat_contacts_table(context, chat_id, contact_id).await?;
|
||||
} else {
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"DELETE FROM chats_contacts
|
||||
WHERE chat_id=? AND contact_id=?",
|
||||
(chat_id, contact_id),
|
||||
)
|
||||
.await?;
|
||||
remove_from_chat_contacts_table_without_trace(context, chat_id, contact_id).await?;
|
||||
}
|
||||
|
||||
// We do not return an error if the contact does not exist in the database.
|
||||
// This allows to delete dangling references to deleted contacts
|
||||
// in case of the database becoming inconsistent due to a bug.
|
||||
if let Some(contact) = Contact::get_by_id_optional(context, contact_id).await? {
|
||||
if chat.typ == Chattype::Group && chat.is_promoted() {
|
||||
if chat.is_promoted() {
|
||||
let addr = contact.get_addr();
|
||||
let fingerprint = contact.fingerprint().map(|f| f.hex());
|
||||
|
||||
let res = send_member_removal_msg(context, chat_id, contact_id, addr).await;
|
||||
let res = send_member_removal_msg(
|
||||
context,
|
||||
chat_id,
|
||||
contact_id,
|
||||
addr,
|
||||
fingerprint.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if contact_id == ContactId::SELF {
|
||||
res?;
|
||||
@@ -4182,11 +4329,6 @@ pub async fn remove_contact_from_chat(
|
||||
chat.sync_contacts(context).await.log_err(context).ok();
|
||||
}
|
||||
}
|
||||
} else if chat.typ == Chattype::InBroadcast && contact_id == ContactId::SELF {
|
||||
// For incoming broadcast channels, it's not possible to remove members,
|
||||
// but it's possible to leave:
|
||||
let self_addr = context.get_primary_self_addr().await?;
|
||||
send_member_removal_msg(context, chat_id, contact_id, &self_addr).await?;
|
||||
} else {
|
||||
bail!("Cannot remove members from non-group chats.");
|
||||
}
|
||||
@@ -4199,6 +4341,7 @@ async fn send_member_removal_msg(
|
||||
chat_id: ChatId,
|
||||
contact_id: ContactId,
|
||||
addr: &str,
|
||||
fingerprint: Option<&str>,
|
||||
) -> Result<MsgId> {
|
||||
let mut msg = Message::new(Viewtype::Text);
|
||||
|
||||
@@ -4210,6 +4353,7 @@ async fn send_member_removal_msg(
|
||||
|
||||
msg.param.set_cmd(SystemMessage::MemberRemovedFromGroup);
|
||||
msg.param.set(Param::Arg, addr.to_lowercase());
|
||||
msg.param.set_optional(Param::Arg2, fingerprint);
|
||||
msg.param
|
||||
.set(Param::ContactAddedRemoved, contact_id.to_u32());
|
||||
|
||||
@@ -4418,13 +4562,13 @@ pub async fn forward_msgs(context: &Context, msg_ids: &[MsgId], chat_id: ChatId)
|
||||
msg.state = MessageState::OutPending;
|
||||
msg.rfc724_mid = create_outgoing_rfc724_mid();
|
||||
msg.timestamp_sort = curr_timestamp;
|
||||
let new_msg_id = chat.prepare_msg_raw(context, &mut msg, None).await?;
|
||||
chat.prepare_msg_raw(context, &mut msg, None).await?;
|
||||
|
||||
curr_timestamp += 1;
|
||||
if !create_send_msg_jobs(context, &mut msg).await?.is_empty() {
|
||||
context.scheduler.interrupt_smtp().await;
|
||||
}
|
||||
created_msgs.push(new_msg_id);
|
||||
created_msgs.push(msg.id);
|
||||
}
|
||||
for msg_id in created_msgs {
|
||||
context.emit_msgs_changed(chat_id, msg_id);
|
||||
@@ -4481,15 +4625,24 @@ pub(crate) async fn save_copy_in_self_talk(
|
||||
bail!("message already saved.");
|
||||
}
|
||||
|
||||
let copy_fields = "from_id, to_id, timestamp_sent, timestamp_rcvd, type, txt, \
|
||||
mime_modified, mime_headers, mime_compressed, mime_in_reply_to, subject, msgrmsg";
|
||||
let copy_fields = "from_id, to_id, timestamp_rcvd, type, txt,
|
||||
mime_modified, mime_headers, mime_compressed, mime_in_reply_to, subject, msgrmsg";
|
||||
let row_id = context
|
||||
.sql
|
||||
.insert(
|
||||
&format!(
|
||||
"INSERT INTO msgs ({copy_fields}, chat_id, rfc724_mid, state, timestamp, param, starred) \
|
||||
SELECT {copy_fields}, ?, ?, ?, ?, ?, ? \
|
||||
FROM msgs WHERE id=?;"
|
||||
"INSERT INTO msgs ({copy_fields},
|
||||
timestamp_sent,
|
||||
chat_id, rfc724_mid, state, timestamp, param, starred)
|
||||
SELECT {copy_fields},
|
||||
-- Outgoing messages on originating device
|
||||
-- have timestamp_sent == 0.
|
||||
-- We copy sort timestamp instead
|
||||
-- so UIs display the same timestamp
|
||||
-- for saved and original message.
|
||||
IIF(timestamp_sent == 0, timestamp, timestamp_sent),
|
||||
?, ?, ?, ?, ?, ?
|
||||
FROM msgs WHERE id=?;"
|
||||
),
|
||||
(
|
||||
dest_chat_id,
|
||||
@@ -4520,18 +4673,9 @@ pub(crate) async fn save_copy_in_self_talk(
|
||||
///
|
||||
/// This is primarily intended to make existing webxdcs available to new chat members.
|
||||
pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
|
||||
let mut chat_id = None;
|
||||
let mut msgs: Vec<Message> = Vec::new();
|
||||
for msg_id in msg_ids {
|
||||
let msg = Message::load_from_db(context, *msg_id).await?;
|
||||
if let Some(chat_id) = chat_id {
|
||||
ensure!(
|
||||
chat_id == msg.chat_id,
|
||||
"messages to resend needs to be in the same chat"
|
||||
);
|
||||
} else {
|
||||
chat_id = Some(msg.chat_id);
|
||||
}
|
||||
ensure!(
|
||||
msg.from_id == ContactId::SELF,
|
||||
"can resend only own messages"
|
||||
@@ -4540,16 +4684,7 @@ pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
|
||||
msgs.push(msg)
|
||||
}
|
||||
|
||||
let Some(chat_id) = chat_id else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let chat = Chat::load_from_db(context, chat_id).await?;
|
||||
for mut msg in msgs {
|
||||
if msg.get_showpadlock() && !chat.is_protected() {
|
||||
msg.param.remove(Param::GuaranteeE2ee);
|
||||
msg.update_param(context).await?;
|
||||
}
|
||||
match msg.get_state() {
|
||||
// `get_state()` may return an outdated `OutPending`, so update anyway.
|
||||
MessageState::OutPending
|
||||
@@ -4560,16 +4695,21 @@ pub async fn resend_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
|
||||
}
|
||||
msg_state => bail!("Unexpected message state {msg_state}"),
|
||||
}
|
||||
msg.timestamp_sort = create_smeared_timestamp(context);
|
||||
if create_send_msg_jobs(context, &mut msg).await?.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Emit the event only after `create_send_msg_jobs`
|
||||
// because `create_send_msg_jobs` may change the message
|
||||
// encryption status and call `msg.update_param`.
|
||||
context.emit_event(EventType::MsgsChanged {
|
||||
chat_id: msg.chat_id,
|
||||
msg_id: msg.id,
|
||||
});
|
||||
msg.timestamp_sort = create_smeared_timestamp(context);
|
||||
// note(treefit): only matters if it is the last message in chat (but probably to expensive to check, debounce also solves it)
|
||||
chatlist_events::emit_chatlist_item_changed(context, msg.chat_id);
|
||||
if create_send_msg_jobs(context, &mut msg).await?.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if msg.viewtype == Viewtype::Webxdc {
|
||||
let conn_fn = |conn: &mut rusqlite::Connection| {
|
||||
let range = conn.query_row(
|
||||
@@ -4998,7 +5138,12 @@ pub(crate) enum SyncAction {
|
||||
SetVisibility(ChatVisibility),
|
||||
SetMuted(MuteDuration),
|
||||
/// Create broadcast channel with the given name.
|
||||
CreateBroadcast(String),
|
||||
CreateOutBroadcast {
|
||||
chat_name: String,
|
||||
shared_secret: String,
|
||||
},
|
||||
/// Mark the contact with the given fingerprint as verified by self.
|
||||
MarkVerified,
|
||||
Rename(String),
|
||||
/// Set chat contacts by their addresses.
|
||||
SetContacts(Vec<String>),
|
||||
@@ -5054,6 +5199,16 @@ impl Context {
|
||||
SyncAction::Unblock => {
|
||||
return contact::set_blocked(self, Nosync, contact_id, false).await;
|
||||
}
|
||||
SyncAction::MarkVerified => {
|
||||
ContactId::scaleup_origin(self, &[contact_id], Origin::SecurejoinJoined)
|
||||
.await?;
|
||||
return contact::mark_contact_id_as_verified(
|
||||
self,
|
||||
contact_id,
|
||||
Some(ContactId::SELF),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
ChatIdBlocked::get_for_contact(self, contact_id, Blocked::Request)
|
||||
@@ -5061,8 +5216,8 @@ impl Context {
|
||||
.id
|
||||
}
|
||||
SyncId::Grpid(grpid) => {
|
||||
if let SyncAction::CreateBroadcast(name) = action {
|
||||
create_broadcast_ex(self, Nosync, grpid.clone(), name.clone()).await?;
|
||||
let handled = self.handle_sync_create_chat(action, grpid).await?;
|
||||
if handled {
|
||||
return Ok(());
|
||||
}
|
||||
get_chat_id_by_grpid(self, grpid)
|
||||
@@ -5085,7 +5240,9 @@ impl Context {
|
||||
SyncAction::Accept => chat_id.accept_ex(self, Nosync).await,
|
||||
SyncAction::SetVisibility(v) => chat_id.set_visibility_ex(self, Nosync, *v).await,
|
||||
SyncAction::SetMuted(duration) => set_muted_ex(self, Nosync, chat_id, *duration).await,
|
||||
SyncAction::CreateBroadcast(_) => {
|
||||
SyncAction::CreateOutBroadcast { .. } | SyncAction::MarkVerified => {
|
||||
// Create action should have been handled by handle_sync_create_chat() already.
|
||||
// MarkVerified action should have been handled by mark_contact_id_as_verified() already.
|
||||
Err(anyhow!("sync_alter_chat({id:?}, {action:?}): Bad request."))
|
||||
}
|
||||
SyncAction::Rename(to) => rename_ex(self, Nosync, chat_id, to).await,
|
||||
@@ -5097,6 +5254,26 @@ impl Context {
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_sync_create_chat(&self, action: &SyncAction, grpid: &str) -> Result<bool> {
|
||||
match action {
|
||||
SyncAction::CreateOutBroadcast {
|
||||
chat_name,
|
||||
shared_secret,
|
||||
} => {
|
||||
create_out_broadcast_ex(
|
||||
self,
|
||||
Nosync,
|
||||
grpid.to_string(),
|
||||
chat_name.clone(),
|
||||
shared_secret.to_string(),
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits the appropriate `MsgsChanged` event. Should be called if the number of unnoticed
|
||||
/// archived chats could decrease. In general we don't want to make an extra db query to know if
|
||||
/// a noticed chat is archived. Emitting events should be cheap, a false-positive `MsgsChanged`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::chatlist::get_archived_cnt;
|
||||
use crate::constants::{DC_GCL_ARCHIVED_ONLY, DC_GCL_NO_SPECIALS};
|
||||
@@ -7,11 +9,14 @@ use crate::imex::{ImexMode, has_backup, imex};
|
||||
use crate::message::{MessengerMessage, delete_msgs};
|
||||
use crate::mimeparser::{self, MimeMessage};
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::securejoin::{get_securejoin_qr, join_securejoin};
|
||||
use crate::test_utils::{
|
||||
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, E2EE_INFO_MSGS, TestContext, TestContextManager,
|
||||
TimeShiftFalsePositiveNote, sync,
|
||||
};
|
||||
use crate::tools::SystemTime;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::time::Duration;
|
||||
use strum::IntoEnumIterator;
|
||||
use tokio::fs;
|
||||
|
||||
@@ -32,7 +37,7 @@ async fn test_chat_info() {
|
||||
"archived": false,
|
||||
"param": "",
|
||||
"is_sending_locations": false,
|
||||
"color": 35391,
|
||||
"color": 29377,
|
||||
"profile_image": {},
|
||||
"draft": "",
|
||||
"is_muted": false,
|
||||
@@ -1644,7 +1649,7 @@ async fn test_set_mute_duration() {
|
||||
async fn test_add_info_msg() -> Result<()> {
|
||||
let t = TestContext::new().await;
|
||||
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "foo").await?;
|
||||
add_info_msg(&t, chat_id, "foo info", 200000).await?;
|
||||
add_info_msg(&t, chat_id, "foo info", time()).await?;
|
||||
|
||||
let msg = t.get_last_msg_in(chat_id).await;
|
||||
assert_eq!(msg.get_chat_id(), chat_id);
|
||||
@@ -1666,7 +1671,7 @@ async fn test_add_info_msg_with_cmd() -> Result<()> {
|
||||
chat_id,
|
||||
"foo bar info",
|
||||
SystemMessage::EphemeralTimerChanged,
|
||||
10000,
|
||||
time(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -1929,19 +1934,31 @@ async fn test_classic_email_chat() -> Result<()> {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_chat_get_color() -> Result<()> {
|
||||
let t = TestContext::new().await;
|
||||
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "a chat").await?;
|
||||
let chat_id = create_group_ex(&t, None, "a chat").await?;
|
||||
let color1 = Chat::load_from_db(&t, chat_id).await?.get_color(&t).await?;
|
||||
assert_eq!(color1, 0x008772);
|
||||
assert_eq!(color1, 0x613dd7);
|
||||
|
||||
// upper-/lowercase makes a difference for the colors, these are different groups
|
||||
// (in contrast to email addresses, where upper-/lowercase is ignored in practise)
|
||||
let t = TestContext::new().await;
|
||||
let chat_id = create_group_chat(&t, ProtectionStatus::Unprotected, "A CHAT").await?;
|
||||
let chat_id = create_group_ex(&t, None, "A CHAT").await?;
|
||||
let color2 = Chat::load_from_db(&t, chat_id).await?.get_color(&t).await?;
|
||||
assert_ne!(color2, color1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_chat_get_color_encrypted() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let t = &tcm.alice().await;
|
||||
let chat_id = create_group_ex(t, Some(ProtectionStatus::Unprotected), "a chat").await?;
|
||||
let color1 = Chat::load_from_db(t, chat_id).await?.get_color(t).await?;
|
||||
set_chat_name(t, chat_id, "A CHAT").await?;
|
||||
let color2 = Chat::load_from_db(t, chat_id).await?.get_color(t).await?;
|
||||
assert_eq!(color2, color1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn test_sticker(
|
||||
filename: &str,
|
||||
bytes: &[u8],
|
||||
@@ -2262,7 +2279,8 @@ async fn test_only_minimal_data_are_forwarded() -> Result<()> {
|
||||
let group_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "group2").await?;
|
||||
add_contact_to_chat(&bob, group_id, charlie_id).await?;
|
||||
let broadcast_id = create_broadcast(&bob, "Channel".to_string()).await?;
|
||||
add_contact_to_chat(&bob, broadcast_id, charlie_id).await?;
|
||||
let qr = get_securejoin_qr(&bob, Some(broadcast_id)).await?;
|
||||
tcm.exec_securejoin_qr(&charlie, &bob, &qr).await;
|
||||
for chat_id in &[single_id, group_id, broadcast_id] {
|
||||
forward_msgs(&bob, &[orig_msg.id], *chat_id).await?;
|
||||
let sent_msg = bob.pop_sent_msg().await;
|
||||
@@ -2280,14 +2298,19 @@ async fn test_only_minimal_data_are_forwarded() -> Result<()> {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_save_msgs() -> Result<()> {
|
||||
let alice = TestContext::new_alice().await;
|
||||
let bob = TestContext::new_bob().await;
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = tcm.alice().await;
|
||||
let bob = tcm.bob().await;
|
||||
let alice_chat = alice.create_chat(&bob).await;
|
||||
|
||||
let sent = alice.send_text(alice_chat.get_id(), "hi, bob").await;
|
||||
let sent_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?;
|
||||
assert!(sent_msg.get_saved_msg_id(&alice).await?.is_none());
|
||||
assert!(sent_msg.get_original_msg_id(&alice).await?.is_none());
|
||||
let sent_timestamp = sent_msg.get_timestamp();
|
||||
assert!(sent_timestamp > 0);
|
||||
|
||||
SystemTime::shift(Duration::from_secs(60));
|
||||
|
||||
let self_chat = alice.get_self_chat().await;
|
||||
save_msgs(&alice, &[sent.sender_msg_id]).await?;
|
||||
@@ -2305,6 +2328,8 @@ async fn test_save_msgs() -> Result<()> {
|
||||
assert_eq!(saved_msg.get_from_id(), ContactId::SELF);
|
||||
assert_eq!(saved_msg.get_state(), MessageState::OutDelivered);
|
||||
assert_ne!(saved_msg.rfc724_mid(), sent_msg.rfc724_mid());
|
||||
let saved_timestamp = saved_msg.get_timestamp();
|
||||
assert_eq!(saved_timestamp, sent_timestamp);
|
||||
|
||||
let sent_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?;
|
||||
assert_eq!(
|
||||
@@ -2618,44 +2643,67 @@ async fn test_can_send_group() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast() -> Result<()> {
|
||||
async fn test_broadcast_change_name() -> Result<()> {
|
||||
// create two context, send two messages so both know the other
|
||||
let alice = TestContext::new_alice().await;
|
||||
let bob = TestContext::new_bob().await;
|
||||
let fiona = TestContext::new_fiona().await;
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let fiona = &tcm.fiona().await;
|
||||
|
||||
let chat_alice = alice.create_chat(&bob).await;
|
||||
send_text_msg(&alice, chat_alice.id, "hi!".to_string()).await?;
|
||||
tcm.section("Alice sends a message to Bob");
|
||||
let chat_alice = alice.create_chat(bob).await;
|
||||
send_text_msg(alice, chat_alice.id, "hi!".to_string()).await?;
|
||||
bob.recv_msg(&alice.pop_sent_msg().await).await;
|
||||
|
||||
let chat_bob = bob.create_chat(&alice).await;
|
||||
send_text_msg(&bob, chat_bob.id, "ho!".to_string()).await?;
|
||||
tcm.section("Bob sends a message to Alice");
|
||||
let chat_bob = bob.create_chat(alice).await;
|
||||
send_text_msg(bob, chat_bob.id, "ho!".to_string()).await?;
|
||||
let msg = alice.recv_msg(&bob.pop_sent_msg().await).await;
|
||||
assert!(msg.get_showpadlock());
|
||||
|
||||
// test broadcast channel
|
||||
let broadcast_id = create_broadcast(&alice, "Channel".to_string()).await?;
|
||||
add_contact_to_chat(
|
||||
&alice,
|
||||
broadcast_id,
|
||||
get_chat_contacts(&alice, chat_bob.id).await?.pop().unwrap(),
|
||||
)
|
||||
.await?;
|
||||
let fiona_contact_id = alice.add_or_lookup_contact_id(&fiona).await;
|
||||
add_contact_to_chat(&alice, broadcast_id, fiona_contact_id).await?;
|
||||
set_chat_name(&alice, broadcast_id, "Broadcast channel").await?;
|
||||
let broadcast_id = create_broadcast(alice, "Channel".to_string()).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(broadcast_id)).await.unwrap();
|
||||
|
||||
tcm.section("Alice invites Bob to her channel");
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
tcm.section("Alice invites Fiona to her channel");
|
||||
tcm.exec_securejoin_qr(fiona, alice, &qr).await;
|
||||
|
||||
{
|
||||
let chat = Chat::load_from_db(&alice, broadcast_id).await?;
|
||||
tcm.section("Alice changes the chat name");
|
||||
set_chat_name(alice, broadcast_id, "My great broadcast").await?;
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
|
||||
tcm.section("Bob receives the name-change system message");
|
||||
let msg = bob.recv_msg(&sent).await;
|
||||
assert_eq!(msg.subject, "Re: My great broadcast");
|
||||
let bob_chat = Chat::load_from_db(bob, msg.chat_id).await?;
|
||||
assert_eq!(bob_chat.name, "My great broadcast");
|
||||
|
||||
tcm.section("Fiona receives the name-change system message");
|
||||
let msg = fiona.recv_msg(&sent).await;
|
||||
assert_eq!(msg.subject, "Re: My great broadcast");
|
||||
let fiona_chat = Chat::load_from_db(fiona, msg.chat_id).await?;
|
||||
assert_eq!(fiona_chat.name, "My great broadcast");
|
||||
}
|
||||
|
||||
{
|
||||
tcm.section("Alice changes the chat name again, but the system message is lost somehow");
|
||||
set_chat_name(alice, broadcast_id, "Broadcast channel").await?;
|
||||
|
||||
let chat = Chat::load_from_db(alice, broadcast_id).await?;
|
||||
assert_eq!(chat.typ, Chattype::OutBroadcast);
|
||||
assert_eq!(chat.name, "Broadcast channel");
|
||||
assert!(!chat.is_self_talk());
|
||||
|
||||
send_text_msg(&alice, broadcast_id, "ola!".to_string()).await?;
|
||||
tcm.section("Alice sends a text message 'ola!'");
|
||||
send_text_msg(alice, broadcast_id, "ola!".to_string()).await?;
|
||||
let msg = alice.get_last_msg().await;
|
||||
assert_eq!(msg.chat_id, chat.id);
|
||||
}
|
||||
|
||||
{
|
||||
tcm.section("Bob receives the 'ola!' message");
|
||||
let sent_msg = alice.pop_sent_msg().await;
|
||||
let msg = bob.parse_msg(&sent_msg).await;
|
||||
assert!(msg.was_encrypted());
|
||||
@@ -2668,25 +2716,23 @@ async fn test_broadcast() -> Result<()> {
|
||||
|
||||
let msg = bob.recv_msg(&sent_msg).await;
|
||||
assert_eq!(msg.get_text(), "ola!");
|
||||
assert_eq!(msg.subject, "Broadcast channel");
|
||||
assert_eq!(msg.subject, "Re: Broadcast channel");
|
||||
assert!(msg.get_showpadlock());
|
||||
assert!(msg.get_override_sender_name().is_none());
|
||||
let chat = Chat::load_from_db(&bob, msg.chat_id).await?;
|
||||
let chat = Chat::load_from_db(bob, msg.chat_id).await?;
|
||||
assert_eq!(chat.typ, Chattype::InBroadcast);
|
||||
assert_ne!(chat.id, chat_bob.id);
|
||||
assert_eq!(chat.name, "Broadcast channel");
|
||||
assert!(!chat.is_self_talk());
|
||||
}
|
||||
|
||||
{
|
||||
// Alice changes the name:
|
||||
set_chat_name(&alice, broadcast_id, "My great broadcast").await?;
|
||||
let sent = alice.send_text(broadcast_id, "I changed the title!").await;
|
||||
|
||||
let msg = bob.recv_msg(&sent).await;
|
||||
assert_eq!(msg.subject, "Re: My great broadcast");
|
||||
let bob_chat = Chat::load_from_db(&bob, msg.chat_id).await?;
|
||||
assert_eq!(bob_chat.name, "My great broadcast");
|
||||
tcm.section("Fiona receives the 'ola!' message");
|
||||
let msg = fiona.recv_msg(&sent_msg).await;
|
||||
assert_eq!(msg.get_text(), "ola!");
|
||||
assert!(msg.get_showpadlock());
|
||||
assert!(msg.get_override_sender_name().is_none());
|
||||
let chat = Chat::load_from_db(fiona, msg.chat_id).await?;
|
||||
assert_eq!(chat.typ, Chattype::InBroadcast);
|
||||
assert_eq!(chat.name, "Broadcast channel");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -2702,45 +2748,43 @@ async fn test_broadcast() -> Result<()> {
|
||||
/// `test_sync_broadcast()` tests that synchronization works via sync messages.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast_multidev() -> Result<()> {
|
||||
let alices = [
|
||||
TestContext::new_alice().await,
|
||||
TestContext::new_alice().await,
|
||||
];
|
||||
let bob = TestContext::new_bob().await;
|
||||
let a1b_contact_id = alices[1].add_or_lookup_contact(&bob).await.id;
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice0 = &tcm.alice().await;
|
||||
let alice1 = &tcm.alice().await;
|
||||
for a in &[alice0, alice1] {
|
||||
a.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
}
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let a0_broadcast_id = create_broadcast(&alices[0], "Channel".to_string()).await?;
|
||||
let a0_broadcast_chat = Chat::load_from_db(&alices[0], a0_broadcast_id).await?;
|
||||
set_chat_name(&alices[0], a0_broadcast_id, "Broadcast channel 42").await?;
|
||||
let sent_msg = alices[0].send_text(a0_broadcast_id, "hi").await;
|
||||
let msg = alices[1].recv_msg(&sent_msg).await;
|
||||
let a1_broadcast_id = get_chat_id_by_grpid(&alices[1], &a0_broadcast_chat.grpid)
|
||||
let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?;
|
||||
sync(alice0, alice1).await;
|
||||
let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?;
|
||||
set_chat_name(alice0, a0_broadcast_id, "Broadcast channel 42").await?;
|
||||
let sent_msg = alice0.send_text(a0_broadcast_id, "hi").await;
|
||||
let msg = alice1.recv_msg(&sent_msg).await;
|
||||
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)
|
||||
.await?
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(msg.chat_id, a1_broadcast_id);
|
||||
let a1_broadcast_chat = Chat::load_from_db(&alices[1], a1_broadcast_id).await?;
|
||||
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
|
||||
assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast);
|
||||
assert_eq!(a1_broadcast_chat.get_name(), "Broadcast channel 42");
|
||||
assert!(
|
||||
get_chat_contacts(&alices[1], a1_broadcast_id)
|
||||
.await?
|
||||
.is_empty()
|
||||
);
|
||||
assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty());
|
||||
|
||||
add_contact_to_chat(&alices[1], a1_broadcast_id, a1b_contact_id).await?;
|
||||
set_chat_name(&alices[1], a1_broadcast_id, "Broadcast channel 43").await?;
|
||||
let sent_msg = alices[1].send_text(a1_broadcast_id, "hi").await;
|
||||
let msg = alices[0].recv_msg(&sent_msg).await;
|
||||
let qr = get_securejoin_qr(alice1, Some(a1_broadcast_id))
|
||||
.await
|
||||
.unwrap();
|
||||
tcm.exec_securejoin_qr(bob, alice1, &qr).await;
|
||||
|
||||
set_chat_name(alice1, a1_broadcast_id, "Broadcast channel 43").await?;
|
||||
let sent_msg = alice1.send_text(a1_broadcast_id, "hi").await;
|
||||
let msg = alice0.recv_msg(&sent_msg).await;
|
||||
assert_eq!(msg.chat_id, a0_broadcast_id);
|
||||
let a0_broadcast_chat = Chat::load_from_db(&alices[0], a0_broadcast_id).await?;
|
||||
let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?;
|
||||
assert_eq!(a0_broadcast_chat.get_type(), Chattype::OutBroadcast);
|
||||
assert_eq!(a0_broadcast_chat.get_name(), "Broadcast channel 42");
|
||||
assert!(
|
||||
get_chat_contacts(&alices[0], a0_broadcast_id)
|
||||
.await?
|
||||
.is_empty()
|
||||
);
|
||||
assert!(get_chat_contacts(alice0, a0_broadcast_id).await?.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2758,7 +2802,6 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
let alice = &tcm.alice().await;
|
||||
alice.set_config(Config::Displayname, Some("Alice")).await?;
|
||||
let bob = &tcm.bob().await;
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
|
||||
tcm.section("Create a broadcast channel");
|
||||
let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?;
|
||||
@@ -2766,14 +2809,15 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
assert_eq!(alice_chat.typ, Chattype::OutBroadcast);
|
||||
|
||||
let alice_chat = Chat::load_from_db(alice, alice_chat_id).await?;
|
||||
assert_eq!(alice_chat.is_promoted(), false);
|
||||
assert_eq!(alice_chat.is_promoted(), true); // Broadcast channels are never unpromoted
|
||||
let sent = alice.send_text(alice_chat_id, "Hi nobody").await;
|
||||
let alice_chat = Chat::load_from_db(alice, alice_chat_id).await?;
|
||||
assert_eq!(alice_chat.is_promoted(), true);
|
||||
assert_eq!(sent.recipients, "alice@example.org");
|
||||
|
||||
tcm.section("Add a contact to the chat and send a message");
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
let sent = alice.send_text(alice_chat_id, "Hi somebody").await;
|
||||
|
||||
assert_eq!(sent.recipients, "bob@example.net alice@example.org");
|
||||
@@ -2831,6 +2875,67 @@ async fn test_broadcasts_name_and_avatar() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that directly after broadcast-securejoin,
|
||||
/// the brodacast is shown correctly on both devices.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_broadcast_joining_golden() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
alice.set_config(Config::Displayname, Some("Alice")).await?;
|
||||
|
||||
tcm.section("Create a broadcast channel with an avatar");
|
||||
let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?;
|
||||
let file = alice.get_blobdir().join("avatar.png");
|
||||
tokio::fs::write(&file, AVATAR_64x64_BYTES).await?;
|
||||
set_chat_profile_image(alice, alice_chat_id, file.to_str().unwrap()).await?;
|
||||
// Because broadcasts are always 'promoted',
|
||||
// set_chat_profile_image() sends out a message,
|
||||
// which we need to pop:
|
||||
alice.pop_sent_msg().await;
|
||||
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
let bob_chat_id = tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
alice
|
||||
.golden_test_chat(alice_chat_id, "test_broadcast_joining_golden_alice")
|
||||
.await;
|
||||
bob.golden_test_chat(bob_chat_id, "test_broadcast_joining_golden_bob")
|
||||
.await;
|
||||
|
||||
let alice_bob_contact = alice.add_or_lookup_contact_no_key(bob).await;
|
||||
let direct_chat = ChatIdBlocked::lookup_by_contact(alice, alice_bob_contact.id)
|
||||
.await?
|
||||
.unwrap();
|
||||
// The 1:1 chat with Bob should not be visible to the user:
|
||||
assert_eq!(direct_chat.blocked, Blocked::Yes);
|
||||
alice
|
||||
.golden_test_chat(direct_chat.id, "test_broadcast_joining_golden_alice_direct")
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
alice_bob_contact
|
||||
.get_verifier_id(alice)
|
||||
.await?
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
ContactId::SELF
|
||||
);
|
||||
|
||||
let bob_alice_contact = bob.add_or_lookup_contact_no_key(alice).await;
|
||||
assert_eq!(
|
||||
bob_alice_contact
|
||||
.get_verifier_id(bob)
|
||||
.await?
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
ContactId::SELF
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// - Create a broadcast channel
|
||||
/// - Block it
|
||||
/// - Check that the broadcast channel appears in the list of blocked contacts
|
||||
@@ -2842,11 +2947,13 @@ async fn test_block_broadcast() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
|
||||
tcm.section("Create a broadcast channel with Bob, and send a message");
|
||||
let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?;
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
let sent = alice.send_text(alice_chat_id, "Hi somebody").await;
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
|
||||
@@ -2854,7 +2961,7 @@ async fn test_block_broadcast() -> Result<()> {
|
||||
assert_eq!(chats.len(), 1);
|
||||
assert_eq!(chats.get_chat_id(0)?, rcvd.chat_id);
|
||||
|
||||
assert_eq!(rcvd.chat_blocked, Blocked::Request);
|
||||
assert_eq!(rcvd.chat_blocked, Blocked::Not);
|
||||
let blocked = Contact::get_all_blocked(bob).await.unwrap();
|
||||
assert_eq!(blocked.len(), 0);
|
||||
|
||||
@@ -2908,11 +3015,13 @@ async fn test_broadcast_channel_protected_listid() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
|
||||
tcm.section("Create a broadcast channel with Bob, and send a message");
|
||||
let alice_chat_id = create_broadcast(alice, "My Channel".to_string()).await?;
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
let mut sent = alice.send_text(alice_chat_id, "Hi somebody").await;
|
||||
|
||||
assert!(!sent.payload.contains("List-ID"));
|
||||
@@ -2957,8 +3066,8 @@ async fn test_leave_broadcast() -> Result<()> {
|
||||
|
||||
tcm.section("Alice creates broadcast channel with Bob.");
|
||||
let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?;
|
||||
let bob_contact = alice.add_or_lookup_contact(bob).await.id;
|
||||
add_contact_to_chat(alice, alice_chat_id, bob_contact).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
|
||||
tcm.section("Alice sends first message to broadcast.");
|
||||
let sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
|
||||
@@ -2981,7 +3090,12 @@ async fn test_leave_broadcast() -> Result<()> {
|
||||
let leave_msg = bob.pop_sent_msg().await;
|
||||
alice.recv_msg_trash(&leave_msg).await;
|
||||
|
||||
assert_eq!(get_chat_contacts(alice, alice_chat_id).await?.len(), 0);
|
||||
assert!(get_chat_contacts(alice, alice_chat_id).await?.is_empty());
|
||||
assert!(
|
||||
get_past_chat_contacts(alice, alice_chat_id)
|
||||
.await?
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
alice.emit_event(EventType::Test);
|
||||
alice
|
||||
@@ -3012,11 +3126,35 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
let alice = &tcm.alice().await;
|
||||
let bob0 = &tcm.bob().await;
|
||||
let bob1 = &tcm.bob().await;
|
||||
for b in [bob0, bob1] {
|
||||
b.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
}
|
||||
|
||||
tcm.section("Alice creates broadcast channel with Bob.");
|
||||
let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?;
|
||||
let bob_contact = alice.add_or_lookup_contact(bob0).await.id;
|
||||
add_contact_to_chat(alice, alice_chat_id, bob_contact).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
join_securejoin(bob0, &qr).await.unwrap();
|
||||
let request = bob0.pop_sent_msg().await;
|
||||
|
||||
// Bob must send the message only to Alice, not to Self,
|
||||
// because otherwise, his second device would show a device message
|
||||
// "⚠️ It seems you are using Delta Chat on multiple devices that cannot decrypt each other's outgoing messages.
|
||||
// To fix this, on the older device use \"Settings / Add Second Device\" and follow the instructions."
|
||||
assert_eq!(request.recipients, "alice@example.org");
|
||||
|
||||
alice.recv_msg_trash(&request).await;
|
||||
let answer = alice.pop_sent_msg().await;
|
||||
bob0.recv_msg(&answer).await;
|
||||
|
||||
// Sync Bob's verification of Alice:
|
||||
sync(bob0, bob1).await;
|
||||
bob1.recv_msg(&answer).await;
|
||||
|
||||
// The 1:1 chat should not be visible to the user on any of the devices.
|
||||
// The contact should be marked as verified.
|
||||
check_direct_chat_is_hidden_and_contact_is_verified(alice, bob0).await;
|
||||
check_direct_chat_is_hidden_and_contact_is_verified(bob0, alice).await;
|
||||
check_direct_chat_is_hidden_and_contact_is_verified(bob1, alice).await;
|
||||
|
||||
tcm.section("Alice sends first message to broadcast.");
|
||||
let sent_msg = alice.send_text(alice_chat_id, "Hello!").await;
|
||||
@@ -3048,6 +3186,180 @@ async fn test_leave_broadcast_multidevice() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_direct_chat_is_hidden_and_contact_is_verified(
|
||||
t: &TestContext,
|
||||
contact: &TestContext,
|
||||
) {
|
||||
let contact = t.add_or_lookup_contact_no_key(contact).await;
|
||||
if let Some(direct_chat) = ChatIdBlocked::lookup_by_contact(t, contact.id)
|
||||
.await
|
||||
.unwrap()
|
||||
{
|
||||
assert_eq!(direct_chat.blocked, Blocked::Yes);
|
||||
}
|
||||
assert!(contact.is_verified(t).await.unwrap());
|
||||
}
|
||||
|
||||
/// Test that only the owner of the broadcast channel
|
||||
/// can send messages into the chat.
|
||||
///
|
||||
/// To do so, we change Alice's public key on Bob's side,
|
||||
/// so that she is supposed to appear as a new contact when we receive another message,
|
||||
/// and check that she can't write into the channel.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_only_broadcast_owner_can_send_1() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
tcm.section("Alice creates broadcast channel and creates a QR code.");
|
||||
let alice_chat_id = create_broadcast(alice, "foo".to_string()).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await.unwrap();
|
||||
|
||||
tcm.section("Bob now scans the QR code sends the request message");
|
||||
let bob_broadcast_id = join_securejoin(bob, &qr).await.unwrap();
|
||||
let request = bob.pop_sent_msg().await;
|
||||
alice.recv_msg_trash(&request).await;
|
||||
|
||||
tcm.section("Alice answers");
|
||||
let answer = alice.pop_sent_msg().await;
|
||||
|
||||
tcm.section("Change Alice's fingerprint for Bob, so that she is a different contact from Bob's point of view");
|
||||
let bob_alice_id = bob.add_or_lookup_contact_no_key(alice).await.id;
|
||||
bob.sql
|
||||
.execute(
|
||||
"UPDATE contacts
|
||||
SET fingerprint='1234567890123456789012345678901234567890'
|
||||
WHERE id=?",
|
||||
(bob_alice_id,),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tcm.section("Bob receives an answer, but it ignored because of a fingerprint mismatch");
|
||||
bob.recv_msg(&answer).await;
|
||||
assert!(
|
||||
load_broadcast_shared_secret(bob, bob_broadcast_id)
|
||||
.await?
|
||||
.is_none()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Same as the previous test, but Alice's fingerprint is changed later,
|
||||
/// so that we can check that until the fingerprint change, everything works fine.
|
||||
///
|
||||
/// Also, this changes Alice's fingerprint in Alice's database, rather than Bob's database,
|
||||
/// in order to test for the same thing in different ways.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_only_broadcast_owner_can_send_2() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &mut tcm.bob().await;
|
||||
|
||||
tcm.section("Alice creates broadcast channel and creates a QR code.");
|
||||
let alice_broadcast_id = create_broadcast(alice, "foo".to_string()).await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_broadcast_id))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
tcm.section("Bob now scans the QR code");
|
||||
let bob_broadcast_id = join_securejoin(bob, &qr).await.unwrap();
|
||||
let request = bob.pop_sent_msg().await;
|
||||
alice.recv_msg_trash(&request).await;
|
||||
let answer = alice.pop_sent_msg().await;
|
||||
|
||||
tcm.section("Bob receives an answer, and processes it");
|
||||
let rcvd = bob.recv_msg(&answer).await;
|
||||
assert!(
|
||||
load_broadcast_shared_secret(bob, bob_broadcast_id)
|
||||
.await?
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(rcvd.param.get_cmd(), SystemMessage::MemberAddedToGroup);
|
||||
|
||||
tcm.section("Alice sends a message, which still arrives fine");
|
||||
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.text, "Hi");
|
||||
|
||||
tcm.section("Now, Alice's fingerprint changes");
|
||||
|
||||
alice.sql.execute("DELETE FROM keypairs", ()).await?;
|
||||
alice
|
||||
.sql
|
||||
.execute("DELETE FROM config WHERE keyname='key_id'", ())
|
||||
.await?;
|
||||
// Invalidate cached self fingerprint:
|
||||
Arc::get_mut(&mut bob.ctx.inner)
|
||||
.unwrap()
|
||||
.self_fingerprint
|
||||
.take();
|
||||
|
||||
tcm.section("Alice sends a message, which doesn't arrive fine");
|
||||
let sent = alice.send_text(alice_broadcast_id, "Hi").await;
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert_eq!(
|
||||
rcvd.text,
|
||||
"[Error: This message was not sent by the channel owner]"
|
||||
);
|
||||
assert_eq!(
|
||||
rcvd.error.unwrap(),
|
||||
r#"Error: This message was not sent by the channel owner:
|
||||
"Hi""#
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_encrypt_decrypt_broadcast() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let bob_without_secret = &tcm.bob().await;
|
||||
|
||||
let secret = "secret";
|
||||
let grpid = "grpid";
|
||||
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
|
||||
tcm.section("Create a broadcast channel with Bob, and send a message");
|
||||
let alice_chat_id = create_out_broadcast_ex(
|
||||
alice,
|
||||
Sync,
|
||||
"My Channel".to_string(),
|
||||
grpid.to_string(),
|
||||
secret.to_string(),
|
||||
)
|
||||
.await?;
|
||||
add_to_chat_contacts_table(alice, time(), alice_chat_id, &[alice_bob_contact_id]).await?;
|
||||
|
||||
let bob_chat_id = ChatId::create_multiuser_record(
|
||||
bob,
|
||||
Chattype::InBroadcast,
|
||||
grpid,
|
||||
"My Channel",
|
||||
Blocked::Not,
|
||||
ProtectionStatus::Unprotected,
|
||||
None,
|
||||
time(),
|
||||
)
|
||||
.await?;
|
||||
save_broadcast_shared_secret(bob, bob_chat_id, secret).await?;
|
||||
|
||||
let sent = alice
|
||||
.send_text(alice_chat_id, "Symmetrically encrypted message")
|
||||
.await;
|
||||
let rcvd = bob.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.text, "Symmetrically encrypted message");
|
||||
|
||||
tcm.section("If Bob doesn't know the secret, he can't decrypt the message");
|
||||
bob_without_secret.recv_msg_trash(&sent).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_create_for_contact_with_blocked() -> Result<()> {
|
||||
let t = TestContext::new().await;
|
||||
@@ -3152,6 +3464,30 @@ async fn test_chat_get_encryption_info() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_out_failed_on_all_keys_missing() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let fiona = &tcm.fiona().await;
|
||||
|
||||
let bob_chat_id = bob
|
||||
.create_group_with_members(ProtectionStatus::Unprotected, "", &[alice, fiona])
|
||||
.await;
|
||||
bob.send_text(bob_chat_id, "Gossiping Fiona's key").await;
|
||||
alice
|
||||
.recv_msg(&bob.send_text(bob_chat_id, "No key gossip").await)
|
||||
.await;
|
||||
SystemTime::shift(Duration::from_secs(60));
|
||||
remove_contact_from_chat(bob, bob_chat_id, ContactId::SELF).await?;
|
||||
let alice_chat_id = alice.recv_msg(&bob.pop_sent_msg().await).await.chat_id;
|
||||
alice_chat_id.accept(alice).await?;
|
||||
let mut msg = Message::new_text("Hi".to_string());
|
||||
send_msg(alice, alice_chat_id, &mut msg).await.ok();
|
||||
assert_eq!(msg.id.get_state(alice).await?, MessageState::OutFailed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_get_chat_media() -> Result<()> {
|
||||
let t = TestContext::new_alice().await;
|
||||
@@ -3738,55 +4074,86 @@ async fn test_sync_muted() -> Result<()> {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_sync_broadcast() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice0 = &tcm.alice().await;
|
||||
let alice1 = &tcm.alice().await;
|
||||
for a in [alice0, alice1] {
|
||||
let alice2 = &tcm.alice().await;
|
||||
for a in [alice1, alice2] {
|
||||
a.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
}
|
||||
let bob = &tcm.bob().await;
|
||||
let a0b_contact_id = alice0.add_or_lookup_contact(bob).await.id;
|
||||
let a1b_contact_id = alice1.add_or_lookup_contact(bob).await.id;
|
||||
|
||||
let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?;
|
||||
sync(alice0, alice1).await;
|
||||
let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?;
|
||||
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)
|
||||
tcm.section("Alice creates a channel on her first device");
|
||||
let a1_broadcast_id = create_broadcast(alice1, "Channel".to_string()).await?;
|
||||
|
||||
tcm.section("The channel syncs to her second device");
|
||||
sync(alice1, alice2).await;
|
||||
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
|
||||
let a2_broadcast_id = get_chat_id_by_grpid(alice2, &a1_broadcast_chat.grpid)
|
||||
.await?
|
||||
.unwrap()
|
||||
.0;
|
||||
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
|
||||
assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast);
|
||||
assert_eq!(a1_broadcast_chat.get_name(), a0_broadcast_chat.get_name());
|
||||
assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty());
|
||||
add_contact_to_chat(alice0, a0_broadcast_id, a0b_contact_id).await?;
|
||||
sync(alice0, alice1).await;
|
||||
let a2_broadcast_chat = Chat::load_from_db(alice2, a2_broadcast_id).await?;
|
||||
assert_eq!(a2_broadcast_chat.get_type(), Chattype::OutBroadcast);
|
||||
assert_eq!(a2_broadcast_chat.get_name(), a1_broadcast_chat.get_name());
|
||||
assert!(get_chat_contacts(alice2, a2_broadcast_id).await?.is_empty());
|
||||
|
||||
// This also imports Bob's key from the vCard.
|
||||
// Otherwise it is possible that second device
|
||||
// does not have Bob's key as only the fingerprint
|
||||
// is transferred in the sync message.
|
||||
let a1b_contact_id = alice1.add_or_lookup_contact(bob).await.id;
|
||||
tcm.section("Bob scans Alice's QR code, both of Alice's devices answer");
|
||||
let qr = get_securejoin_qr(alice1, Some(a1_broadcast_id))
|
||||
.await
|
||||
.unwrap();
|
||||
sync(alice1, alice2).await; // Sync QR code
|
||||
let bob_broadcast_id = tcm
|
||||
.exec_securejoin_qr_multi_device(bob, &[alice1, alice2], &qr)
|
||||
.await;
|
||||
|
||||
let a2b_contact_id = alice2.add_or_lookup_contact_no_key(bob).await.id;
|
||||
assert_eq!(
|
||||
get_chat_contacts(alice1, a1_broadcast_id).await?,
|
||||
vec![a1b_contact_id]
|
||||
get_chat_contacts(alice2, a2_broadcast_id).await?,
|
||||
vec![a2b_contact_id]
|
||||
);
|
||||
let sent_msg = alice1.send_text(a1_broadcast_id, "hi").await;
|
||||
|
||||
tcm.section("Alice's second device sends a message to the channel");
|
||||
let sent_msg = alice2.send_text(a2_broadcast_id, "hi").await;
|
||||
let msg = bob.recv_msg(&sent_msg).await;
|
||||
let chat = Chat::load_from_db(bob, msg.chat_id).await?;
|
||||
assert_eq!(chat.get_type(), Chattype::InBroadcast);
|
||||
let msg = alice0.recv_msg(&sent_msg).await;
|
||||
assert_eq!(msg.chat_id, a0_broadcast_id);
|
||||
remove_contact_from_chat(alice0, a0_broadcast_id, a0b_contact_id).await?;
|
||||
sync(alice0, alice1).await;
|
||||
assert!(get_chat_contacts(alice1, a1_broadcast_id).await?.is_empty());
|
||||
let msg = alice1.recv_msg(&sent_msg).await;
|
||||
assert_eq!(msg.chat_id, a1_broadcast_id);
|
||||
|
||||
tcm.section("Alice's first device removes Bob");
|
||||
remove_contact_from_chat(alice1, a1_broadcast_id, a1b_contact_id).await?;
|
||||
let sent = alice1.pop_sent_msg().await;
|
||||
|
||||
tcm.section("Alice's second device receives the removal-message");
|
||||
alice2.recv_msg(&sent).await;
|
||||
assert!(get_chat_contacts(alice2, a2_broadcast_id).await?.is_empty());
|
||||
assert!(
|
||||
get_past_chat_contacts(alice1, a1_broadcast_id)
|
||||
get_past_chat_contacts(alice2, a2_broadcast_id)
|
||||
.await?
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
a0_broadcast_id.delete(alice0).await?;
|
||||
sync(alice0, alice1).await;
|
||||
alice1.assert_no_chat(a1_broadcast_id).await;
|
||||
tcm.section("Bob receives the removal-message");
|
||||
bob.recv_msg(&sent).await;
|
||||
let bob_chat = Chat::load_from_db(bob, bob_broadcast_id).await?;
|
||||
assert!(!bob_chat.is_self_in_chat(bob).await?);
|
||||
|
||||
bob.golden_test_chat(bob_broadcast_id, "test_sync_broadcast_bob")
|
||||
.await;
|
||||
|
||||
// Alice1 and Alice2 are supposed to show the chat in the same way:
|
||||
alice1
|
||||
.golden_test_chat(a1_broadcast_id, "test_sync_broadcast_alice1")
|
||||
.await;
|
||||
alice2
|
||||
.golden_test_chat(a2_broadcast_id, "test_sync_broadcast_alice2")
|
||||
.await;
|
||||
|
||||
tcm.section("Alice's first device deletes the chat");
|
||||
a1_broadcast_id.delete(alice1).await?;
|
||||
sync(alice1, alice2).await;
|
||||
alice2.assert_no_chat(a2_broadcast_id).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3800,12 +4167,25 @@ async fn test_sync_name() -> Result<()> {
|
||||
let a0_broadcast_id = create_broadcast(alice0, "Channel".to_string()).await?;
|
||||
sync(alice0, alice1).await;
|
||||
let a0_broadcast_chat = Chat::load_from_db(alice0, a0_broadcast_id).await?;
|
||||
|
||||
set_chat_name(alice0, a0_broadcast_id, "Broadcast channel 42").await?;
|
||||
sync(alice0, alice1).await;
|
||||
//sync(alice0, alice1).await; // crash
|
||||
|
||||
let sent = alice0.pop_sent_msg().await;
|
||||
let rcvd = alice1.recv_msg(&sent).await;
|
||||
assert_eq!(rcvd.from_id, ContactId::SELF);
|
||||
assert_eq!(rcvd.to_id, ContactId::SELF);
|
||||
assert_eq!(
|
||||
rcvd.text,
|
||||
"You changed group name from \"Channel\" to \"Broadcast channel 42\"."
|
||||
);
|
||||
assert_eq!(rcvd.param.get_cmd(), SystemMessage::GroupNameChanged);
|
||||
let a1_broadcast_id = get_chat_id_by_grpid(alice1, &a0_broadcast_chat.grpid)
|
||||
.await?
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(rcvd.chat_id, a1_broadcast_id);
|
||||
|
||||
let a1_broadcast_chat = Chat::load_from_db(alice1, a1_broadcast_id).await?;
|
||||
assert_eq!(a1_broadcast_chat.get_type(), Chattype::OutBroadcast);
|
||||
assert_eq!(a1_broadcast_chat.get_name(), "Broadcast channel 42");
|
||||
@@ -4742,6 +5122,16 @@ async fn test_create_unencrypted_group_chat() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_create_group_invalid_name() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let chat_id = create_group_ex(alice, None, " ").await?;
|
||||
let chat = Chat::load_from_db(alice, chat_id).await?;
|
||||
assert_eq!(chat.get_name(), "…");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that avatar cannot be set in ad hoc groups.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_no_avatar_in_adhoc_chats() -> Result<()> {
|
||||
@@ -4772,3 +5162,25 @@ async fn test_no_avatar_in_adhoc_chats() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that long group name with non-ASCII characters is correctly received
|
||||
/// by other members.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_long_group_name() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let group_name = "δδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδδ";
|
||||
let alice_chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, group_name).await?;
|
||||
let alice_bob_contact_id = alice.add_or_lookup_contact_id(bob).await;
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_bob_contact_id).await?;
|
||||
let sent = alice
|
||||
.send_text(alice_chat_id, "Hi! I created a group.")
|
||||
.await;
|
||||
let bob_chat_id = bob.recv_msg(&sent).await.chat_id;
|
||||
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
|
||||
assert_eq!(bob_chat.name, group_name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -488,6 +488,8 @@ mod tests {
|
||||
use crate::stock_str::StockMessage;
|
||||
use crate::test_utils::TestContext;
|
||||
use crate::test_utils::TestContextManager;
|
||||
use crate::tools::SystemTime;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_try_load() {
|
||||
@@ -510,6 +512,8 @@ mod tests {
|
||||
assert_eq!(chats.get_chat_id(1).unwrap(), chat_id2);
|
||||
assert_eq!(chats.get_chat_id(2).unwrap(), chat_id1);
|
||||
|
||||
SystemTime::shift(Duration::from_secs(5));
|
||||
|
||||
// New drafts are sorted to the top
|
||||
// We have to set a draft on the other two messages, too, as
|
||||
// chat timestamps are only exact to the second and sorting by timestamp
|
||||
|
||||
46
src/color.rs
46
src/color.rs
@@ -1,38 +1,39 @@
|
||||
//! Implementation of Consistent Color Generation.
|
||||
//! Color generation.
|
||||
//!
|
||||
//! Consistent Color Generation is defined in XEP-0392.
|
||||
//!
|
||||
//! Color Vision Deficiency correction is not implemented as Delta Chat does not offer
|
||||
//! corresponding settings.
|
||||
use hsluv::hsluv_to_rgb;
|
||||
//! This is similar to Consistent Color Generation defined in XEP-0392,
|
||||
//! but uses OKLCh colorspace instead of HSLuv
|
||||
//! to ensure that colors have the same lightness.
|
||||
use colorutils_rs::{Oklch, Rgb, TransferFunction};
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
/// Converts an identifier to Hue angle.
|
||||
fn str_to_angle(s: &str) -> f64 {
|
||||
fn str_to_angle(s: &str) -> f32 {
|
||||
let bytes = s.as_bytes();
|
||||
let result = Sha1::digest(bytes);
|
||||
let checksum: u16 = result.first().map_or(0, |&x| u16::from(x))
|
||||
+ 256 * result.get(1).map_or(0, |&x| u16::from(x));
|
||||
f64::from(checksum) / 65536.0 * 360.0
|
||||
f32::from(checksum) / 65536.0 * 360.0
|
||||
}
|
||||
|
||||
/// Converts RGB tuple to a 24-bit number.
|
||||
///
|
||||
/// Returns a 24-bit number with 8 least significant bits corresponding to the blue color and 8
|
||||
/// most significant bits corresponding to the red color.
|
||||
fn rgb_to_u32((r, g, b): (f64, f64, f64)) -> u32 {
|
||||
let r = ((r * 256.0) as u32).min(255);
|
||||
let g = ((g * 256.0) as u32).min(255);
|
||||
let b = ((b * 256.0) as u32).min(255);
|
||||
65536 * r + 256 * g + b
|
||||
fn rgb_to_u32(rgb: Rgb<u8>) -> u32 {
|
||||
65536 * u32::from(rgb.r) + 256 * u32::from(rgb.g) + u32::from(rgb.b)
|
||||
}
|
||||
|
||||
/// Converts an identifier to RGB color.
|
||||
///
|
||||
/// Saturation is set to maximum (100.0) to make colors distinguishable, and lightness is set to
|
||||
/// half (50.0) to make colors suitable both for light and dark theme.
|
||||
/// Lightness is set to half (0.5) to make colors suitable both for light and dark theme.
|
||||
pub fn str_to_color(s: &str) -> u32 {
|
||||
rgb_to_u32(hsluv_to_rgb((str_to_angle(s), 100.0, 50.0)))
|
||||
let lightness = 0.5;
|
||||
let chroma = 0.22;
|
||||
let angle = str_to_angle(s);
|
||||
let oklch = Oklch::new(lightness, chroma, angle);
|
||||
let rgb = oklch.to_rgb(TransferFunction::Srgb);
|
||||
|
||||
rgb_to_u32(rgb)
|
||||
}
|
||||
|
||||
/// Returns color as a "#RRGGBB" `String` where R, G, B are hex digits.
|
||||
@@ -45,6 +46,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::excessive_precision)]
|
||||
fn test_str_to_angle() {
|
||||
// Test against test vectors from
|
||||
// <https://xmpp.org/extensions/xep-0392.html#testvectors-fullrange-no-cvd>
|
||||
@@ -57,11 +59,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_rgb_to_u32() {
|
||||
assert_eq!(rgb_to_u32((0.0, 0.0, 0.0)), 0);
|
||||
assert_eq!(rgb_to_u32((1.0, 1.0, 1.0)), 0xffffff);
|
||||
assert_eq!(rgb_to_u32((0.0, 0.0, 1.0)), 0x0000ff);
|
||||
assert_eq!(rgb_to_u32((0.0, 1.0, 0.0)), 0x00ff00);
|
||||
assert_eq!(rgb_to_u32((1.0, 0.0, 0.0)), 0xff0000);
|
||||
assert_eq!(rgb_to_u32((1.0, 0.5, 0.0)), 0xff8000);
|
||||
assert_eq!(rgb_to_u32(Rgb::new(0, 0, 0)), 0);
|
||||
assert_eq!(rgb_to_u32(Rgb::new(0xff, 0xff, 0xff)), 0xffffff);
|
||||
assert_eq!(rgb_to_u32(Rgb::new(0, 0, 0xff)), 0x0000ff);
|
||||
assert_eq!(rgb_to_u32(Rgb::new(0, 0xff, 0)), 0x00ff00);
|
||||
assert_eq!(rgb_to_u32(Rgb::new(0xff, 0, 0)), 0xff0000);
|
||||
assert_eq!(rgb_to_u32(Rgb::new(0xff, 0x80, 0)), 0xff8000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,10 +151,6 @@ pub enum Config {
|
||||
/// setting up a second device, or receiving a sync message.
|
||||
BccSelf,
|
||||
|
||||
/// True if encryption is preferred according to Autocrypt standard.
|
||||
#[strum(props(default = "1"))]
|
||||
E2eeEnabled,
|
||||
|
||||
/// True if Message Delivery Notifications (read receipts) should
|
||||
/// be sent and requested.
|
||||
#[strum(props(default = "1"))]
|
||||
@@ -422,7 +418,7 @@ pub enum Config {
|
||||
/// Regardless of this setting, `chat.is_protected()` returns true while the key is verified,
|
||||
/// and when the key changes, an info message is posted into the chat.
|
||||
/// 0=Nothing else happens when the key changes.
|
||||
/// 1=After the key changed, `can_send()` returns false and `is_protection_broken()` returns true
|
||||
/// 1=After the key changed, `can_send()` returns false
|
||||
/// until `chat_id.accept()` is called.
|
||||
#[strum(props(default = "0"))]
|
||||
VerifiedOneOnOneChats,
|
||||
@@ -705,7 +701,6 @@ impl Context {
|
||||
Config::Socks5Enabled
|
||||
| Config::ProxyEnabled
|
||||
| Config::BccSelf
|
||||
| Config::E2eeEnabled
|
||||
| Config::MdnsEnabled
|
||||
| Config::SentboxWatch
|
||||
| Config::MvboxMove
|
||||
@@ -734,7 +729,7 @@ impl Context {
|
||||
Self::check_config(key, value)?;
|
||||
|
||||
let _pause = match key.needs_io_restart() {
|
||||
true => self.scheduler.pause(self.clone()).await?,
|
||||
true => self.scheduler.pause(self).await?,
|
||||
_ => Default::default(),
|
||||
};
|
||||
self.set_config_internal(key, value).await?;
|
||||
|
||||
@@ -95,10 +95,10 @@ pub const DC_GCL_ADDRESS: u32 = 0x04;
|
||||
pub(crate) const DC_RESEND_USER_AVATAR_DAYS: i64 = 14;
|
||||
|
||||
// warn about an outdated app after a given number of days.
|
||||
// as we use the "provider-db generation date" as reference (that might not be updated very often)
|
||||
// and as not all system get speedy updates,
|
||||
// reference is the release date.
|
||||
// as not all system get speedy updates,
|
||||
// do not use too small value that will annoy users checking for nonexistent updates.
|
||||
pub(crate) const DC_OUTDATED_WARNING_DAYS: i64 = 365;
|
||||
pub(crate) const DC_OUTDATED_WARNING_DAYS: i64 = 183;
|
||||
|
||||
/// messages that should be deleted get this chat_id; the messages are deleted from the working thread later then. This is also needed as rfc724_mid should be preset as long as the message is not deleted on the server (otherwise it is downloaded again)
|
||||
pub const DC_CHAT_ID_TRASH: ChatId = ChatId::new(3);
|
||||
|
||||
@@ -21,7 +21,7 @@ use tokio::task;
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
use crate::blob::BlobObject;
|
||||
use crate::chat::{ChatId, ChatIdBlocked, ProtectionStatus};
|
||||
use crate::chat::ChatId;
|
||||
use crate::color::str_to_color;
|
||||
use crate::config::Config;
|
||||
use crate::constants::{self, Blocked, Chattype};
|
||||
@@ -807,14 +807,28 @@ impl Contact {
|
||||
.query_get_value(
|
||||
"SELECT id FROM contacts
|
||||
WHERE addr=?1 COLLATE NOCASE
|
||||
AND id>?2 AND origin>=?3 AND (? OR blocked=?)
|
||||
ORDER BY last_seen DESC LIMIT 1",
|
||||
AND id>?2 AND origin>=?3 AND (? OR blocked=?)
|
||||
ORDER BY
|
||||
(
|
||||
SELECT COUNT(*) FROM chats c
|
||||
INNER JOIN chats_contacts cc
|
||||
ON c.id=cc.chat_id
|
||||
WHERE c.type=?
|
||||
AND c.id>?
|
||||
AND c.blocked=?
|
||||
AND cc.contact_id=contacts.id
|
||||
) DESC,
|
||||
last_seen DESC, fingerprint DESC
|
||||
LIMIT 1",
|
||||
(
|
||||
&addr_normalized,
|
||||
ContactId::LAST_SPECIAL,
|
||||
min_origin as u32,
|
||||
blocked.is_none(),
|
||||
blocked.unwrap_or_default(),
|
||||
blocked.unwrap_or(Blocked::Not),
|
||||
Chattype::Single,
|
||||
constants::DC_CHAT_ID_LAST_SPECIAL,
|
||||
blocked.unwrap_or(Blocked::Not),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
@@ -1550,7 +1564,7 @@ impl Contact {
|
||||
return Ok(Some(chat::get_device_icon(context).await?));
|
||||
}
|
||||
if show_fallback_icon && !self.id.is_special() && !self.is_key_contact() {
|
||||
return Ok(Some(chat::get_address_contact_icon(context).await?));
|
||||
return Ok(Some(chat::get_unencrypted_icon(context).await?));
|
||||
}
|
||||
if let Some(image_rel) = self.param.get(Param::ProfileImage) {
|
||||
if !image_rel.is_empty() {
|
||||
@@ -1561,11 +1575,16 @@ impl Contact {
|
||||
}
|
||||
|
||||
/// Get a color for the contact.
|
||||
/// The color is calculated from the contact's email address
|
||||
/// and can be used for an fallback avatar with white initials
|
||||
/// The color is calculated from the contact's fingerprint (for key-contacts)
|
||||
/// or email address (for address-contacts) and can be used
|
||||
/// for an fallback avatar with white initials
|
||||
/// as well as for headlines in bubbles of group chats.
|
||||
pub fn get_color(&self) -> u32 {
|
||||
str_to_color(&self.addr.to_lowercase())
|
||||
if let Some(fingerprint) = self.fingerprint() {
|
||||
str_to_color(&fingerprint.hex())
|
||||
} else {
|
||||
str_to_color(&self.addr.to_lowercase())
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the contact's status.
|
||||
@@ -1631,29 +1650,6 @@ impl Contact {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns if the contact profile title should display a green checkmark.
|
||||
///
|
||||
/// This generally should be consistent with the 1:1 chat with the contact
|
||||
/// so 1:1 chat with the contact and the contact profile
|
||||
/// either both display the green checkmark or both don't display a green checkmark.
|
||||
///
|
||||
/// UI often knows beforehand if a chat exists and can also call
|
||||
/// `chat.is_protected()` (if there is a chat)
|
||||
/// or `contact.is_verified()` (if there is no chat) directly.
|
||||
/// This is often easier and also skips some database calls.
|
||||
pub async fn is_profile_verified(&self, context: &Context) -> Result<bool> {
|
||||
let contact_id = self.id;
|
||||
|
||||
if let Some(ChatIdBlocked { id: chat_id, .. }) =
|
||||
ChatIdBlocked::lookup_by_contact(context, contact_id).await?
|
||||
{
|
||||
Ok(chat_id.is_protected(context).await? == ProtectionStatus::Protected)
|
||||
} else {
|
||||
// 1:1 chat does not exist.
|
||||
Ok(self.is_verified(context).await?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of real (i.e. non-special) contacts in the database.
|
||||
pub async fn get_real_cnt(context: &Context) -> Result<usize> {
|
||||
if !context.sql.is_open().await {
|
||||
@@ -1929,16 +1925,21 @@ pub(crate) async fn update_last_seen(
|
||||
}
|
||||
|
||||
/// Marks contact `contact_id` as verified by `verifier_id`.
|
||||
///
|
||||
/// `verifier_id == None` means that the verifier is unknown.
|
||||
pub(crate) async fn mark_contact_id_as_verified(
|
||||
context: &Context,
|
||||
contact_id: ContactId,
|
||||
verifier_id: ContactId,
|
||||
verifier_id: Option<ContactId>,
|
||||
) -> Result<()> {
|
||||
ensure_and_debug_assert_ne!(contact_id, ContactId::SELF,);
|
||||
ensure_and_debug_assert_ne!(
|
||||
contact_id,
|
||||
Some(contact_id),
|
||||
verifier_id,
|
||||
"Contact cannot be verified by self",
|
||||
);
|
||||
let by_self = verifier_id == Some(ContactId::SELF);
|
||||
let mut verifier_id = verifier_id.unwrap_or(contact_id);
|
||||
context
|
||||
.sql
|
||||
.transaction(|transaction| {
|
||||
@@ -1951,20 +1952,33 @@ pub(crate) async fn mark_contact_id_as_verified(
|
||||
bail!("Non-key-contact {contact_id} cannot be verified");
|
||||
}
|
||||
if verifier_id != ContactId::SELF {
|
||||
let verifier_fingerprint: String = transaction.query_row(
|
||||
"SELECT fingerprint FROM contacts WHERE id=?",
|
||||
(verifier_id,),
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let (verifier_fingerprint, verifier_verifier_id): (String, ContactId) = transaction
|
||||
.query_row(
|
||||
"SELECT fingerprint, verifier FROM contacts WHERE id=?",
|
||||
(verifier_id,),
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
if verifier_fingerprint.is_empty() {
|
||||
bail!(
|
||||
"Contact {contact_id} cannot be verified by non-key-contact {verifier_id}"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
verifier_id == contact_id || verifier_verifier_id != ContactId::UNDEFINED,
|
||||
"Contact {contact_id} cannot be verified by unverified contact {verifier_id}",
|
||||
);
|
||||
if verifier_verifier_id == verifier_id {
|
||||
// Avoid introducing incorrect reverse chains: if the verifier itself has an
|
||||
// unknown verifier, it may be `contact_id` actually (directly or indirectly) on
|
||||
// the other device (which is needed for getting "verified by unknown contact"
|
||||
// in the first place).
|
||||
verifier_id = contact_id;
|
||||
}
|
||||
}
|
||||
transaction.execute(
|
||||
"UPDATE contacts SET verifier=? WHERE id=?",
|
||||
(verifier_id, contact_id),
|
||||
"UPDATE contacts SET verifier=?1
|
||||
WHERE id=?2 AND (verifier=0 OR verifier=id OR ?3)",
|
||||
(verifier_id, contact_id, by_self),
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use deltachat_contact_tools::{addr_cmp, may_be_valid_addr};
|
||||
|
||||
use super::*;
|
||||
use crate::chat::{Chat, get_chat_contacts, send_text_msg};
|
||||
use crate::chat::{Chat, ProtectionStatus, get_chat_contacts, send_text_msg};
|
||||
use crate::chatlist::Chatlist;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::test_utils::{self, TestContext, TestContextManager, TimeShiftFalsePositiveNote};
|
||||
@@ -759,7 +759,7 @@ async fn test_contact_get_color() -> Result<()> {
|
||||
let t = TestContext::new().await;
|
||||
let contact_id = Contact::create(&t, "name", "name@example.net").await?;
|
||||
let color1 = Contact::get_by_id(&t, contact_id).await?.get_color();
|
||||
assert_eq!(color1, 0xA739FF);
|
||||
assert_eq!(color1, 0x4947dc);
|
||||
|
||||
let t = TestContext::new().await;
|
||||
let contact_id = Contact::create(&t, "prename name", "name@example.net").await?;
|
||||
@@ -1035,6 +1035,50 @@ async fn test_was_seen_recently_event() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn test_lookup_id_by_addr_recent_ex(accept_unencrypted_chat: bool) -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let raw = include_bytes!("../../test-data/message/thunderbird_with_autocrypt.eml");
|
||||
assert!(std::str::from_utf8(raw)?.contains("Date: Thu, 24 Nov 2022 20:05:57 +0100"));
|
||||
let received_msg = receive_imf(bob, raw, false).await?.unwrap();
|
||||
received_msg.chat_id.accept(bob).await?;
|
||||
|
||||
let raw = r#"From: Alice <alice@example.org>
|
||||
To: bob@example.net
|
||||
Message-ID: message$TIME@example.org
|
||||
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
|
||||
Date: Thu, 24 Nov 2022 $TIME +0100
|
||||
|
||||
Hi"#
|
||||
.to_string();
|
||||
for (time, is_key_contact) in [("20:05:57", true), ("20:05:58", !accept_unencrypted_chat)] {
|
||||
let raw = raw.replace("$TIME", time);
|
||||
let received_msg = receive_imf(bob, raw.as_bytes(), false).await?.unwrap();
|
||||
if accept_unencrypted_chat {
|
||||
received_msg.chat_id.accept(bob).await?;
|
||||
}
|
||||
let contact_id = Contact::lookup_id_by_addr(bob, "alice@example.org", Origin::Unknown)
|
||||
.await?
|
||||
.unwrap();
|
||||
let contact = Contact::get_by_id(bob, contact_id).await?;
|
||||
assert_eq!(contact.is_key_contact(), is_key_contact);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_lookup_id_by_addr_recent() -> Result<()> {
|
||||
let accept_unencrypted_chat = true;
|
||||
test_lookup_id_by_addr_recent_ex(accept_unencrypted_chat).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_lookup_id_by_addr_recent_accepted() -> Result<()> {
|
||||
let accept_unencrypted_chat = false;
|
||||
test_lookup_id_by_addr_recent_ex(accept_unencrypted_chat).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_verified_by_none() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
@@ -1258,7 +1302,6 @@ async fn test_self_is_verified() -> Result<()> {
|
||||
|
||||
let contact = Contact::get_by_id(&alice, ContactId::SELF).await?;
|
||||
assert_eq!(contact.is_verified(&alice).await?, true);
|
||||
assert!(contact.is_profile_verified(&alice).await?);
|
||||
assert!(contact.get_verifier_id(&alice).await?.is_none());
|
||||
assert!(contact.is_key_contact());
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ use crate::param::{Param, Params};
|
||||
use crate::peer_channels::Iroh;
|
||||
use crate::push::PushSubscriber;
|
||||
use crate::quota::QuotaInfo;
|
||||
use crate::scheduler::{SchedulerState, convert_folder_meaning};
|
||||
use crate::scheduler::{ConnectivityStore, SchedulerState, convert_folder_meaning};
|
||||
use crate::sql::Sql;
|
||||
use crate::stock_str::StockStrings;
|
||||
use crate::timesmearing::SmearedTimestamp;
|
||||
@@ -304,6 +304,10 @@ pub struct InnerContext {
|
||||
/// tokio::sync::OnceCell would be possible to use, but overkill for our usecase;
|
||||
/// the standard library's OnceLock is enough, and it's a lot smaller in memory.
|
||||
pub(crate) self_fingerprint: OnceLock<String>,
|
||||
|
||||
/// `Connectivity` values for mailboxes, unordered. Used to compute the aggregate connectivity,
|
||||
/// see [`Context::get_connectivity()`].
|
||||
pub(crate) connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
|
||||
}
|
||||
|
||||
/// The state of ongoing process.
|
||||
@@ -473,6 +477,7 @@ impl Context {
|
||||
push_subscribed: AtomicBool::new(false),
|
||||
iroh: Arc::new(RwLock::new(None)),
|
||||
self_fingerprint: OnceLock::new(),
|
||||
connectivities: parking_lot::Mutex::new(Vec::new()),
|
||||
};
|
||||
|
||||
let ctx = Context {
|
||||
@@ -502,7 +507,7 @@ impl Context {
|
||||
// Now, some configs may have changed, so, we need to invalidate the cache.
|
||||
self.sql.config_cache.write().await.clear();
|
||||
|
||||
self.scheduler.start(self.clone()).await;
|
||||
self.scheduler.start(self).await;
|
||||
}
|
||||
|
||||
/// Stops the IO scheduler.
|
||||
@@ -579,7 +584,7 @@ impl Context {
|
||||
} else {
|
||||
// Pause the scheduler to ensure another connection does not start
|
||||
// while we are fetching on a dedicated connection.
|
||||
let _pause_guard = self.scheduler.pause(self.clone()).await?;
|
||||
let _pause_guard = self.scheduler.pause(self).await?;
|
||||
|
||||
// Start a new dedicated connection.
|
||||
let mut connection = Imap::new_configured(self, channel::bounded(1).1).await?;
|
||||
@@ -828,7 +833,6 @@ impl Context {
|
||||
.query_get_value("PRAGMA journal_mode;", ())
|
||||
.await?
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let e2ee_enabled = self.get_config_int(Config::E2eeEnabled).await?;
|
||||
let mdns_enabled = self.get_config_int(Config::MdnsEnabled).await?;
|
||||
let bcc_self = self.get_config_int(Config::BccSelf).await?;
|
||||
let sync_msgs = self.get_config_int(Config::SyncMsgs).await?;
|
||||
@@ -962,7 +966,6 @@ impl Context {
|
||||
res.insert("configured_mvbox_folder", configured_mvbox_folder);
|
||||
res.insert("configured_trash_folder", configured_trash_folder);
|
||||
res.insert("mdns_enabled", mdns_enabled.to_string());
|
||||
res.insert("e2ee_enabled", e2ee_enabled.to_string());
|
||||
res.insert("bcc_self", bcc_self.to_string());
|
||||
res.insert("sync_msgs", sync_msgs.to_string());
|
||||
res.insert("disable_idle", disable_idle.to_string());
|
||||
@@ -1210,7 +1213,7 @@ impl Context {
|
||||
.await?
|
||||
.first()
|
||||
.context("Self reporting bot vCard does not contain a contact")?;
|
||||
mark_contact_id_as_verified(self, contact_id, ContactId::SELF).await?;
|
||||
mark_contact_id_as_verified(self, contact_id, Some(ContactId::SELF)).await?;
|
||||
|
||||
let chat_id = ChatId::create_for_contact(self, contact_id).await?;
|
||||
chat_id
|
||||
|
||||
@@ -10,17 +10,19 @@ use crate::pgp;
|
||||
|
||||
/// Tries to decrypt a message, but only if it is structured as an Autocrypt message.
|
||||
///
|
||||
/// If successful and the message is encrypted, returns decrypted body.
|
||||
/// If successful and the message was encrypted,
|
||||
/// returns the decrypted and decompressed message.
|
||||
pub fn try_decrypt<'a>(
|
||||
mail: &'a ParsedMail<'a>,
|
||||
private_keyring: &'a [SignedSecretKey],
|
||||
shared_secrets: &[String],
|
||||
) -> Result<Option<::pgp::composed::Message<'static>>> {
|
||||
let Some(encrypted_data_part) = get_encrypted_mime(mail) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let data = encrypted_data_part.get_body_raw()?;
|
||||
let msg = pgp::pk_decrypt(data, private_keyring)?;
|
||||
let msg = pgp::decrypt(data, private_keyring, shared_secrets)?;
|
||||
|
||||
Ok(Some(msg))
|
||||
}
|
||||
|
||||
37
src/e2ee.rs
37
src/e2ee.rs
@@ -4,10 +4,8 @@ use std::io::Cursor;
|
||||
|
||||
use anyhow::Result;
|
||||
use mail_builder::mime::MimePart;
|
||||
use num_traits::FromPrimitive;
|
||||
|
||||
use crate::aheader::{Aheader, EncryptPreference};
|
||||
use crate::config::Config;
|
||||
use crate::context::Context;
|
||||
use crate::key::{SignedPublicKey, load_self_public_key, load_self_secret_key};
|
||||
use crate::pgp;
|
||||
@@ -21,9 +19,7 @@ pub struct EncryptHelper {
|
||||
|
||||
impl EncryptHelper {
|
||||
pub async fn new(context: &Context) -> Result<EncryptHelper> {
|
||||
let prefer_encrypt =
|
||||
EncryptPreference::from_i32(context.get_config_int(Config::E2eeEnabled).await?)
|
||||
.unwrap_or_default();
|
||||
let prefer_encrypt = EncryptPreference::Mutual;
|
||||
let addr = context.get_primary_self_addr().await?;
|
||||
let public_key = load_self_public_key(context).await?;
|
||||
|
||||
@@ -35,9 +31,12 @@ impl EncryptHelper {
|
||||
}
|
||||
|
||||
pub fn get_aheader(&self) -> Aheader {
|
||||
let pk = self.public_key.clone();
|
||||
let addr = self.addr.to_string();
|
||||
Aheader::new(addr, pk, self.prefer_encrypt)
|
||||
Aheader {
|
||||
addr: self.addr.clone(),
|
||||
public_key: self.public_key.clone(),
|
||||
prefer_encrypt: self.prefer_encrypt,
|
||||
verified: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to encrypt the passed in `mail`.
|
||||
@@ -59,6 +58,28 @@ impl EncryptHelper {
|
||||
Ok(ctext)
|
||||
}
|
||||
|
||||
/// Symmetrically encrypt the message to be sent into a broadcast channel,
|
||||
/// or for version 2 of the Securejoin protocol.
|
||||
/// `shared secret` is the secret that will be used for symmetric encryption.
|
||||
pub async fn encrypt_symmetrically(
|
||||
self,
|
||||
context: &Context,
|
||||
shared_secret: &str,
|
||||
mail_to_encrypt: MimePart<'static>,
|
||||
compress: bool,
|
||||
) -> Result<String> {
|
||||
let sign_key = load_self_secret_key(context).await?;
|
||||
|
||||
let mut raw_message = Vec::new();
|
||||
let cursor = Cursor::new(&mut raw_message);
|
||||
mail_to_encrypt.clone().write_part(cursor).ok();
|
||||
|
||||
let ctext =
|
||||
pgp::symm_encrypt_message(raw_message, shared_secret, sign_key, compress).await?;
|
||||
|
||||
Ok(ctext)
|
||||
}
|
||||
|
||||
/// Signs the passed-in `mail` using the private key from `context`.
|
||||
/// Returns the payload and the signature.
|
||||
pub async fn sign(self, context: &Context, mail: &MimePart<'static>) -> Result<String> {
|
||||
|
||||
@@ -277,6 +277,7 @@ pub(crate) async fn stock_ephemeral_timer_changed(
|
||||
.await
|
||||
}
|
||||
604_800 => stock_str::msg_ephemeral_timer_week(context, from_id).await,
|
||||
31_536_000..=31_708_800 => stock_str::msg_ephemeral_timer_year(context, from_id).await,
|
||||
_ => {
|
||||
stock_str::msg_ephemeral_timer_weeks(
|
||||
context,
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::chat::ChatId;
|
||||
use crate::config::Config;
|
||||
use crate::constants::Chattype;
|
||||
use crate::contact::ContactId;
|
||||
use crate::ephemeral::Timer as EphemeralTimer;
|
||||
use crate::message::MsgId;
|
||||
@@ -272,9 +273,12 @@ pub enum EventType {
|
||||
/// ID of the contact that wants to join.
|
||||
contact_id: ContactId,
|
||||
|
||||
/// The type of the joined chat.
|
||||
chat_type: Chattype,
|
||||
|
||||
/// 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".
|
||||
/// 600=vg-/vc-request-with-auth received and verified, 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,
|
||||
@@ -376,6 +380,34 @@ pub enum EventType {
|
||||
/// This event is emitted from the account whose property changed.
|
||||
AccountsItemChanged,
|
||||
|
||||
/// Incoming call.
|
||||
IncomingCall {
|
||||
/// ID of the message referring to the call.
|
||||
msg_id: MsgId,
|
||||
/// User-defined info as passed to place_outgoing_call()
|
||||
place_call_info: String,
|
||||
},
|
||||
|
||||
/// Incoming call accepted.
|
||||
IncomingCallAccepted {
|
||||
/// ID of the message referring to the call.
|
||||
msg_id: MsgId,
|
||||
},
|
||||
|
||||
/// Outgoing call accepted.
|
||||
OutgoingCallAccepted {
|
||||
/// ID of the message referring to the call.
|
||||
msg_id: MsgId,
|
||||
/// User-defined info as passed to accept_incoming_call()
|
||||
accept_call_info: String,
|
||||
},
|
||||
|
||||
/// Call ended.
|
||||
CallEnded {
|
||||
/// ID of the message referring to the call.
|
||||
msg_id: MsgId,
|
||||
},
|
||||
|
||||
/// Event for using in tests, e.g. as a fence between normally generated events.
|
||||
#[cfg(test)]
|
||||
Test,
|
||||
|
||||
@@ -63,6 +63,7 @@ pub enum HeaderDef {
|
||||
ChatUserAvatar,
|
||||
ChatVoiceMessage,
|
||||
ChatGroupMemberRemoved,
|
||||
ChatGroupMemberRemovedFpr,
|
||||
ChatGroupMemberAdded,
|
||||
ChatContent,
|
||||
|
||||
@@ -86,6 +87,7 @@ pub enum HeaderDef {
|
||||
|
||||
ChatDispositionNotificationTo,
|
||||
ChatWebrtcRoom,
|
||||
ChatWebrtcAccepted,
|
||||
|
||||
/// This message deletes the messages listed in the value by rfc724_mid.
|
||||
ChatDelete,
|
||||
@@ -93,6 +95,11 @@ pub enum HeaderDef {
|
||||
/// This message obsoletes the text of the message defined here by rfc724_mid.
|
||||
ChatEdit,
|
||||
|
||||
/// The secret shared amongst all recipients of this broadcast channel,
|
||||
/// used to encrypt and decrypt messages.
|
||||
/// This secret is sent to a new member in the member-addition message.
|
||||
ChatBroadcastSecret,
|
||||
|
||||
/// [Autocrypt](https://autocrypt.org/) header.
|
||||
Autocrypt,
|
||||
AutocryptGossip,
|
||||
|
||||
@@ -325,7 +325,7 @@ impl Imap {
|
||||
}
|
||||
|
||||
info!(context, "Connecting to IMAP server.");
|
||||
self.connectivity.set_connecting(context).await;
|
||||
self.connectivity.set_connecting(context);
|
||||
|
||||
self.conn_last_try = tools::Time::now();
|
||||
const BACKOFF_MIN_MS: u64 = 2000;
|
||||
@@ -408,7 +408,7 @@ impl Imap {
|
||||
"IMAP-LOGIN as {}",
|
||||
lp.user
|
||||
)));
|
||||
self.connectivity.set_preparing(context).await;
|
||||
self.connectivity.set_preparing(context);
|
||||
info!(context, "Successfully logged into IMAP server.");
|
||||
return Ok(session);
|
||||
}
|
||||
@@ -466,7 +466,7 @@ impl Imap {
|
||||
let mut session = match self.connect(context, configuring).await {
|
||||
Ok(session) => session,
|
||||
Err(err) => {
|
||||
self.connectivity.set_err(context, &err).await;
|
||||
self.connectivity.set_err(context, &err);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
@@ -692,7 +692,7 @@ impl Imap {
|
||||
}
|
||||
|
||||
if !uids_fetch.is_empty() {
|
||||
self.connectivity.set_working(context).await;
|
||||
self.connectivity.set_working(context);
|
||||
}
|
||||
|
||||
let (sender, receiver) = async_channel::unbounded();
|
||||
|
||||
28
src/imex.rs
28
src/imex.rs
@@ -90,7 +90,7 @@ pub async fn imex(
|
||||
let cancel = context.alloc_ongoing().await?;
|
||||
|
||||
let res = {
|
||||
let _guard = context.scheduler.pause(context.clone()).await?;
|
||||
let _guard = context.scheduler.pause(context).await?;
|
||||
imex_inner(context, what, path, passphrase)
|
||||
.race(async {
|
||||
cancel.recv().await.ok();
|
||||
@@ -140,32 +140,8 @@ pub async fn has_backup(_context: &Context, dir_name: &Path) -> Result<String> {
|
||||
}
|
||||
|
||||
async fn set_self_key(context: &Context, armored: &str) -> Result<()> {
|
||||
// try hard to only modify key-state
|
||||
let (private_key, header) = SignedSecretKey::from_asc(armored)?;
|
||||
let private_key = SignedSecretKey::from_asc(armored)?;
|
||||
let public_key = private_key.split_public_key()?;
|
||||
if let Some(preferencrypt) = header.get("Autocrypt-Prefer-Encrypt") {
|
||||
let e2ee_enabled = match preferencrypt.as_str() {
|
||||
"nopreference" => 0,
|
||||
"mutual" => 1,
|
||||
_ => {
|
||||
bail!("invalid Autocrypt-Prefer-Encrypt header: {:?}", header);
|
||||
}
|
||||
};
|
||||
context
|
||||
.sql
|
||||
.set_raw_config_int("e2ee_enabled", e2ee_enabled)
|
||||
.await?;
|
||||
} else {
|
||||
// `Autocrypt-Prefer-Encrypt` is not included
|
||||
// in keys exported to file.
|
||||
//
|
||||
// `Autocrypt-Prefer-Encrypt` also SHOULD be sent
|
||||
// in Autocrypt Setup Message according to Autocrypt specification,
|
||||
// but K-9 6.802 does not include this header.
|
||||
//
|
||||
// We keep current setting in this case.
|
||||
info!(context, "No Autocrypt-Prefer-Encrypt header.");
|
||||
};
|
||||
|
||||
let keypair = pgp::KeyPair {
|
||||
public: public_key,
|
||||
|
||||
@@ -93,12 +93,9 @@ pub async fn render_setup_file(context: &Context, passphrase: &str) -> Result<St
|
||||
bail!("Passphrase must be at least 2 chars long.");
|
||||
};
|
||||
let private_key = load_self_secret_key(context).await?;
|
||||
let ac_headers = match context.get_config_bool(Config::E2eeEnabled).await? {
|
||||
false => None,
|
||||
true => Some(("Autocrypt-Prefer-Encrypt", "mutual")),
|
||||
};
|
||||
let ac_headers = Some(("Autocrypt-Prefer-Encrypt", "mutual"));
|
||||
let private_key_asc = private_key.to_asc(ac_headers);
|
||||
let encr = pgp::symm_encrypt(passphrase, private_key_asc.into_bytes())
|
||||
let encr = pgp::symm_encrypt_setup_file(passphrase, private_key_asc.into_bytes())
|
||||
.await?
|
||||
.replace('\n', "\r\n");
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ impl BackupProvider {
|
||||
|
||||
// Acquire global "ongoing" mutex.
|
||||
let cancel_token = context.alloc_ongoing().await?;
|
||||
let paused_guard = context.scheduler.pause(context.clone()).await?;
|
||||
let paused_guard = context.scheduler.pause(context).await?;
|
||||
let context_dir = context
|
||||
.get_blobdir()
|
||||
.parent()
|
||||
@@ -250,7 +250,7 @@ impl BackupProvider {
|
||||
Err(format_err!("Backup provider dropped"))
|
||||
}
|
||||
).await {
|
||||
warn!(context, "Error while handling backup connection: {err:#}.");
|
||||
error!(context, "Error while handling backup connection: {err:#}.");
|
||||
context.emit_event(EventType::ImexProgress(0));
|
||||
break;
|
||||
} else {
|
||||
@@ -367,7 +367,8 @@ pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> {
|
||||
Err(format_err!("Backup reception cancelled"))
|
||||
})
|
||||
.await;
|
||||
if res.is_err() {
|
||||
if let Err(ref res) = res {
|
||||
error!(context, "{:#}", res);
|
||||
context.emit_event(EventType::ImexProgress(0));
|
||||
}
|
||||
context.free_ongoing().await;
|
||||
|
||||
42
src/internals_for_benchmarks.rs
Normal file
42
src/internals_for_benchmarks.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! Re-exports of internal functions needed for benchmarks.
|
||||
#![allow(missing_docs)] // Not necessary to put a doc comment on the pub functions here
|
||||
|
||||
use anyhow::Result;
|
||||
use deltachat_contact_tools::EmailAddress;
|
||||
|
||||
use crate::chat::ChatId;
|
||||
use crate::context::Context;
|
||||
use crate::key;
|
||||
use crate::key::DcKey;
|
||||
use crate::mimeparser::MimeMessage;
|
||||
use crate::pgp;
|
||||
use crate::pgp::KeyPair;
|
||||
|
||||
pub fn key_from_asc(data: &str) -> Result<key::SignedSecretKey> {
|
||||
key::SignedSecretKey::from_asc(data)
|
||||
}
|
||||
|
||||
pub async fn store_self_keypair(context: &Context, keypair: &KeyPair) -> Result<()> {
|
||||
key::store_self_keypair(context, keypair).await
|
||||
}
|
||||
|
||||
pub async fn parse_and_get_text(context: &Context, imf_raw: &[u8]) -> Result<String> {
|
||||
let mime_parser = MimeMessage::from_bytes(context, imf_raw, None).await?;
|
||||
Ok(mime_parser.parts.into_iter().next().unwrap().msg)
|
||||
}
|
||||
|
||||
pub async fn save_broadcast_shared_secret(
|
||||
context: &Context,
|
||||
chat_id: ChatId,
|
||||
secret: &str,
|
||||
) -> Result<()> {
|
||||
crate::chat::save_broadcast_shared_secret(context, chat_id, secret).await
|
||||
}
|
||||
|
||||
pub fn create_dummy_keypair(addr: &str) -> Result<KeyPair> {
|
||||
pgp::create_keypair(EmailAddress::new(addr)?)
|
||||
}
|
||||
|
||||
pub fn create_broadcast_shared_secret() -> String {
|
||||
crate::tools::create_broadcast_shared_secret()
|
||||
}
|
||||
32
src/key.rs
32
src/key.rs
@@ -71,31 +71,17 @@ pub(crate) trait DcKey: Serialize + Deserializable + Clone {
|
||||
}
|
||||
|
||||
/// Create a key from an ASCII-armored string.
|
||||
///
|
||||
/// Returns the key and a map of any headers which might have been set in
|
||||
/// the ASCII-armored representation.
|
||||
fn from_asc(data: &str) -> Result<(Self, BTreeMap<String, String>)> {
|
||||
fn from_asc(data: &str) -> Result<Self> {
|
||||
let bytes = data.as_bytes();
|
||||
let res = Self::from_armor_single(Cursor::new(bytes));
|
||||
let (key, headers) = match res {
|
||||
let (key, _headers) = match res {
|
||||
Err(pgp::errors::Error::NoMatchingPacket { .. }) => match Self::is_private() {
|
||||
true => bail!("No private key packet found"),
|
||||
false => bail!("No public key packet found"),
|
||||
},
|
||||
_ => res.context("rPGP error")?,
|
||||
};
|
||||
let headers = headers
|
||||
.into_iter()
|
||||
.map(|(key, values)| {
|
||||
(
|
||||
key.trim().to_lowercase(),
|
||||
values
|
||||
.last()
|
||||
.map_or_else(String::new, |s| s.trim().to_string()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok((key, headers))
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Serialise the key as bytes.
|
||||
@@ -446,7 +432,7 @@ pub(crate) async fn store_self_keypair(context: &Context, keypair: &KeyPair) ->
|
||||
/// to avoid generating the key in tests.
|
||||
/// Use import/export APIs instead.
|
||||
pub async fn preconfigure_keypair(context: &Context, secret_data: &str) -> Result<()> {
|
||||
let secret = SignedSecretKey::from_asc(secret_data)?.0;
|
||||
let secret = SignedSecretKey::from_asc(secret_data)?;
|
||||
let public = secret.split_public_key()?;
|
||||
let keypair = KeyPair { public, secret };
|
||||
store_self_keypair(context, &keypair).await?;
|
||||
@@ -532,7 +518,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_from_armored_string() {
|
||||
let (private_key, _) = SignedSecretKey::from_asc(
|
||||
let private_key = SignedSecretKey::from_asc(
|
||||
"-----BEGIN PGP PRIVATE KEY BLOCK-----
|
||||
|
||||
xcLYBF0fgz4BCADnRUV52V4xhSsU56ZaAn3+3oG86MZhXy4X8w14WZZDf0VJGeTh
|
||||
@@ -600,17 +586,13 @@ i8pcjGO+IZffvyZJVRWfVooBJmWWbPB1pueo3tx8w3+fcuzpxz+RLFKaPyqXO+dD
|
||||
fn test_asc_roundtrip() {
|
||||
let key = KEYPAIR.public.clone();
|
||||
let asc = key.to_asc(Some(("spam", "ham")));
|
||||
let (key2, hdrs) = SignedPublicKey::from_asc(&asc).unwrap();
|
||||
let key2 = SignedPublicKey::from_asc(&asc).unwrap();
|
||||
assert_eq!(key, key2);
|
||||
assert_eq!(hdrs.len(), 1);
|
||||
assert_eq!(hdrs.get("spam"), Some(&String::from("ham")));
|
||||
|
||||
let key = KEYPAIR.secret.clone();
|
||||
let asc = key.to_asc(Some(("spam", "ham")));
|
||||
let (key2, hdrs) = SignedSecretKey::from_asc(&asc).unwrap();
|
||||
let key2 = SignedSecretKey::from_asc(&asc).unwrap();
|
||||
assert_eq!(key, key2);
|
||||
assert_eq!(hdrs.len(), 1);
|
||||
assert_eq!(hdrs.get("spam"), Some(&String::from("ham")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -53,6 +53,7 @@ pub use events::*;
|
||||
|
||||
mod aheader;
|
||||
pub mod blob;
|
||||
pub mod calls;
|
||||
pub mod chat;
|
||||
pub mod chatlist;
|
||||
pub mod config;
|
||||
@@ -74,7 +75,10 @@ mod mimefactory;
|
||||
pub mod mimeparser;
|
||||
pub mod oauth2;
|
||||
mod param;
|
||||
#[cfg(not(feature = "internals"))]
|
||||
mod pgp;
|
||||
#[cfg(feature = "internals")]
|
||||
pub mod pgp;
|
||||
pub mod provider;
|
||||
pub mod qr;
|
||||
pub mod qr_code_generator;
|
||||
@@ -108,6 +112,9 @@ pub mod accounts;
|
||||
pub mod peer_channels;
|
||||
pub mod reaction;
|
||||
|
||||
#[cfg(feature = "internals")]
|
||||
pub mod internals_for_benchmarks;
|
||||
|
||||
/// If set IMAP/incoming and SMTP/outgoing MIME messages will be printed.
|
||||
pub const DCC_MIME_DEBUG: &str = "DCC_MIME_DEBUG";
|
||||
|
||||
|
||||
@@ -973,6 +973,8 @@ impl Message {
|
||||
| SystemMessage::WebxdcStatusUpdate
|
||||
| SystemMessage::WebxdcInfoMessage
|
||||
| SystemMessage::IrohNodeAddr
|
||||
| SystemMessage::CallAccepted
|
||||
| SystemMessage::CallEnded
|
||||
| SystemMessage::Unknown => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -1366,17 +1368,6 @@ impl Message {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn update_subject(&self, context: &Context) -> Result<()> {
|
||||
context
|
||||
.sql
|
||||
.execute(
|
||||
"UPDATE msgs SET subject=? WHERE id=?;",
|
||||
(&self.subject, self.id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets the error status of the message.
|
||||
///
|
||||
/// A message can have an associated error status if something went wrong when sending or
|
||||
@@ -1392,6 +1383,19 @@ impl Message {
|
||||
pub fn error(&self) -> Option<String> {
|
||||
self.error.clone()
|
||||
}
|
||||
|
||||
/// Returns `true` if this message is a `vb-request-with-auth` SecureJoin message.
|
||||
pub(crate) fn is_vb_request_with_auth(&self) -> bool {
|
||||
if self.param.get_cmd() == SystemMessage::SecurejoinMessage {
|
||||
// CAVE: You can't check in the same way whether the message
|
||||
// is a `v{g|b}-member-added` message,
|
||||
// because for these messages,
|
||||
// `param.get_cmd()` returns `SystemMessage::MemberAddedToGroup`.
|
||||
self.param.get(Param::Arg) == Some("vb-request-with-auth")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the message.
|
||||
@@ -2287,6 +2291,9 @@ pub enum Viewtype {
|
||||
/// Message is an invitation to a videochat.
|
||||
VideochatInvitation = 70,
|
||||
|
||||
/// Message is an incoming or outgoing call.
|
||||
Call = 71,
|
||||
|
||||
/// Message is an webxdc instance.
|
||||
Webxdc = 80,
|
||||
|
||||
@@ -2310,6 +2317,7 @@ impl Viewtype {
|
||||
Viewtype::Video => true,
|
||||
Viewtype::File => true,
|
||||
Viewtype::VideochatInvitation => false,
|
||||
Viewtype::Call => false,
|
||||
Viewtype::Webxdc => true,
|
||||
Viewtype::Vcard => true,
|
||||
}
|
||||
|
||||
@@ -808,3 +808,22 @@ async fn test_sanitize_filename_message() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that empty file can be sent and received.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_send_empty_file() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_chat = alice.create_chat(bob).await;
|
||||
let mut msg = Message::new(Viewtype::File);
|
||||
msg.set_file_from_bytes(alice, "myfile", b"", None)?;
|
||||
chat::send_msg(alice, alice_chat.id, &mut msg).await?;
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
|
||||
let bob_received_msg = bob.recv_msg(&sent).await;
|
||||
assert_eq!(bob_received_msg.get_filename().unwrap(), "myfile");
|
||||
assert_eq!(bob_received_msg.get_viewtype(), Viewtype::File);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
use std::io::Cursor;
|
||||
|
||||
use anyhow::{Context as _, Result, bail, ensure};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use base64::Engine as _;
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use deltachat_contact_tools::sanitize_bidi_characters;
|
||||
@@ -15,7 +15,7 @@ use tokio::fs;
|
||||
|
||||
use crate::aheader::{Aheader, EncryptPreference};
|
||||
use crate::blob::BlobObject;
|
||||
use crate::chat::{self, Chat};
|
||||
use crate::chat::{self, Chat, load_broadcast_shared_secret};
|
||||
use crate::config::Config;
|
||||
use crate::constants::ASM_SUBJECT;
|
||||
use crate::constants::{Chattype, DC_FROM_HANDSHAKE};
|
||||
@@ -182,7 +182,7 @@ impl MimeFactory {
|
||||
let now = time();
|
||||
let chat = Chat::load_from_db(context, msg.chat_id).await?;
|
||||
let attach_profile_data = Self::should_attach_profile_data(&msg);
|
||||
let undisclosed_recipients = chat.typ == Chattype::OutBroadcast;
|
||||
let undisclosed_recipients = should_hide_recipients(&msg, &chat);
|
||||
|
||||
let from_addr = context.get_primary_self_addr().await?;
|
||||
let config_displayname = context
|
||||
@@ -329,7 +329,7 @@ impl MimeFactory {
|
||||
|
||||
if let Some(public_key) = public_key_opt {
|
||||
keys.push((addr.clone(), public_key))
|
||||
} else if id != ContactId::SELF {
|
||||
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
|
||||
missing_key_addresses.insert(addr.clone());
|
||||
if is_encrypted {
|
||||
warn!(context, "Missing key for {addr}");
|
||||
@@ -350,7 +350,7 @@ impl MimeFactory {
|
||||
|
||||
if let Some(public_key) = public_key_opt {
|
||||
keys.push((addr.clone(), public_key))
|
||||
} else if id != ContactId::SELF {
|
||||
} else if id != ContactId::SELF && !should_encrypt_symmetrically(&msg, &chat) {
|
||||
missing_key_addresses.insert(addr.clone());
|
||||
if is_encrypted {
|
||||
warn!(context, "Missing key for {addr}");
|
||||
@@ -415,8 +415,24 @@ impl MimeFactory {
|
||||
req_mdn = true;
|
||||
}
|
||||
|
||||
// If undisclosed_recipients, and this is a member-added/removed message,
|
||||
// only send to the added/removed member
|
||||
if undisclosed_recipients
|
||||
&& matches!(
|
||||
msg.param.get_cmd(),
|
||||
SystemMessage::MemberRemovedFromGroup | SystemMessage::MemberAddedToGroup
|
||||
)
|
||||
{
|
||||
if let Some(member) = msg.param.get(Param::Arg) {
|
||||
recipients.retain(|addr| addr == member);
|
||||
}
|
||||
}
|
||||
|
||||
encryption_keys = if !is_encrypted {
|
||||
None
|
||||
} else if should_encrypt_symmetrically(&msg, &chat) {
|
||||
// Encrypt, but only symmetrically, not with the public keys.
|
||||
Some(Vec::new())
|
||||
} else {
|
||||
if keys.is_empty() && !recipients.is_empty() {
|
||||
bail!(
|
||||
@@ -563,7 +579,13 @@ impl MimeFactory {
|
||||
// messages are auto-sent unlike usual unencrypted messages.
|
||||
step == "vg-request-with-auth"
|
||||
|| step == "vc-request-with-auth"
|
||||
|| step == "vb-request-with-auth"
|
||||
// Note that for "vg-member-added" and "vb-member-added",
|
||||
// get_cmd() returns `MemberAddedToGroup` rather than `SecurejoinMessage`,
|
||||
// so, it wouldn't actually be necessary to have them in the list here.
|
||||
// Still, they are here for completeness.
|
||||
|| step == "vg-member-added"
|
||||
|| step == "vb-member-added"
|
||||
|| step == "vc-contact-confirm"
|
||||
}
|
||||
}
|
||||
@@ -806,7 +828,7 @@ impl MimeFactory {
|
||||
} else if let Loaded::Message { msg, .. } = &self.loaded {
|
||||
if msg.param.get_cmd() == SystemMessage::SecurejoinMessage {
|
||||
let step = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
if step != "vg-request" && step != "vc-request" {
|
||||
if step != "vg-request" && step != "vc-request" && step != "vb-request-with-auth" {
|
||||
headers.push((
|
||||
"Auto-Submitted",
|
||||
mail_builder::headers::raw::Raw::new("auto-replied".to_string()).into(),
|
||||
@@ -815,7 +837,7 @@ impl MimeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
if let Loaded::Message { chat, .. } = &self.loaded {
|
||||
if let Loaded::Message { msg, chat } = &self.loaded {
|
||||
if chat.typ == Chattype::OutBroadcast || chat.typ == Chattype::InBroadcast {
|
||||
headers.push((
|
||||
"List-ID",
|
||||
@@ -825,6 +847,15 @@ impl MimeFactory {
|
||||
))
|
||||
.into(),
|
||||
));
|
||||
|
||||
if msg.param.get_cmd() == SystemMessage::MemberAddedToGroup {
|
||||
if let Some(secret) = msg.param.get(Param::Arg3) {
|
||||
headers.push((
|
||||
"Chat-Broadcast-Secret",
|
||||
mail_builder::headers::text::Text::new(secret.to_string()).into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1005,6 +1036,15 @@ impl MimeFactory {
|
||||
} else {
|
||||
unprotected_headers.push(header.clone());
|
||||
}
|
||||
} else if header_name == "chat-broadcast-secret" {
|
||||
if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
} else {
|
||||
warn!(
|
||||
context,
|
||||
"Message is unnecrypted, not including broadcast secret"
|
||||
);
|
||||
}
|
||||
} else if is_encrypted {
|
||||
protected_headers.push(header.clone());
|
||||
|
||||
@@ -1060,7 +1100,7 @@ impl MimeFactory {
|
||||
|
||||
match &self.loaded {
|
||||
Loaded::Message { chat, msg } => {
|
||||
if chat.typ != Chattype::OutBroadcast {
|
||||
if !should_hide_recipients(msg, chat) {
|
||||
for (addr, key) in &encryption_keys {
|
||||
let fingerprint = key.dc_fingerprint().hex();
|
||||
let cmd = msg.param.get_cmd();
|
||||
@@ -1092,13 +1132,14 @@ impl MimeFactory {
|
||||
continue;
|
||||
}
|
||||
|
||||
let header = Aheader::new(
|
||||
addr.clone(),
|
||||
key.clone(),
|
||||
let header = Aheader {
|
||||
addr: addr.clone(),
|
||||
public_key: key.clone(),
|
||||
// Autocrypt 1.1.0 specification says that
|
||||
// `prefer-encrypt` attribute SHOULD NOT be included.
|
||||
EncryptPreference::NoPreference,
|
||||
)
|
||||
prefer_encrypt: EncryptPreference::NoPreference,
|
||||
verified: false,
|
||||
}
|
||||
.to_string();
|
||||
|
||||
message = message.header(
|
||||
@@ -1143,18 +1184,48 @@ impl MimeFactory {
|
||||
Loaded::Mdn { .. } => true,
|
||||
};
|
||||
|
||||
// Encrypt to self unconditionally,
|
||||
// even for a single-device setup.
|
||||
let mut encryption_keyring = vec![encrypt_helper.public_key.clone()];
|
||||
encryption_keyring.extend(encryption_keys.iter().map(|(_addr, key)| (*key).clone()));
|
||||
let shared_secret: Option<String> = match &self.loaded {
|
||||
Loaded::Message { msg, .. } if should_encrypt_with_auth_token(msg) => {
|
||||
msg.param.get(Param::Arg2).map(|s| s.to_string())
|
||||
}
|
||||
Loaded::Message { chat, msg }
|
||||
if should_encrypt_with_broadcast_secret(msg, chat) =>
|
||||
{
|
||||
// If there is no shared secret yet
|
||||
// (because this is an old broadcast channel,
|
||||
// created before we had symmetric encryption),
|
||||
// we just encrypt asymmetrically.
|
||||
// Symmetric encryption exists since 2025-08;
|
||||
// some time after that, we can think about requiring everyone
|
||||
// to switch to symmetrically-encrypted broadcast lists.
|
||||
load_broadcast_shared_secret(context, chat.id).await?
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let encrypted = if let Some(shared_secret) = shared_secret {
|
||||
info!(context, "Encrypting symmetrically.");
|
||||
encrypt_helper
|
||||
.encrypt_symmetrically(context, &shared_secret, message, compress)
|
||||
.await?
|
||||
} else {
|
||||
// Asymmetric encryption
|
||||
|
||||
// Encrypt to self unconditionally,
|
||||
// even for a single-device setup.
|
||||
let mut encryption_keyring = vec![encrypt_helper.public_key.clone()];
|
||||
encryption_keyring
|
||||
.extend(encryption_keys.iter().map(|(_addr, key)| (*key).clone()));
|
||||
|
||||
encrypt_helper
|
||||
.encrypt(context, encryption_keyring, message, compress)
|
||||
.await?
|
||||
};
|
||||
|
||||
// XXX: additional newline is needed
|
||||
// to pass filtermail at
|
||||
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>
|
||||
let encrypted = encrypt_helper
|
||||
.encrypt(context, encryption_keyring, message, compress)
|
||||
.await?
|
||||
+ "\n";
|
||||
// <https://github.com/deltachat/chatmail/blob/4d915f9800435bf13057d41af8d708abd34dbfa8/chatmaild/src/chatmaild/filtermail.py#L84-L86>:
|
||||
let encrypted = encrypted + "\n";
|
||||
|
||||
// Set the appropriate Content-Type for the outer message
|
||||
MimePart::new(
|
||||
@@ -1363,8 +1434,8 @@ impl MimeFactory {
|
||||
|
||||
match command {
|
||||
SystemMessage::MemberRemovedFromGroup => {
|
||||
ensure!(chat.typ != Chattype::OutBroadcast);
|
||||
let email_to_remove = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
let fingerprint_to_remove = msg.param.get(Param::Arg2).unwrap_or_default();
|
||||
|
||||
if email_to_remove
|
||||
== context
|
||||
@@ -1385,9 +1456,16 @@ impl MimeFactory {
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
if !fingerprint_to_remove.is_empty() {
|
||||
headers.push((
|
||||
"Chat-Group-Member-Removed-Fpr",
|
||||
mail_builder::headers::raw::Raw::new(fingerprint_to_remove.to_string())
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
SystemMessage::MemberAddedToGroup => {
|
||||
ensure!(chat.typ != Chattype::OutBroadcast);
|
||||
// TODO: lookup the contact by ID rather than email address.
|
||||
// We are adding key-contacts, the cannot be looked up by address.
|
||||
let email_to_add = msg.param.get(Param::Arg).unwrap_or_default();
|
||||
@@ -1401,14 +1479,15 @@ impl MimeFactory {
|
||||
));
|
||||
}
|
||||
if 0 != msg.param.get_int(Param::Arg2).unwrap_or_default() & DC_FROM_HANDSHAKE {
|
||||
info!(
|
||||
context,
|
||||
"Sending secure-join message {:?}.", "vg-member-added",
|
||||
);
|
||||
let step = match chat.typ {
|
||||
Chattype::Group => "vg-member-added",
|
||||
Chattype::OutBroadcast => "vb-member-added",
|
||||
_ => bail!("Wrong chattype {}", chat.typ),
|
||||
};
|
||||
info!(context, "Sending secure-join message {:?}.", step,);
|
||||
headers.push((
|
||||
"Secure-Join",
|
||||
mail_builder::headers::raw::Raw::new("vg-member-added".to_string())
|
||||
.into(),
|
||||
mail_builder::headers::raw::Raw::new(step.to_string()).into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1484,7 +1563,10 @@ impl MimeFactory {
|
||||
let param2 = msg.param.get(Param::Arg2).unwrap_or_default();
|
||||
if !param2.is_empty() {
|
||||
headers.push((
|
||||
if step == "vg-request-with-auth" || step == "vc-request-with-auth" {
|
||||
if step == "vg-request-with-auth"
|
||||
|| step == "vc-request-with-auth"
|
||||
|| step == "vb-request-with-auth"
|
||||
{
|
||||
"Secure-Join-Auth"
|
||||
} else {
|
||||
"Secure-Join-Invitenumber"
|
||||
@@ -1533,6 +1615,18 @@ impl MimeFactory {
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
SystemMessage::CallAccepted => {
|
||||
headers.push((
|
||||
"Chat-Content",
|
||||
mail_builder::headers::raw::Raw::new("call-accepted").into(),
|
||||
));
|
||||
}
|
||||
SystemMessage::CallEnded => {
|
||||
headers.push((
|
||||
"Chat-Content",
|
||||
mail_builder::headers::raw::Raw::new("call-ended").into(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -1557,6 +1651,17 @@ impl MimeFactory {
|
||||
"Chat-Content",
|
||||
mail_builder::headers::raw::Raw::new("videochat-invitation").into(),
|
||||
));
|
||||
} else if msg.viewtype == Viewtype::Call {
|
||||
headers.push((
|
||||
"Chat-Content",
|
||||
mail_builder::headers::raw::Raw::new("call").into(),
|
||||
));
|
||||
placeholdertext = Some(
|
||||
"[This is a 'Call'. The sender uses an experiment not supported on your version yet]".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if msg.param.exists(Param::WebrtcRoom) {
|
||||
headers.push((
|
||||
"Chat-Webrtc-Room",
|
||||
mail_builder::headers::raw::Raw::new(
|
||||
@@ -1567,6 +1672,17 @@ impl MimeFactory {
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
} else if msg.param.exists(Param::WebrtcAccepted) {
|
||||
headers.push((
|
||||
"Chat-Webrtc-Accepted",
|
||||
mail_builder::headers::raw::Raw::new(
|
||||
msg.param
|
||||
.get(Param::WebrtcAccepted)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
if msg.viewtype == Viewtype::Voice
|
||||
@@ -1799,6 +1915,29 @@ fn hidden_recipients() -> Address<'static> {
|
||||
Address::new_group(Some("hidden-recipients".to_string()), Vec::new())
|
||||
}
|
||||
|
||||
fn should_encrypt_with_auth_token(msg: &Message) -> bool {
|
||||
msg.is_vb_request_with_auth()
|
||||
}
|
||||
|
||||
fn should_encrypt_with_broadcast_secret(msg: &Message, chat: &Chat) -> bool {
|
||||
chat.typ == Chattype::OutBroadcast
|
||||
// The only `SystemMessage::SecurejoinMessage` that is ever sent into a broadcast,
|
||||
// which is `vb-request-with-auth`,
|
||||
// should be encrypted with the AUTH token rather than the broadcast secret.
|
||||
&& msg.param.get_cmd() != SystemMessage::SecurejoinMessage
|
||||
// The member-added message in a broadcast must be asymmetrically encrypted,
|
||||
// because the newly-added member doesn't know the broadcast shared secret yet:
|
||||
&& msg.param.get_cmd() != SystemMessage::MemberAddedToGroup
|
||||
}
|
||||
|
||||
fn should_hide_recipients(msg: &Message, chat: &Chat) -> bool {
|
||||
should_encrypt_with_broadcast_secret(msg, chat)
|
||||
}
|
||||
|
||||
fn should_encrypt_symmetrically(msg: &Message, chat: &Chat) -> bool {
|
||||
should_encrypt_with_auth_token(msg) || should_encrypt_with_broadcast_secret(msg, chat)
|
||||
}
|
||||
|
||||
async fn build_body_file(context: &Context, msg: &Message) -> Result<MimePart<'static>> {
|
||||
let file_name = msg.get_filename().context("msg has no file")?;
|
||||
let blob = msg
|
||||
|
||||
@@ -91,8 +91,11 @@ fn test_render_rfc724_mid() {
|
||||
|
||||
fn render_header_text(text: &str) -> String {
|
||||
let mut output = Vec::<u8>::new();
|
||||
|
||||
// Some non-zero length of the header name.
|
||||
let bytes_written = 20;
|
||||
mail_builder::headers::text::Text::new(text.to_string())
|
||||
.write_header(&mut output, 0)
|
||||
.write_header(&mut output, bytes_written)
|
||||
.unwrap();
|
||||
|
||||
String::from_utf8(output).unwrap()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! # MIME message parsing module.
|
||||
|
||||
use std::cmp::min;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::str;
|
||||
use std::str::FromStr;
|
||||
@@ -18,7 +18,6 @@ use crate::authres::handle_authres;
|
||||
use crate::blob::BlobObject;
|
||||
use crate::chat::ChatId;
|
||||
use crate::config::Config;
|
||||
use crate::constants;
|
||||
use crate::contact::ContactId;
|
||||
use crate::context::Context;
|
||||
use crate::decrypt::{try_decrypt, validate_detached_signature};
|
||||
@@ -35,6 +34,18 @@ use crate::tools::{
|
||||
get_filemeta, parse_receive_headers, smeared_time, time, truncate_msg_text, validate_id,
|
||||
};
|
||||
use crate::{chatlist_events, location, stock_str, tools};
|
||||
use crate::{constants, token};
|
||||
|
||||
/// Public key extracted from `Autocrypt-Gossip`
|
||||
/// header with associated information.
|
||||
#[derive(Debug)]
|
||||
pub struct GossipedKey {
|
||||
/// Public key extracted from `keydata` attribute.
|
||||
pub public_key: SignedPublicKey,
|
||||
|
||||
/// True if `Autocrypt-Gossip` has a `_verified` attribute.
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
/// A parsed MIME message.
|
||||
///
|
||||
@@ -85,7 +96,7 @@ pub(crate) struct MimeMessage {
|
||||
|
||||
/// The addresses for which there was a gossip header
|
||||
/// and their respective gossiped keys.
|
||||
pub gossiped_keys: HashMap<String, SignedPublicKey>,
|
||||
pub gossiped_keys: BTreeMap<String, GossipedKey>,
|
||||
|
||||
/// Fingerprint of the key in the Autocrypt header.
|
||||
///
|
||||
@@ -216,6 +227,12 @@ pub enum SystemMessage {
|
||||
|
||||
/// "Messages are end-to-end encrypted."
|
||||
ChatE2ee = 50,
|
||||
|
||||
/// Message indicating that a call was accepted.
|
||||
CallAccepted = 66,
|
||||
|
||||
/// Message indicating that a call was ended.
|
||||
CallEnded = 67,
|
||||
}
|
||||
|
||||
const MIME_AC_SETUP_FILE: &str = "application/autocrypt-setup";
|
||||
@@ -338,9 +355,22 @@ impl MimeMessage {
|
||||
|
||||
let mail_raw; // Memory location for a possible decrypted message.
|
||||
let decrypted_msg; // Decrypted signed OpenPGP message.
|
||||
let mut secrets: Vec<String> = context
|
||||
.sql
|
||||
.query_map(
|
||||
"SELECT secret FROM broadcasts_shared_secrets",
|
||||
(),
|
||||
|row| row.get(0),
|
||||
|rows| {
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Into::into)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
secrets.extend(token::lookup_all(context, token::Namespace::Auth).await?);
|
||||
|
||||
let (mail, is_encrypted) =
|
||||
match tokio::task::block_in_place(|| try_decrypt(&mail, &private_keyring)) {
|
||||
match tokio::task::block_in_place(|| try_decrypt(&mail, &private_keyring, &secrets)) {
|
||||
Ok(Some(mut msg)) => {
|
||||
mail_raw = msg.as_data_vec().unwrap_or_default();
|
||||
|
||||
@@ -427,7 +457,7 @@ impl MimeMessage {
|
||||
None
|
||||
};
|
||||
|
||||
let public_keyring = if incoming {
|
||||
let mut public_keyring = if incoming {
|
||||
if let Some(autocrypt_header) = autocrypt_header {
|
||||
vec![autocrypt_header.public_key]
|
||||
} else {
|
||||
@@ -437,8 +467,46 @@ impl MimeMessage {
|
||||
key::load_self_public_keyring(context).await?
|
||||
};
|
||||
|
||||
if let Some(signature) = match &decrypted_msg {
|
||||
Some(pgp::composed::Message::Literal { .. }) => None,
|
||||
Some(pgp::composed::Message::Compressed { .. }) => {
|
||||
// One layer of compression should already be handled by now.
|
||||
// We don't decompress messages compressed multiple times.
|
||||
None
|
||||
}
|
||||
Some(pgp::composed::Message::SignedOnePass { reader, .. }) => reader.signature(),
|
||||
Some(pgp::composed::Message::Signed { reader, .. }) => Some(reader.signature()),
|
||||
Some(pgp::composed::Message::Encrypted { .. }) => {
|
||||
// The message is already decrypted once.
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
} {
|
||||
for issuer_fingerprint in signature.issuer_fingerprint() {
|
||||
let issuer_fingerprint =
|
||||
crate::key::Fingerprint::from(issuer_fingerprint.clone()).hex();
|
||||
if let Some(public_key_bytes) = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT public_key
|
||||
FROM public_keys
|
||||
WHERE fingerprint=?",
|
||||
(&issuer_fingerprint,),
|
||||
|row| {
|
||||
let bytes: Vec<u8> = row.get(0)?;
|
||||
Ok(bytes)
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
|
||||
public_keyring.push(public_key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut signatures = if let Some(ref decrypted_msg) = decrypted_msg {
|
||||
crate::pgp::valid_signature_fingerprints(decrypted_msg, &public_keyring)?
|
||||
crate::pgp::valid_signature_fingerprints(decrypted_msg, &public_keyring)
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
@@ -638,6 +706,10 @@ impl MimeMessage {
|
||||
self.is_system_message = SystemMessage::ChatProtectionDisabled;
|
||||
} else if value == "group-avatar-changed" {
|
||||
self.is_system_message = SystemMessage::GroupImageChanged;
|
||||
} else if value == "call-accepted" {
|
||||
self.is_system_message = SystemMessage::CallAccepted;
|
||||
} else if value == "call-ended" {
|
||||
self.is_system_message = SystemMessage::CallEnded;
|
||||
}
|
||||
} else if self.get_header(HeaderDef::ChatGroupMemberRemoved).is_some() {
|
||||
self.is_system_message = SystemMessage::MemberRemovedFromGroup;
|
||||
@@ -660,16 +732,26 @@ impl MimeMessage {
|
||||
}
|
||||
|
||||
fn parse_videochat_headers(&mut self) {
|
||||
if let Some(value) = self.get_header(HeaderDef::ChatContent) {
|
||||
if value == "videochat-invitation" {
|
||||
let instance = self
|
||||
.get_header(HeaderDef::ChatWebrtcRoom)
|
||||
.map(|s| s.to_string());
|
||||
if let Some(part) = self.parts.first_mut() {
|
||||
let content = self
|
||||
.get_header(HeaderDef::ChatContent)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let room = self
|
||||
.get_header(HeaderDef::ChatWebrtcRoom)
|
||||
.map(|s| s.to_string());
|
||||
let accepted = self
|
||||
.get_header(HeaderDef::ChatWebrtcAccepted)
|
||||
.map(|s| s.to_string());
|
||||
if let Some(part) = self.parts.first_mut() {
|
||||
if let Some(room) = room {
|
||||
if content == "videochat-invitation" {
|
||||
part.typ = Viewtype::VideochatInvitation;
|
||||
part.param
|
||||
.set(Param::WebrtcRoom, instance.unwrap_or_default());
|
||||
} else if content == "call" {
|
||||
part.typ = Viewtype::Call
|
||||
}
|
||||
part.param.set(Param::WebrtcRoom, room);
|
||||
} else if let Some(accepted) = accepted {
|
||||
part.param.set(Param::WebrtcAccepted, accepted);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -695,7 +777,10 @@ impl MimeMessage {
|
||||
| Viewtype::Vcard
|
||||
| Viewtype::File
|
||||
| Viewtype::Webxdc => true,
|
||||
Viewtype::Unknown | Viewtype::Text | Viewtype::VideochatInvitation => false,
|
||||
Viewtype::Unknown
|
||||
| Viewtype::Text
|
||||
| Viewtype::VideochatInvitation
|
||||
| Viewtype::Call => false,
|
||||
})
|
||||
{
|
||||
let mut parts = std::mem::take(&mut self.parts);
|
||||
@@ -1322,10 +1407,6 @@ impl MimeMessage {
|
||||
filename: &str,
|
||||
is_related: bool,
|
||||
) -> Result<()> {
|
||||
if decoded_data.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Process attached PGP keys.
|
||||
if mime_type.type_() == mime::APPLICATION
|
||||
&& mime_type.subtype().as_str() == "pgp-keys"
|
||||
@@ -1451,7 +1532,7 @@ impl MimeMessage {
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
Ok((key, _)) => key,
|
||||
Ok(key) => key,
|
||||
};
|
||||
if let Err(err) = key.verify() {
|
||||
warn!(context, "Attached PGP key verification failed: {err:#}.");
|
||||
@@ -1514,6 +1595,13 @@ impl MimeMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a message is a call.
|
||||
pub(crate) fn is_call(&self) -> bool {
|
||||
self.parts
|
||||
.first()
|
||||
.is_some_and(|part| part.typ == Viewtype::Call)
|
||||
}
|
||||
|
||||
pub fn replace_msg_by_error(&mut self, error_msg: &str) {
|
||||
self.is_system_message = SystemMessage::Unknown;
|
||||
if let Some(part) = self.parts.first_mut() {
|
||||
@@ -1908,9 +1996,9 @@ async fn parse_gossip_headers(
|
||||
from: &str,
|
||||
recipients: &[SingleInfo],
|
||||
gossip_headers: Vec<String>,
|
||||
) -> Result<HashMap<String, SignedPublicKey>> {
|
||||
) -> Result<BTreeMap<String, GossipedKey>> {
|
||||
// XXX split the parsing from the modification part
|
||||
let mut gossiped_keys: HashMap<String, SignedPublicKey> = Default::default();
|
||||
let mut gossiped_keys: BTreeMap<String, GossipedKey> = Default::default();
|
||||
|
||||
for value in &gossip_headers {
|
||||
let header = match value.parse::<Aheader>() {
|
||||
@@ -1952,7 +2040,12 @@ async fn parse_gossip_headers(
|
||||
)
|
||||
.await?;
|
||||
|
||||
gossiped_keys.insert(header.addr.to_lowercase(), header.public_key);
|
||||
let gossiped_key = GossipedKey {
|
||||
public_key: header.public_key,
|
||||
|
||||
verified: header.verified,
|
||||
};
|
||||
gossiped_keys.insert(header.addr.to_lowercase(), gossiped_key);
|
||||
}
|
||||
|
||||
Ok(gossiped_keys)
|
||||
|
||||
@@ -227,9 +227,6 @@ pub(crate) async fn update_connect_timestamp(
|
||||
}
|
||||
|
||||
/// Preloaded DNS results that can be used in case of DNS server failures.
|
||||
///
|
||||
/// See <https://support.delta.chat/t/no-dns-resolution-result/2778> and
|
||||
/// <https://github.com/deltachat/deltachat-core-rust/issues/4920> for reasons.
|
||||
static DNS_PRELOAD: LazyLock<HashMap<&'static str, Vec<IpAddr>>> = LazyLock::new(|| {
|
||||
HashMap::from([
|
||||
(
|
||||
|
||||
@@ -244,7 +244,7 @@ async fn fetch_url(context: &Context, original_url: &str) -> Result<Response> {
|
||||
.clone();
|
||||
|
||||
let req = hyper::Request::builder()
|
||||
.uri(parsed_url.path())
|
||||
.uri(parsed_url)
|
||||
.header(hyper::header::HOST, authority.as_str())
|
||||
.body(http_body_util::Empty::<Bytes>::new())?;
|
||||
let response = sender.send_request(req).await?;
|
||||
@@ -378,7 +378,7 @@ pub(crate) async fn post_string(context: &Context, url: &str, body: String) -> R
|
||||
.context("URL has no authority")?
|
||||
.clone();
|
||||
|
||||
let request = hyper::Request::post(parsed_url.path())
|
||||
let request = hyper::Request::post(parsed_url)
|
||||
.header(hyper::header::HOST, authority.as_str())
|
||||
.body(body)?;
|
||||
let response = sender.send_request(request).await?;
|
||||
@@ -408,7 +408,7 @@ pub(crate) async fn post_form<T: Serialize + ?Sized>(
|
||||
.authority()
|
||||
.context("URL has no authority")?
|
||||
.clone();
|
||||
let request = hyper::Request::post(parsed_url.path())
|
||||
let request = hyper::Request::post(parsed_url)
|
||||
.header(hyper::header::HOST, authority.as_str())
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(encoded_body)?;
|
||||
|
||||
34
src/param.rs
34
src/param.rs
@@ -99,19 +99,42 @@ pub enum Param {
|
||||
|
||||
/// For Messages
|
||||
///
|
||||
/// For "MemberRemovedFromGroup" this is the email address
|
||||
/// For "MemberRemovedFromGroup", this is the email address
|
||||
/// removed from the group.
|
||||
///
|
||||
/// For "MemberAddedToGroup" this is the email address added to the group.
|
||||
/// For "MemberAddedToGroup", this is the email address added to the group.
|
||||
///
|
||||
/// For securejoin messages, this is the step,
|
||||
/// which is put into the `Secure-Join` header.
|
||||
Arg = b'E',
|
||||
|
||||
/// For Messages
|
||||
///
|
||||
/// For `BobHandshakeMsg::Request`, this is the `Secure-Join-Invitenumber` header.
|
||||
///
|
||||
/// For `BobHandshakeMsg::RequestWithAuth`, this is the `Secure-Join-Auth` header.
|
||||
///
|
||||
/// For version two of the securejoin protocol (`vb-request-with-auth`),
|
||||
/// this is the Auth token used to encrypt the message.
|
||||
///
|
||||
/// For [`SystemMessage::MultiDeviceSync`], this contains the ids that are synced.
|
||||
///
|
||||
/// For [`SystemMessage::MemberAddedToGroup`],
|
||||
/// this is '1' if it was added because of a securejoin-handshake, and '0' otherwise.
|
||||
Arg2 = b'F',
|
||||
|
||||
/// `Secure-Join-Fingerprint` header for `{vc,vg}-request-with-auth` messages.
|
||||
/// For Messages
|
||||
///
|
||||
/// For `BobHandshakeMsg::RequestWithAuth`,
|
||||
/// this contains the `Secure-Join-Fingerprint` header.
|
||||
///
|
||||
/// For [`SystemMessage::MemberAddedToGroup`] that add to a broadcast channel,
|
||||
/// this contains the broadcast channel's shared secret.
|
||||
Arg3 = b'G',
|
||||
|
||||
/// Deprecated `Secure-Join-Group` header for messages.
|
||||
/// For Messages
|
||||
///
|
||||
/// Deprecated `Secure-Join-Group` header for `BobHandshakeMsg::RequestWithAuth` messages.
|
||||
Arg4 = b'H',
|
||||
|
||||
/// For Messages
|
||||
@@ -120,6 +143,9 @@ pub enum Param {
|
||||
/// For Messages
|
||||
WebrtcRoom = b'V',
|
||||
|
||||
/// For Messages
|
||||
WebrtcAccepted = b'7',
|
||||
|
||||
/// For Messages: space-separated list of messaged IDs of forwarded copies.
|
||||
///
|
||||
/// This is used when a [crate::message::Message] is in the
|
||||
|
||||
240
src/pgp.rs
240
src/pgp.rs
@@ -3,7 +3,7 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::io::{BufRead, Cursor};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use chrono::SubsecRound;
|
||||
use deltachat_contact_tools::EmailAddress;
|
||||
use pgp::armor::BlockType;
|
||||
@@ -12,12 +12,13 @@ use pgp::composed::{
|
||||
SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey, SignedSecretKey,
|
||||
StandaloneSignature, SubkeyParamsBuilder, TheRing,
|
||||
};
|
||||
use pgp::crypto::aead::{AeadAlgorithm, ChunkSize};
|
||||
use pgp::crypto::ecc_curve::ECCCurve;
|
||||
use pgp::crypto::hash::HashAlgorithm;
|
||||
use pgp::crypto::sym::SymmetricKeyAlgorithm;
|
||||
use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
|
||||
use pgp::types::{CompressionAlgorithm, KeyDetails, Password, PublicKeyTrait, StringToKey};
|
||||
use rand::thread_rng;
|
||||
use rand::{Rng as _, thread_rng};
|
||||
use tokio::runtime::Handle;
|
||||
|
||||
use crate::key::{DcKey, Fingerprint};
|
||||
@@ -25,7 +26,7 @@ use crate::key::{DcKey, Fingerprint};
|
||||
#[cfg(test)]
|
||||
pub(crate) const HEADER_AUTOCRYPT: &str = "autocrypt-prefer-encrypt";
|
||||
|
||||
pub const HEADER_SETUPCODE: &str = "passphrase-begin";
|
||||
pub(crate) const HEADER_SETUPCODE: &str = "passphrase-begin";
|
||||
|
||||
/// Preferred symmetric encryption algorithm.
|
||||
const SYMMETRIC_KEY_ALGORITHM: SymmetricKeyAlgorithm = SymmetricKeyAlgorithm::AES128;
|
||||
@@ -231,13 +232,17 @@ pub fn pk_calc_signature(
|
||||
Ok(sig.to_armored_string(ArmorOptions::default())?)
|
||||
}
|
||||
|
||||
/// Decrypts the message with keys from the private key keyring.
|
||||
/// Decrypts the message:
|
||||
/// - with keys from the private key keyring (passed in `private_keys_for_decryption`)
|
||||
/// if the message was asymmetrically encrypted,
|
||||
/// - with a shared secret/password (passed in `shared_secrets`),
|
||||
/// if the message was symmetrically encrypted.
|
||||
///
|
||||
/// Receiver private keys are provided in
|
||||
/// `private_keys_for_decryption`.
|
||||
pub fn pk_decrypt(
|
||||
/// Returns the decrypted and decompressed message.
|
||||
pub fn decrypt(
|
||||
ctext: Vec<u8>,
|
||||
private_keys_for_decryption: &[SignedSecretKey],
|
||||
mut shared_secrets: &[String],
|
||||
) -> Result<pgp::composed::Message<'static>> {
|
||||
let cursor = Cursor::new(ctext);
|
||||
let (msg, _headers) = Message::from_armor(cursor)?;
|
||||
@@ -245,18 +250,43 @@ pub fn pk_decrypt(
|
||||
let skeys: Vec<&SignedSecretKey> = private_keys_for_decryption.iter().collect();
|
||||
let empty_pw = Password::empty();
|
||||
|
||||
let try_symmetric_decryption = should_try_symmetric_decryption(&msg);
|
||||
if try_symmetric_decryption.is_err() {
|
||||
shared_secrets = &[];
|
||||
}
|
||||
|
||||
// We always try out all passwords here, which is not great for performance.
|
||||
// But benchmarking (see `benchmark_decrypting.rs`)
|
||||
// showed that the performance penalty is acceptable.
|
||||
// We could include a short (~2 character) identifier of the secret in cleartext
|
||||
// (or just include the first 2 characters of the secret in cleartext)
|
||||
// in order to narrow down the number of shared secrets that have to be tried out.
|
||||
let message_password: Vec<Password> = shared_secrets
|
||||
.iter()
|
||||
.map(|p| Password::from(p.as_str()))
|
||||
.collect();
|
||||
let message_password: Vec<&Password> = message_password.iter().collect();
|
||||
|
||||
let ring = TheRing {
|
||||
secret_keys: skeys,
|
||||
key_passwords: vec![&empty_pw],
|
||||
message_password: vec![],
|
||||
message_password,
|
||||
session_keys: vec![],
|
||||
allow_legacy: false,
|
||||
};
|
||||
let (msg, ring_result) = msg.decrypt_the_ring(ring, true)?;
|
||||
anyhow::ensure!(
|
||||
!ring_result.secret_keys.is_empty(),
|
||||
"decryption failed, no matching secret keys"
|
||||
);
|
||||
|
||||
let res = msg.decrypt_the_ring(ring, true);
|
||||
|
||||
let (msg, _ring_result) = match res {
|
||||
Ok(it) => it,
|
||||
Err(err) => {
|
||||
if let Err(reason) = try_symmetric_decryption {
|
||||
bail!("{err:#} (Note: symmetric decryption was not tried: {reason})")
|
||||
} else {
|
||||
bail!("{err:#}");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// remove one layer of compression
|
||||
let msg = msg.decompress()?;
|
||||
@@ -264,6 +294,34 @@ pub fn pk_decrypt(
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Returns Ok(()) if we want to try symmetrically decrypting the message,
|
||||
/// and Err with a reason if symmetric decryption should not be tried.
|
||||
///
|
||||
/// A DOS attacker could send a message with a lot of encrypted session keys,
|
||||
/// all of which use a very hard-to-compute string2key algorithm.
|
||||
/// We would then try to decrypt all of the encrypted session keys
|
||||
/// with all of the known shared secrets.
|
||||
/// In order to prevent this, we do not try to symmetrically decrypt messages
|
||||
/// that use a string2key algorithm other than 'Salted'.
|
||||
fn should_try_symmetric_decryption(msg: &Message<'_>) -> std::result::Result<(), &'static str> {
|
||||
let Message::Encrypted { esk, .. } = msg else {
|
||||
return Err("not encrypted");
|
||||
};
|
||||
|
||||
if esk.len() > 1 {
|
||||
return Err("too many esks");
|
||||
}
|
||||
|
||||
let [pgp::composed::Esk::SymKeyEncryptedSessionKey(esk)] = &esk[..] else {
|
||||
return Err("not symmetrically encrypted");
|
||||
};
|
||||
|
||||
match esk.s2k() {
|
||||
Some(StringToKey::Salted { .. }) => Ok(()),
|
||||
_ => Err("unsupported string2key algorithm"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns fingerprints
|
||||
/// of all keys from the `public_keys_for_validation` keyring that
|
||||
/// have valid signatures there.
|
||||
@@ -272,7 +330,7 @@ pub fn pk_decrypt(
|
||||
pub fn valid_signature_fingerprints(
|
||||
msg: &pgp::composed::Message,
|
||||
public_keys_for_validation: &[SignedPublicKey],
|
||||
) -> Result<HashSet<Fingerprint>> {
|
||||
) -> HashSet<Fingerprint> {
|
||||
let mut ret_signature_fingerprints: HashSet<Fingerprint> = Default::default();
|
||||
if msg.is_signed() {
|
||||
for pkey in public_keys_for_validation {
|
||||
@@ -282,7 +340,7 @@ pub fn valid_signature_fingerprints(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ret_signature_fingerprints)
|
||||
ret_signature_fingerprints
|
||||
}
|
||||
|
||||
/// Validates detached signature.
|
||||
@@ -304,8 +362,8 @@ pub fn pk_validate(
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
/// Symmetric encryption.
|
||||
pub async fn symm_encrypt(passphrase: &str, plain: Vec<u8>) -> Result<String> {
|
||||
/// Symmetric encryption for the autocrypt setup file.
|
||||
pub async fn symm_encrypt_setup_file(passphrase: &str, plain: Vec<u8>) -> Result<String> {
|
||||
let passphrase = Password::from(passphrase.to_string());
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -322,6 +380,46 @@ pub async fn symm_encrypt(passphrase: &str, plain: Vec<u8>) -> Result<String> {
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Symmetrically encrypt the message.
|
||||
/// This is used for broadcast channels and for version 2 of the Securejoin protocol.
|
||||
/// `shared secret` is the secret that will be used for symmetric encryption.
|
||||
pub async fn symm_encrypt_message(
|
||||
plain: Vec<u8>,
|
||||
shared_secret: &str,
|
||||
private_key_for_signing: SignedSecretKey,
|
||||
compress: bool,
|
||||
) -> Result<String> {
|
||||
let shared_secret = Password::from(shared_secret.to_string());
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let msg = MessageBuilder::from_bytes("", plain);
|
||||
let mut rng = thread_rng();
|
||||
let mut salt = [0u8; 8];
|
||||
rng.fill(&mut salt[..]);
|
||||
let s2k = StringToKey::Salted {
|
||||
hash_alg: HashAlgorithm::default(),
|
||||
salt,
|
||||
};
|
||||
let mut msg = msg.seipd_v2(
|
||||
&mut rng,
|
||||
SymmetricKeyAlgorithm::AES128,
|
||||
AeadAlgorithm::Ocb,
|
||||
ChunkSize::C8KiB,
|
||||
);
|
||||
msg.encrypt_with_password(&mut rng, s2k, &shared_secret)?;
|
||||
|
||||
msg.sign(&*private_key_for_signing, Password::empty(), HASH_ALGORITHM);
|
||||
if compress {
|
||||
msg.compression(CompressionAlgorithm::ZLIB);
|
||||
}
|
||||
|
||||
let encoded_msg = msg.to_armored_string(&mut rng, Default::default())?;
|
||||
|
||||
Ok(encoded_msg)
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Symmetric decryption.
|
||||
pub async fn symm_decrypt<T: BufRead + std::fmt::Debug + 'static + Send>(
|
||||
passphrase: &str,
|
||||
@@ -345,7 +443,10 @@ mod tests {
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use super::*;
|
||||
use crate::test_utils::{alice_keypair, bob_keypair};
|
||||
use crate::{
|
||||
key::{load_self_public_key, load_self_secret_key},
|
||||
test_utils::{TestContextManager, alice_keypair, bob_keypair},
|
||||
};
|
||||
|
||||
fn pk_decrypt_and_validate<'a>(
|
||||
ctext: &'a [u8],
|
||||
@@ -356,10 +457,10 @@ mod tests {
|
||||
HashSet<Fingerprint>,
|
||||
Vec<u8>,
|
||||
)> {
|
||||
let mut msg = pk_decrypt(ctext.to_vec(), private_keys_for_decryption)?;
|
||||
let mut msg = decrypt(ctext.to_vec(), private_keys_for_decryption, &[])?;
|
||||
let content = msg.as_data_vec()?;
|
||||
let ret_signature_fingerprints =
|
||||
valid_signature_fingerprints(&msg, public_keys_for_validation)?;
|
||||
valid_signature_fingerprints(&msg, public_keys_for_validation);
|
||||
|
||||
Ok((msg, ret_signature_fingerprints, content))
|
||||
}
|
||||
@@ -542,4 +643,103 @@ mod tests {
|
||||
assert_eq!(content, CLEARTEXT);
|
||||
assert_eq!(valid_signatures.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_encrypt_decrypt_broadcast() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let plain = Vec::from(b"this is the secret message");
|
||||
let shared_secret = "shared secret";
|
||||
let ctext = symm_encrypt_message(
|
||||
plain.clone(),
|
||||
shared_secret,
|
||||
load_self_secret_key(alice).await?,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||
let mut decrypted = decrypt(
|
||||
ctext.into(),
|
||||
&bob_private_keyring,
|
||||
&[shared_secret.to_string()],
|
||||
)?;
|
||||
|
||||
assert_eq!(decrypted.as_data_vec()?, plain);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that we don't try to decrypt a message
|
||||
/// that is symmetrically encrypted
|
||||
/// with an expensive string2key algorithm
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_dont_decrypt_expensive_message() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let plain = Vec::from(b"this is the secret message");
|
||||
let shared_secret = "shared secret";
|
||||
|
||||
// Create a symmetrically encrypted message
|
||||
// with an IteratedAndSalted string2key algorithm:
|
||||
|
||||
let shared_secret_pw = Password::from(shared_secret.to_string());
|
||||
let msg = MessageBuilder::from_bytes("", plain);
|
||||
let mut rng = thread_rng();
|
||||
let s2k = StringToKey::new_default(&mut rng); // Default is IteratedAndSalted
|
||||
|
||||
let mut msg = msg.seipd_v2(
|
||||
&mut rng,
|
||||
SymmetricKeyAlgorithm::AES128,
|
||||
AeadAlgorithm::Ocb,
|
||||
ChunkSize::C8KiB,
|
||||
);
|
||||
msg.encrypt_with_password(&mut rng, s2k, &shared_secret_pw)?;
|
||||
|
||||
let ctext = msg.to_armored_string(&mut rng, Default::default())?;
|
||||
|
||||
// Trying to decrypt it should fail with a helpful error message:
|
||||
|
||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||
let error = decrypt(
|
||||
ctext.into(),
|
||||
&bob_private_keyring,
|
||||
&[shared_secret.to_string()],
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"missing key (Note: symmetric decryption was not tried: unsupported string2key algorithm)"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_decryption_error_msg() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let plain = Vec::from(b"this is the secret message");
|
||||
let pk_for_encryption = load_self_public_key(alice).await?;
|
||||
|
||||
// Encrypt a message, but only to self, not to Bob:
|
||||
let ctext = pk_encrypt(plain, vec![pk_for_encryption], None, true).await?;
|
||||
|
||||
// Trying to decrypt it should fail with an OK error message:
|
||||
let bob_private_keyring = crate::key::load_self_secret_keyring(bob).await?;
|
||||
let error = decrypt(ctext.into(), &bob_private_keyring, &[]).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"missing key (Note: symmetric decryption was not tried: not symmetrically encrypted)"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ use std::sync::LazyLock;
|
||||
// 163.md: 163.com
|
||||
static P_163: Provider = Provider {
|
||||
id: "163",
|
||||
status: Status::Ok,
|
||||
before_login_hint: "",
|
||||
status: Status::Preparation,
|
||||
before_login_hint: "Enable \"POP3/SMTP/IMAP\" on the website, add a third-party auth code and use that as the login password",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/163",
|
||||
server: &[
|
||||
@@ -98,7 +98,7 @@ static P_ALIYUN: Provider = Provider {
|
||||
static P_AOL: Provider = Provider {
|
||||
id: "aol",
|
||||
status: Status::Preparation,
|
||||
before_login_hint: "To log in to AOL with Delta Chat, you need to set up an app password in the AOL web interface.",
|
||||
before_login_hint: "To log in to AOL, you need to set up an app password in the AOL web interface.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/aol",
|
||||
server: &[
|
||||
@@ -432,7 +432,7 @@ static P_EXAMPLE_COM: Provider = Provider {
|
||||
id: "example.com",
|
||||
status: Status::Broken,
|
||||
before_login_hint: "Hush this provider doesn't exist!",
|
||||
after_login_hint: "This provider doesn't really exist, so you can't use it :/ If you need an email provider for Delta Chat, take a look at providers.delta.chat!",
|
||||
after_login_hint: "This provider doesn't really exist, so you can't use it :/ If you need an email provider, take a look at providers.delta.chat!",
|
||||
overview_page: "https://providers.delta.chat/example-com",
|
||||
server: &[
|
||||
Server {
|
||||
@@ -459,7 +459,7 @@ static P_EXAMPLE_COM: Provider = Provider {
|
||||
static P_FASTMAIL: Provider = Provider {
|
||||
id: "fastmail",
|
||||
status: Status::Preparation,
|
||||
before_login_hint: "You must create an app-specific password for Delta Chat before you can log in.",
|
||||
before_login_hint: "You must create an app-specific password before you can log in.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/fastmail",
|
||||
server: &[
|
||||
@@ -526,7 +526,7 @@ static P_FIVE_CHAT: Provider = Provider {
|
||||
static P_FREENET_DE: Provider = Provider {
|
||||
id: "freenet.de",
|
||||
status: Status::Preparation,
|
||||
before_login_hint: "Um deine freenet.de E-Mail-Adresse mit Delta Chat zu benutzen, musst du erst auf der freenet.de-Webseite \"POP3/IMAP/SMTP\" aktivieren.",
|
||||
before_login_hint: "Um deine freenet.de E-Mail-Adresse zu benutzen, musst du erst auf der freenet.de-Webseite \"POP3/IMAP/SMTP\" aktivieren.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/freenet-de",
|
||||
server: &[
|
||||
@@ -647,10 +647,6 @@ static P_HERMES_RADIO: Provider = Provider {
|
||||
key: Config::MdnsEnabled,
|
||||
value: "0",
|
||||
},
|
||||
ConfigDefault {
|
||||
key: Config::E2eeEnabled,
|
||||
value: "0",
|
||||
},
|
||||
ConfigDefault {
|
||||
key: Config::ShowEmails,
|
||||
value: "2",
|
||||
@@ -663,7 +659,7 @@ static P_HERMES_RADIO: Provider = Provider {
|
||||
static P_HEY_COM: Provider = Provider {
|
||||
id: "hey.com",
|
||||
status: Status::Broken,
|
||||
before_login_hint: "hey.com does not offer the standard IMAP e-mail protocol, so you cannot log in with Delta Chat to hey.com.",
|
||||
before_login_hint: "hey.com does not offer the standard IMAP e-mail protocol, so you cannot log in to hey.com.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/hey-com",
|
||||
server: &[],
|
||||
@@ -702,7 +698,7 @@ static P_I3_NET: Provider = Provider {
|
||||
static P_ICLOUD: Provider = Provider {
|
||||
id: "icloud",
|
||||
status: Status::Preparation,
|
||||
before_login_hint: "You must create an app-specific password for Delta Chat before login.",
|
||||
before_login_hint: "You must create an app-specific password before login.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/icloud",
|
||||
server: &[
|
||||
@@ -787,7 +783,7 @@ static P_KONTENT_COM: Provider = Provider {
|
||||
static P_MAIL_COM: Provider = Provider {
|
||||
id: "mail.com",
|
||||
status: Status::Preparation,
|
||||
before_login_hint: "To log in with Delta Chat, you first need to activate POP3/IMAP in your mail.com settings. Note that this is a mail.com Premium feature only.",
|
||||
before_login_hint: "To log in, you first need to activate POP3/IMAP in your mail.com settings. Note that this is a mail.com Premium feature only.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/mail-com",
|
||||
server: &[],
|
||||
@@ -828,7 +824,7 @@ static P_MAIL_DE: Provider = Provider {
|
||||
static P_MAIL_RU: Provider = Provider {
|
||||
id: "mail.ru",
|
||||
status: Status::Preparation,
|
||||
before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru, чтобы mail.ru работал с Delta Chat.",
|
||||
before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru, чтобы mail.ru работал с chatmail.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/mail-ru",
|
||||
server: &[
|
||||
@@ -1222,8 +1218,8 @@ static P_NUBO_COOP: Provider = Provider {
|
||||
// outlook.com.md: hotmail.com, outlook.com, office365.com, outlook.com.tr, live.com, outlook.de
|
||||
static P_OUTLOOK_COM: Provider = Provider {
|
||||
id: "outlook.com",
|
||||
status: Status::Ok,
|
||||
before_login_hint: "",
|
||||
status: Status::Broken,
|
||||
before_login_hint: "Unfortunately, Outlook does not allow using passwords anymore, per-app-passwords are currently not working.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/outlook-com",
|
||||
server: &[
|
||||
@@ -1321,8 +1317,8 @@ static P_POSTEO: Provider = Provider {
|
||||
static P_PROTONMAIL: Provider = Provider {
|
||||
id: "protonmail",
|
||||
status: Status::Broken,
|
||||
before_login_hint: "Protonmail does not offer the standard IMAP e-mail protocol, so you cannot log in with Delta Chat to Protonmail.",
|
||||
after_login_hint: "To use Delta Chat with Protonmail, the IMAP bridge must be running in the background. If you have connectivity issues, double check whether it works as expected.",
|
||||
before_login_hint: "Protonmail does not offer the standard IMAP e-mail protocol, so you cannot log in with to Protonmail.",
|
||||
after_login_hint: "To use Protonmail, the IMAP bridge must be running in the background. If you have connectivity issues, double check whether it works as expected.",
|
||||
overview_page: "https://providers.delta.chat/protonmail",
|
||||
server: &[],
|
||||
opt: ProviderOptions::new(),
|
||||
@@ -1362,7 +1358,7 @@ static P_PURELYMAIL_COM: Provider = Provider {
|
||||
static P_QQ: Provider = Provider {
|
||||
id: "qq",
|
||||
status: Status::Preparation,
|
||||
before_login_hint: "Manually enabling IMAP/SMTP and creating an app-specific password for Delta Chat are required.",
|
||||
before_login_hint: "Manually enabling IMAP/SMTP and creating an app-specific password are required.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/qq",
|
||||
server: &[
|
||||
@@ -1390,7 +1386,7 @@ static P_QQ: Provider = Provider {
|
||||
static P_RAMBLER_RU: Provider = Provider {
|
||||
id: "rambler.ru",
|
||||
status: Status::Preparation,
|
||||
before_login_hint: "Чтобы войти в Рамблер/почта через Delta Chat, необходимо предварительно включить доступ с помощью почтовых клиентов на сайте mail.rambler.ru",
|
||||
before_login_hint: "Чтобы войти в Рамблер/почта, необходимо предварительно включить доступ с помощью почтовых клиентов на сайте mail.rambler.ru",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/rambler-ru",
|
||||
server: &[
|
||||
@@ -1566,7 +1562,7 @@ static P_SYSTEMLI_ORG: Provider = Provider {
|
||||
static P_T_ONLINE: Provider = Provider {
|
||||
id: "t-online",
|
||||
status: Status::Preparation,
|
||||
before_login_hint: "To use Delta Chat with a T-Online email address, you need to create an app password in the web interface.",
|
||||
before_login_hint: "To use a T-Online email address, you need to create an app password in the web interface.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/t-online",
|
||||
server: &[
|
||||
@@ -1677,7 +1673,7 @@ static P_TISCALI_IT: Provider = Provider {
|
||||
static P_TUTANOTA: Provider = Provider {
|
||||
id: "tutanota",
|
||||
status: Status::Broken,
|
||||
before_login_hint: "Tutanota does not offer the standard IMAP e-mail protocol, so you cannot log in with Delta Chat to Tutanota.",
|
||||
before_login_hint: "Tutanota does not offer the standard IMAP e-mail protocol, so you cannot log in to Tutanota.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/tutanota",
|
||||
server: &[],
|
||||
@@ -1787,7 +1783,7 @@ static P_VIVALDI: Provider = Provider {
|
||||
static P_VK_COM: Provider = Provider {
|
||||
id: "vk.com",
|
||||
status: Status::Preparation,
|
||||
before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru https://account.mail.ru/user/2-step-auth/passwords/ чтобы vk.com работал с Delta Chat.",
|
||||
before_login_hint: "Вам необходимо сгенерировать \"пароль для внешнего приложения\" в веб-интерфейсе mail.ru https://account.mail.ru/user/2-step-auth/passwords/ чтобы vk.com работал с chatmail.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/vk-com",
|
||||
server: &[
|
||||
@@ -1906,7 +1902,7 @@ static P_WKPB_DE: Provider = Provider {
|
||||
static P_YAHOO: Provider = Provider {
|
||||
id: "yahoo",
|
||||
status: Status::Preparation,
|
||||
before_login_hint: "To use Delta Chat with your Yahoo email address you have to create an \"App-Password\" in the account security screen.",
|
||||
before_login_hint: "To use your Yahoo email address you have to create an \"App-Password\" in the account security screen.",
|
||||
after_login_hint: "",
|
||||
overview_page: "https://providers.delta.chat/yahoo",
|
||||
server: &[
|
||||
@@ -2662,4 +2658,4 @@ pub(crate) static PROVIDER_IDS: LazyLock<HashMap<&'static str, &'static Provider
|
||||
});
|
||||
|
||||
pub static _PROVIDER_UPDATED: LazyLock<chrono::NaiveDate> =
|
||||
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2024, 9, 13).unwrap());
|
||||
LazyLock::new(|| chrono::NaiveDate::from_ymd_opt(2025, 9, 4).unwrap());
|
||||
|
||||
@@ -74,7 +74,7 @@ fn pad_device_token(s: &str) -> String {
|
||||
///
|
||||
/// The result is base64-encoded and not ASCII armored to avoid dealing with newlines.
|
||||
pub(crate) fn encrypt_device_token(device_token: &str) -> Result<String> {
|
||||
let public_key = pgp::composed::SignedPublicKey::from_asc(NOTIFIERS_PUBLIC_KEY)?.0;
|
||||
let public_key = pgp::composed::SignedPublicKey::from_asc(NOTIFIERS_PUBLIC_KEY)?;
|
||||
let encryption_subkey = public_key
|
||||
.public_subkeys
|
||||
.first()
|
||||
|
||||
95
src/qr.rs
95
src/qr.rs
@@ -84,6 +84,30 @@ pub enum Qr {
|
||||
authcode: String,
|
||||
},
|
||||
|
||||
/// Ask whether to join the broadcast channel.
|
||||
AskJoinBroadcast {
|
||||
/// The user-visible name of this broadcast channel
|
||||
broadcast_name: String,
|
||||
|
||||
/// A string of random characters,
|
||||
/// uniquely identifying this broadcast channel in the database.
|
||||
/// Called `grpid` for historic reasons:
|
||||
/// The id of multi-user chats is always called `grpid` in the database
|
||||
/// because groups were once the only multi-user chats.
|
||||
grpid: String,
|
||||
|
||||
/// ID of the contact who owns the channel and created the QR code.
|
||||
contact_id: ContactId,
|
||||
|
||||
/// Fingerprint of the contact's key as scanned from the QR code.
|
||||
fingerprint: Fingerprint,
|
||||
|
||||
/// The AUTH code from the secure-join protocol,
|
||||
/// which is both used to encrypt the first message to the inviter
|
||||
/// and to prove to the inviter that we saw the QR code.
|
||||
authcode: String,
|
||||
},
|
||||
|
||||
/// Contact fingerprint is verified.
|
||||
///
|
||||
/// Ask the user if they want to start chatting.
|
||||
@@ -381,6 +405,7 @@ pub fn format_backup(qr: &Qr) -> Result<String> {
|
||||
|
||||
/// scheme: `OPENPGP4FPR:FINGERPRINT#a=ADDR&n=NAME&i=INVITENUMBER&s=AUTH`
|
||||
/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&g=GROUPNAME&x=GROUPID&i=INVITENUMBER&s=AUTH`
|
||||
/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR&b=BROADCAST_NAME&x=BROADCAST_ID&s=AUTH`
|
||||
/// or: `OPENPGP4FPR:FINGERPRINT#a=ADDR`
|
||||
async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
let payload = qr
|
||||
@@ -417,15 +442,7 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
None
|
||||
};
|
||||
|
||||
let name = if let Some(encoded_name) = param.get("n") {
|
||||
let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+`
|
||||
match percent_decode_str(&encoded_name).decode_utf8() {
|
||||
Ok(name) => name.to_string(),
|
||||
Err(err) => bail!("Invalid name: {}", err),
|
||||
}
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
let name = decode_name(¶m, "n")?.unwrap_or_default();
|
||||
|
||||
let invitenumber = param
|
||||
.get("i")
|
||||
@@ -440,21 +457,12 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
.filter(|&s| validate_id(s))
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let grpname = if grpid.is_some() {
|
||||
if let Some(encoded_name) = param.get("g") {
|
||||
let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+`
|
||||
match percent_decode_str(&encoded_name).decode_utf8() {
|
||||
Ok(name) => Some(name.to_string()),
|
||||
Err(err) => bail!("Invalid group name: {}", err),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let grpname = decode_name(¶m, "g")?;
|
||||
let broadcast_name = decode_name(¶m, "b")?;
|
||||
|
||||
if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
|
||||
if let (Some(addr), Some(invitenumber), Some(authcode)) =
|
||||
(&addr, invitenumber, authcode.clone())
|
||||
{
|
||||
let addr = ContactAddress::new(addr)?;
|
||||
let (contact_id, _) = Contact::add_or_lookup_ex(
|
||||
context,
|
||||
@@ -525,6 +533,28 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
authcode,
|
||||
})
|
||||
}
|
||||
} else if let (Some(addr), Some(broadcast_name), Some(grpid), Some(authcode)) =
|
||||
(&addr, broadcast_name, grpid, authcode)
|
||||
{
|
||||
// This is a broadcast channel invite link.
|
||||
let addr = ContactAddress::new(addr)?;
|
||||
let (contact_id, _) = Contact::add_or_lookup_ex(
|
||||
context,
|
||||
&name,
|
||||
&addr,
|
||||
&fingerprint.hex(),
|
||||
Origin::UnhandledSecurejoinQrScan,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?;
|
||||
|
||||
Ok(Qr::AskJoinBroadcast {
|
||||
broadcast_name,
|
||||
grpid,
|
||||
contact_id,
|
||||
fingerprint,
|
||||
authcode,
|
||||
})
|
||||
} else if let Some(addr) = addr {
|
||||
let fingerprint = fingerprint.hex();
|
||||
let (contact_id, _) =
|
||||
@@ -546,6 +576,18 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_name(param: &BTreeMap<&str, &str>, key: &str) -> Result<Option<String>> {
|
||||
if let Some(encoded_name) = param.get(key) {
|
||||
let encoded_name = encoded_name.replace('+', "%20"); // sometimes spaces are encoded as `+`
|
||||
match percent_decode_str(&encoded_name).decode_utf8() {
|
||||
Ok(name) => Ok(Some(name.to_string())),
|
||||
Err(err) => bail!("Invalid QR param {key}: {err}"),
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// scheme: `https://i.delta.chat[/]#FINGERPRINT&a=ADDR[&OPTIONAL_PARAMS]`
|
||||
async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<Qr> {
|
||||
let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1);
|
||||
@@ -766,19 +808,18 @@ pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
|
||||
authcode,
|
||||
..
|
||||
} => {
|
||||
token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?;
|
||||
token::delete(context, token::Namespace::Auth, &authcode).await?;
|
||||
token::delete(context, "").await?;
|
||||
context
|
||||
.sync_qr_code_token_deletion(invitenumber, authcode)
|
||||
.await?;
|
||||
}
|
||||
Qr::WithdrawVerifyGroup {
|
||||
grpid,
|
||||
invitenumber,
|
||||
authcode,
|
||||
..
|
||||
} => {
|
||||
token::delete(context, token::Namespace::InviteNumber, &invitenumber).await?;
|
||||
token::delete(context, token::Namespace::Auth, &authcode).await?;
|
||||
token::delete(context, &grpid).await?;
|
||||
context
|
||||
.sync_qr_code_token_deletion(invitenumber, authcode)
|
||||
.await?;
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::*;
|
||||
use crate::chat::{ProtectionStatus, create_group_chat};
|
||||
use crate::config::Config;
|
||||
use crate::securejoin::get_securejoin_qr;
|
||||
use crate::test_utils::{TestContext, TestContextManager};
|
||||
use crate::test_utils::{TestContext, TestContextManager, sync};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_decode_http() -> Result<()> {
|
||||
@@ -509,6 +509,77 @@ async fn test_withdraw_verifygroup() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_withdraw_multidevice() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let alice2 = &tcm.alice().await;
|
||||
|
||||
alice.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
alice2.set_config_bool(Config::SyncMsgs, true).await?;
|
||||
|
||||
// Alice creates two QR codes on the first device:
|
||||
// group QR code and contact QR code.
|
||||
let chat_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group").await?;
|
||||
let chat2_id = create_group_chat(alice, ProtectionStatus::Unprotected, "Group 2").await?;
|
||||
let contact_qr = get_securejoin_qr(alice, None).await?;
|
||||
let group_qr = get_securejoin_qr(alice, Some(chat_id)).await?;
|
||||
let group2_qr = get_securejoin_qr(alice, Some(chat2_id)).await?;
|
||||
|
||||
assert!(matches!(
|
||||
check_qr(alice, &contact_qr).await?,
|
||||
Qr::WithdrawVerifyContact { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
check_qr(alice, &group_qr).await?,
|
||||
Qr::WithdrawVerifyGroup { .. }
|
||||
));
|
||||
|
||||
// Sync group QR codes.
|
||||
sync(alice, alice2).await;
|
||||
assert!(matches!(
|
||||
check_qr(alice2, &group_qr).await?,
|
||||
Qr::WithdrawVerifyGroup { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
check_qr(alice2, &group2_qr).await?,
|
||||
Qr::WithdrawVerifyGroup { .. }
|
||||
));
|
||||
|
||||
// Alice creates a contact QR code on second device
|
||||
// and withdraws it.
|
||||
let contact_qr2 = get_securejoin_qr(alice2, None).await?;
|
||||
set_config_from_qr(alice2, &contact_qr2).await?;
|
||||
assert!(matches!(
|
||||
check_qr(alice2, &contact_qr2).await?,
|
||||
Qr::ReviveVerifyContact { .. }
|
||||
));
|
||||
|
||||
// Alice also withdraws second group QR code on second device.
|
||||
set_config_from_qr(alice2, &group2_qr).await?;
|
||||
|
||||
// Sync messages are sent from Alice's second device to first device.
|
||||
sync(alice2, alice).await;
|
||||
|
||||
// Now first device has reset all contact QR codes
|
||||
// and second group QR code,
|
||||
// but first group QR code is still valid.
|
||||
assert!(matches!(
|
||||
check_qr(alice, &contact_qr2).await?,
|
||||
Qr::ReviveVerifyContact { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
check_qr(alice, &group_qr).await?,
|
||||
Qr::WithdrawVerifyGroup { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
check_qr(alice, &group2_qr).await?,
|
||||
Qr::ReviveVerifyGroup { .. }
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_decode_and_apply_dclogin() -> Result<()> {
|
||||
let ctx = TestContext::new().await;
|
||||
|
||||
@@ -404,7 +404,7 @@ mod tests {
|
||||
use crate::config::Config;
|
||||
use crate::contact::{Contact, Origin};
|
||||
use crate::download::DownloadState;
|
||||
use crate::message::{MessageState, delete_msgs};
|
||||
use crate::message::{MessageState, Viewtype, delete_msgs};
|
||||
use crate::receive_imf::{receive_imf, receive_imf_from_inbox};
|
||||
use crate::sql::housekeeping;
|
||||
use crate::test_utils::E2EE_INFO_MSGS;
|
||||
@@ -550,6 +550,46 @@ Here's my footer -- bob@example.net"
|
||||
let reactions = get_msg_reactions(&alice, msg.id).await?;
|
||||
assert_eq!(reactions.to_string(), "😀1");
|
||||
|
||||
// Alice receives a message with reaction to her message from Bob.
|
||||
let msg_bob = receive_imf(
|
||||
&alice,
|
||||
"To: alice@example.org\n\
|
||||
From: bob@example.net\n\
|
||||
Date: Today, 29 February 2021 00:00:10 -800\n\
|
||||
Message-ID: 56791@example.net\n\
|
||||
In-Reply-To: 12345@example.org\n\
|
||||
Mime-Version: 1.0\n\
|
||||
Content-Type: multipart/mixed; boundary=\"YiEDa0DAkWCtVeE4\"\n\
|
||||
Content-Disposition: inline\n\
|
||||
\n\
|
||||
--YiEDa0DAkWCtVeE4\n\
|
||||
Content-Type: text/plain; charset=utf-8\n\
|
||||
Content-Disposition: inline\n\
|
||||
\n\
|
||||
Reply + reaction\n\
|
||||
\n\
|
||||
--YiEDa0DAkWCtVeE4\n\
|
||||
Content-Type: text/plain; charset=utf-8\n\
|
||||
Content-Disposition: reaction\n\
|
||||
\n\
|
||||
\u{1F44D}\n\
|
||||
\n\
|
||||
--YiEDa0DAkWCtVeE4--"
|
||||
.as_bytes(),
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.unwrap();
|
||||
let msg_bob = Message::load_from_db(&alice, msg_bob.msg_ids[0]).await?;
|
||||
assert_eq!(msg_bob.from_id, bob_id);
|
||||
assert_eq!(msg_bob.chat_id, msg.chat_id);
|
||||
assert_eq!(msg_bob.viewtype, Viewtype::Text);
|
||||
assert_eq!(msg_bob.state, MessageState::InFresh);
|
||||
assert_eq!(msg_bob.hidden, false);
|
||||
assert_eq!(msg_bob.text, "Reply + reaction");
|
||||
let reactions = get_msg_reactions(&alice, msg.id).await?;
|
||||
assert_eq!(reactions.to_string(), "👍1");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Internet Message Format reception pipeline.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::iter;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@@ -15,10 +15,10 @@ use num_traits::FromPrimitive;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::chat::{
|
||||
self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, remove_from_chat_contacts_table,
|
||||
self, Chat, ChatId, ChatIdBlocked, ProtectionStatus, save_broadcast_shared_secret,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::constants::{Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX, ShowEmails};
|
||||
use crate::constants::{self, Blocked, Chattype, DC_CHAT_ID_TRASH, EDITED_PREFIX, ShowEmails};
|
||||
use crate::contact::{Contact, ContactId, Origin, mark_contact_id_as_verified};
|
||||
use crate::context::Context;
|
||||
use crate::debug_logging::maybe_set_logging_xdc_inner;
|
||||
@@ -27,15 +27,15 @@ use crate::ephemeral::{Timer as EphemeralTimer, stock_ephemeral_timer_changed};
|
||||
use crate::events::EventType;
|
||||
use crate::headerdef::{HeaderDef, HeaderDefMap};
|
||||
use crate::imap::{GENERATED_PREFIX, markseen_on_imap_table};
|
||||
use crate::key::self_fingerprint_opt;
|
||||
use crate::key::{DcKey, Fingerprint, SignedPublicKey};
|
||||
use crate::key::{DcKey, Fingerprint};
|
||||
use crate::key::{self_fingerprint, self_fingerprint_opt};
|
||||
use crate::log::LogExt;
|
||||
use crate::log::{info, warn};
|
||||
use crate::logged_debug_assert;
|
||||
use crate::message::{
|
||||
self, Message, MessageState, MessengerMessage, MsgId, Viewtype, rfc724_mid_exists,
|
||||
};
|
||||
use crate::mimeparser::{AvatarAction, MimeMessage, SystemMessage, parse_message_ids};
|
||||
use crate::mimeparser::{AvatarAction, GossipedKey, MimeMessage, SystemMessage, parse_message_ids};
|
||||
use crate::param::{Param, Params};
|
||||
use crate::peer_channels::{add_gossip_peer_from_header, insert_topic_stub};
|
||||
use crate::reaction::{Reaction, set_msg_reaction};
|
||||
@@ -44,7 +44,10 @@ use crate::securejoin::{self, handle_securejoin_handshake, observe_securejoin_on
|
||||
use crate::simplify;
|
||||
use crate::stock_str;
|
||||
use crate::sync::Sync::*;
|
||||
use crate::tools::{self, buf_compress, remove_subject_prefix};
|
||||
use crate::tools::{
|
||||
self, buf_compress, create_broadcast_shared_secret, remove_subject_prefix,
|
||||
validate_broadcast_shared_secret,
|
||||
};
|
||||
use crate::{chatlist_events, ensure_and_debug_assert, ensure_and_debug_assert_eq, location};
|
||||
use crate::{contact, imap};
|
||||
|
||||
@@ -630,8 +633,7 @@ pub(crate) async fn receive_imf_inner(
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let prevent_rename = (mime_parser.is_mailinglist_message() && !mime_parser.was_encrypted())
|
||||
|| mime_parser.get_header(HeaderDef::Sender).is_some();
|
||||
let prevent_rename = should_prevent_rename(&mime_parser);
|
||||
|
||||
// get From: (it can be an address list!) and check if it is known (for known From:'s we add
|
||||
// the other To:/Cc: in the 3rd pass)
|
||||
@@ -764,7 +766,6 @@ pub(crate) async fn receive_imf_inner(
|
||||
let show_emails = ShowEmails::from_i32(context.get_config_int(Config::ShowEmails).await?)
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_reaction = mime_parser.parts.iter().any(|part| part.is_reaction);
|
||||
let allow_creation = if mime_parser.decrypting_failed {
|
||||
false
|
||||
} else if mime_parser.is_system_message != SystemMessage::AutocryptSetupMessage
|
||||
@@ -778,7 +779,7 @@ pub(crate) async fn receive_imf_inner(
|
||||
ShowEmails::All => true,
|
||||
}
|
||||
} else {
|
||||
!is_reaction
|
||||
!mime_parser.parts.iter().all(|part| part.is_reaction)
|
||||
};
|
||||
|
||||
let to_id = if mime_parser.incoming {
|
||||
@@ -837,7 +838,7 @@ pub(crate) async fn receive_imf_inner(
|
||||
context
|
||||
.sql
|
||||
.transaction(move |transaction| {
|
||||
let fingerprint = gossiped_key.dc_fingerprint().hex();
|
||||
let fingerprint = gossiped_key.public_key.dc_fingerprint().hex();
|
||||
transaction.execute(
|
||||
"INSERT INTO gossip_timestamp (chat_id, fingerprint, timestamp)
|
||||
VALUES (?, ?, ?)
|
||||
@@ -1002,12 +1003,18 @@ pub(crate) async fn receive_imf_inner(
|
||||
}
|
||||
}
|
||||
|
||||
if received_msg.hidden {
|
||||
if mime_parser.is_call() {
|
||||
context
|
||||
.handle_call_msg(insert_msg_id, &mime_parser, from_id)
|
||||
.await?;
|
||||
} else if received_msg.hidden {
|
||||
// No need to emit an event about the changed message
|
||||
} else if let Some(replace_chat_id) = replace_chat_id {
|
||||
context.emit_msgs_changed_without_msg_id(replace_chat_id);
|
||||
} else if !chat_id.is_trash() {
|
||||
let fresh = received_msg.state == MessageState::InFresh;
|
||||
let fresh = received_msg.state == MessageState::InFresh
|
||||
&& mime_parser.is_system_message != SystemMessage::CallAccepted
|
||||
&& mime_parser.is_system_message != SystemMessage::CallEnded;
|
||||
for msg_id in &received_msg.msg_ids {
|
||||
chat_id.emit_msg_event(context, *msg_id, mime_parser.incoming && fresh);
|
||||
}
|
||||
@@ -1151,6 +1158,11 @@ async fn decide_chat_assignment(
|
||||
{
|
||||
info!(context, "Chat edit/delete/iroh/sync message (TRASH).");
|
||||
true
|
||||
} else if mime_parser.is_system_message == SystemMessage::CallAccepted
|
||||
|| mime_parser.is_system_message == SystemMessage::CallEnded
|
||||
{
|
||||
info!(context, "Call state changed (TRASH).");
|
||||
true
|
||||
} else if mime_parser.decrypting_failed && !mime_parser.incoming {
|
||||
// Outgoing undecryptable message.
|
||||
let last_time = context
|
||||
@@ -1219,17 +1231,21 @@ async fn decide_chat_assignment(
|
||||
//
|
||||
// The chat may not exist yet, i.e. there may be
|
||||
// no database row and ChatId yet.
|
||||
let mut num_recipients = mime_parser.recipients.len();
|
||||
if from_id != ContactId::SELF {
|
||||
let mut has_self_addr = false;
|
||||
for recipient in &mime_parser.recipients {
|
||||
if context.is_self_addr(&recipient.addr).await? {
|
||||
has_self_addr = true;
|
||||
}
|
||||
let mut num_recipients = 0;
|
||||
let mut has_self_addr = false;
|
||||
for recipient in &mime_parser.recipients {
|
||||
if addr_cmp(&recipient.addr, &mime_parser.from.addr) {
|
||||
continue;
|
||||
}
|
||||
if !has_self_addr {
|
||||
num_recipients += 1;
|
||||
|
||||
if context.is_self_addr(&recipient.addr).await? {
|
||||
has_self_addr = true;
|
||||
}
|
||||
|
||||
num_recipients += 1;
|
||||
}
|
||||
if from_id != ContactId::SELF && !has_self_addr {
|
||||
num_recipients += 1;
|
||||
}
|
||||
|
||||
let chat_assignment = if should_trash {
|
||||
@@ -1272,11 +1288,15 @@ async fn decide_chat_assignment(
|
||||
chat_id,
|
||||
chat_id_blocked,
|
||||
}
|
||||
} else if mime_parser.get_header(HeaderDef::ChatGroupName).is_some() {
|
||||
ChatAssignment::AdHocGroup
|
||||
} else if num_recipients <= 1 {
|
||||
ChatAssignment::OneOneChat
|
||||
} else {
|
||||
ChatAssignment::AdHocGroup
|
||||
}
|
||||
} else if mime_parser.get_header(HeaderDef::ChatGroupName).is_some() {
|
||||
ChatAssignment::AdHocGroup
|
||||
} else if num_recipients <= 1 {
|
||||
ChatAssignment::OneOneChat
|
||||
} else {
|
||||
@@ -1372,6 +1392,7 @@ async fn do_chat_assignment(
|
||||
create_or_lookup_mailinglist_or_broadcast(
|
||||
context,
|
||||
allow_creation,
|
||||
create_blocked,
|
||||
mailinglist_header,
|
||||
from_id,
|
||||
mime_parser,
|
||||
@@ -1397,7 +1418,6 @@ async fn do_chat_assignment(
|
||||
context,
|
||||
mime_parser,
|
||||
to_ids,
|
||||
from_id,
|
||||
allow_creation || test_normal_chat.is_some(),
|
||||
create_blocked,
|
||||
is_partial_download.is_some(),
|
||||
@@ -1557,7 +1577,9 @@ async fn do_chat_assignment(
|
||||
} else {
|
||||
let name =
|
||||
compute_mailinglist_name(mailinglist_header, &listid, mime_parser);
|
||||
chat::create_broadcast_ex(context, Nosync, listid, name).await?
|
||||
let secret = create_broadcast_shared_secret();
|
||||
chat::create_out_broadcast_ex(context, Nosync, listid, name, secret)
|
||||
.await?
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1567,7 +1589,6 @@ async fn do_chat_assignment(
|
||||
context,
|
||||
mime_parser,
|
||||
to_ids,
|
||||
from_id,
|
||||
allow_creation,
|
||||
Blocked::Not,
|
||||
is_partial_download.is_some(),
|
||||
@@ -1672,12 +1693,12 @@ async fn add_parts(
|
||||
}
|
||||
}
|
||||
|
||||
let mut chat = Chat::load_from_db(context, chat_id).await?;
|
||||
|
||||
if mime_parser.incoming && !chat_id.is_trash() {
|
||||
// It can happen that the message is put into a chat
|
||||
// but the From-address is not a member of this chat.
|
||||
if !chat::is_contact_in_chat(context, chat_id, from_id).await? {
|
||||
let chat = Chat::load_from_db(context, chat_id).await?;
|
||||
|
||||
// Mark the sender as overridden.
|
||||
// The UI will prepend `~` to the sender's name,
|
||||
// indicating that the sender is not part of the group.
|
||||
@@ -1685,12 +1706,15 @@ async fn add_parts(
|
||||
let name: &str = from.display_name.as_ref().unwrap_or(&from.addr);
|
||||
for part in &mut mime_parser.parts {
|
||||
part.param.set(Param::OverrideSenderDisplayname, name);
|
||||
}
|
||||
|
||||
if chat.is_protected() {
|
||||
// In protected chat, also mark the message with an error.
|
||||
let s = stock_str::unknown_sender_for_chat(context).await;
|
||||
part.error = Some(s);
|
||||
if chat.typ == Chattype::InBroadcast {
|
||||
let s = stock_str::error(context, "This message was not sent by the channel owner")
|
||||
.await;
|
||||
if let Some(part) = mime_parser.parts.first_mut() {
|
||||
part.error = Some(format!("{s}:\n\"{}\"", part.msg));
|
||||
}
|
||||
mime_parser.replace_msg_by_error(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1698,7 +1722,6 @@ async fn add_parts(
|
||||
let is_location_kml = mime_parser.location_kml.is_some();
|
||||
let is_mdn = !mime_parser.mdn_reports.is_empty();
|
||||
|
||||
let mut chat = Chat::load_from_db(context, chat_id).await?;
|
||||
let mut group_changes = match chat.typ {
|
||||
_ if chat.id.is_special() => GroupChangesInfo::default(),
|
||||
Chattype::Single => GroupChangesInfo::default(),
|
||||
@@ -1863,26 +1886,7 @@ async fn add_parts(
|
||||
None
|
||||
};
|
||||
|
||||
// if a chat is protected and the message is fully downloaded, check additional properties
|
||||
if !chat_id.is_special() && is_partial_download.is_none() {
|
||||
let chat = Chat::load_from_db(context, chat_id).await?;
|
||||
|
||||
// For outgoing emails in the 1:1 chat we have an exception that
|
||||
// they are allowed to be unencrypted:
|
||||
// 1. They can't be an attack (they are outgoing, not incoming)
|
||||
// 2. Probably the unencryptedness is just a temporary state, after all
|
||||
// the user obviously still uses DC
|
||||
// -> Showing info messages every time would be a lot of noise
|
||||
// 3. The info messages that are shown to the user ("Your chat partner
|
||||
// likely reinstalled DC" or similar) would be wrong.
|
||||
if chat.is_protected() && (mime_parser.incoming || chat.typ != Chattype::Single) {
|
||||
if let VerifiedEncryption::NotVerified(err) = verified_encryption {
|
||||
warn!(context, "Verification problem: {err:#}.");
|
||||
let s = format!("{err}. See 'Info' for more details");
|
||||
mime_parser.replace_msg_by_error(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(chat); // Avoid using stale `chat` object.
|
||||
|
||||
let sort_timestamp = tweak_sort_timestamp(
|
||||
context,
|
||||
@@ -1995,10 +1999,28 @@ async fn add_parts(
|
||||
|
||||
handle_edit_delete(context, mime_parser, from_id).await?;
|
||||
|
||||
let is_reaction = mime_parser.parts.iter().any(|part| part.is_reaction);
|
||||
let hidden = is_reaction;
|
||||
if mime_parser.is_system_message == SystemMessage::CallAccepted
|
||||
|| mime_parser.is_system_message == SystemMessage::CallEnded
|
||||
{
|
||||
if let Some(field) = mime_parser.get_header(HeaderDef::InReplyTo) {
|
||||
if let Some(call) =
|
||||
message::get_by_rfc724_mids(context, &parse_message_ids(field)).await?
|
||||
{
|
||||
context
|
||||
.handle_call_msg(call.get_id(), mime_parser, from_id)
|
||||
.await?;
|
||||
} else {
|
||||
warn!(context, "Call: Cannot load parent.")
|
||||
}
|
||||
} else {
|
||||
warn!(context, "Call: Not a reply.")
|
||||
}
|
||||
}
|
||||
|
||||
let hidden = mime_parser.parts.iter().all(|part| part.is_reaction);
|
||||
let mut parts = mime_parser.parts.iter().peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
let hidden = part.is_reaction;
|
||||
if part.is_reaction {
|
||||
let reaction_str = simplify::remove_footers(part.msg.as_str());
|
||||
let is_incoming_fresh = mime_parser.incoming && !seen;
|
||||
@@ -2464,7 +2486,6 @@ async fn lookup_or_create_adhoc_group(
|
||||
context: &Context,
|
||||
mime_parser: &MimeMessage,
|
||||
to_ids: &[Option<ContactId>],
|
||||
from_id: ContactId,
|
||||
allow_creation: bool,
|
||||
create_blocked: Blocked,
|
||||
is_partial_download: bool,
|
||||
@@ -2487,10 +2508,29 @@ async fn lookup_or_create_adhoc_group(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Lookup address-contact by the From address.
|
||||
let fingerprint = None;
|
||||
let find_key_contact_by_addr = false;
|
||||
let prevent_rename = should_prevent_rename(mime_parser);
|
||||
let (from_id, _from_id_blocked, _incoming_origin) = from_field_to_contact_id(
|
||||
context,
|
||||
&mime_parser.from,
|
||||
fingerprint,
|
||||
prevent_rename,
|
||||
find_key_contact_by_addr,
|
||||
)
|
||||
.await?
|
||||
.context("Cannot lookup address-contact by the From field")?;
|
||||
|
||||
let grpname = mime_parser
|
||||
.get_subject()
|
||||
.map(|s| remove_subject_prefix(&s))
|
||||
.unwrap_or_else(|| "👥📧".to_string());
|
||||
.get_header(HeaderDef::ChatGroupName)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
mime_parser
|
||||
.get_subject()
|
||||
.map(|s| remove_subject_prefix(&s))
|
||||
.unwrap_or_else(|| "👥📧".to_string())
|
||||
});
|
||||
let to_ids: Vec<ContactId> = to_ids.iter().filter_map(|x| *x).collect();
|
||||
let mut contact_ids = Vec::with_capacity(to_ids.len() + 1);
|
||||
contact_ids.extend(&to_ids);
|
||||
@@ -2855,20 +2895,13 @@ async fn apply_group_changes(
|
||||
let is_from_in_chat =
|
||||
!chat_contacts.contains(&ContactId::SELF) || chat_contacts.contains(&from_id);
|
||||
|
||||
if mime_parser.get_header(HeaderDef::ChatVerified).is_some() {
|
||||
if mime_parser.get_header(HeaderDef::ChatVerified).is_some() && !chat.is_protected() {
|
||||
if let VerifiedEncryption::NotVerified(err) = verified_encryption {
|
||||
if chat.is_protected() {
|
||||
warn!(context, "Verification problem: {err:#}.");
|
||||
let s = format!("{err}. See 'Info' for more details");
|
||||
mime_parser.replace_msg_by_error(&s);
|
||||
} else {
|
||||
warn!(
|
||||
context,
|
||||
"Not marking chat {} as protected due to verification problem: {err:#}.",
|
||||
chat.id
|
||||
);
|
||||
}
|
||||
} else if !chat.is_protected() {
|
||||
warn!(
|
||||
context,
|
||||
"Not marking chat {} as protected due to verification problem: {err:#}.", chat.id,
|
||||
);
|
||||
} else {
|
||||
chat.id
|
||||
.set_protection(
|
||||
context,
|
||||
@@ -2881,15 +2914,13 @@ async fn apply_group_changes(
|
||||
}
|
||||
|
||||
if let Some(removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) {
|
||||
// TODO: if address "alice@example.org" is a member of the group twice,
|
||||
// with old and new key,
|
||||
// and someone (maybe Alice's new contact) just removed Alice's old contact,
|
||||
// we may lookup the wrong contact because we only look up by the address.
|
||||
// The result is that info message may contain the new Alice's display name
|
||||
// rather than old display name.
|
||||
// This could be fixed by looking up the contact with the highest
|
||||
// `remove_timestamp` after applying Chat-Group-Member-Timestamps.
|
||||
removed_id = lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await?;
|
||||
if let Some(removed_fpr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemovedFpr) {
|
||||
removed_id = lookup_key_contact_by_fingerprint(context, removed_fpr).await?;
|
||||
} else {
|
||||
// Removal message sent by a legacy Delta Chat client.
|
||||
removed_id =
|
||||
lookup_key_contact_by_address(context, removed_addr, Some(chat.id)).await?;
|
||||
}
|
||||
if let Some(id) = removed_id {
|
||||
better_msg = if id == from_id {
|
||||
silent = true;
|
||||
@@ -2906,9 +2937,11 @@ async fn apply_group_changes(
|
||||
// we may lookup the wrong contact.
|
||||
// This could be fixed by looking up the contact with
|
||||
// highest `add_timestamp` to disambiguate.
|
||||
// Alternatively, this can be fixed by a header ChatGroupMemberAddedFpr,
|
||||
// just like we have ChatGroupMemberRemovedFpr.
|
||||
// The result of the error is that info message
|
||||
// may contain display name of the wrong contact.
|
||||
let fingerprint = key.dc_fingerprint().hex();
|
||||
let fingerprint = key.public_key.dc_fingerprint().hex();
|
||||
if let Some(contact_id) =
|
||||
lookup_key_contact_by_fingerprint(context, &fingerprint).await?
|
||||
{
|
||||
@@ -3047,9 +3080,7 @@ async fn apply_group_changes(
|
||||
|
||||
if let Some(added_id) = added_id {
|
||||
if !added_ids.remove(&added_id) && !self_added {
|
||||
// No-op "Member added" message.
|
||||
//
|
||||
// Trash it.
|
||||
info!(context, "No-op 'Member added' message (TRASH)");
|
||||
better_msg = Some(String::new());
|
||||
}
|
||||
}
|
||||
@@ -3245,6 +3276,7 @@ fn mailinglist_header_listid(list_id_header: &str) -> Result<String> {
|
||||
async fn create_or_lookup_mailinglist_or_broadcast(
|
||||
context: &Context,
|
||||
allow_creation: bool,
|
||||
create_blocked: Blocked,
|
||||
list_id_header: &str,
|
||||
from_id: ContactId,
|
||||
mime_parser: &MimeMessage,
|
||||
@@ -3277,18 +3309,12 @@ async fn create_or_lookup_mailinglist_or_broadcast(
|
||||
p.to_string()
|
||||
});
|
||||
|
||||
let is_bot = context.get_config_bool(Config::Bot).await?;
|
||||
let blocked = if is_bot {
|
||||
Blocked::Not
|
||||
} else {
|
||||
Blocked::Request
|
||||
};
|
||||
let chat_id = ChatId::create_multiuser_record(
|
||||
context,
|
||||
chattype,
|
||||
&listid,
|
||||
name,
|
||||
blocked,
|
||||
create_blocked,
|
||||
ProtectionStatus::Unprotected,
|
||||
param,
|
||||
mime_parser.timestamp_sent,
|
||||
@@ -3301,13 +3327,6 @@ async fn create_or_lookup_mailinglist_or_broadcast(
|
||||
)
|
||||
})?;
|
||||
|
||||
chat::add_to_chat_contacts_table(
|
||||
context,
|
||||
mime_parser.timestamp_sent,
|
||||
chat_id,
|
||||
&[ContactId::SELF],
|
||||
)
|
||||
.await?;
|
||||
if chattype == Chattype::InBroadcast {
|
||||
chat::add_to_chat_contacts_table(
|
||||
context,
|
||||
@@ -3317,7 +3336,12 @@ async fn create_or_lookup_mailinglist_or_broadcast(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(Some((chat_id, blocked)))
|
||||
|
||||
context.emit_event(EventType::ChatModified(chat_id));
|
||||
chatlist_events::emit_chatlist_changed(context);
|
||||
chatlist_events::emit_chatlist_item_changed(context, chat_id);
|
||||
|
||||
Ok(Some((chat_id, create_blocked)))
|
||||
} else {
|
||||
info!(context, "Creating list forbidden by caller.");
|
||||
Ok(None)
|
||||
@@ -3470,19 +3494,53 @@ async fn apply_out_broadcast_changes(
|
||||
) -> Result<GroupChangesInfo> {
|
||||
ensure!(chat.typ == Chattype::OutBroadcast);
|
||||
|
||||
if let Some(_removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) {
|
||||
// The sender of the message left the broadcast channel
|
||||
remove_from_chat_contacts_table(context, chat.id, from_id).await?;
|
||||
let mut send_event_chat_modified = false;
|
||||
let mut better_msg = None;
|
||||
|
||||
return Ok(GroupChangesInfo {
|
||||
better_msg: Some("".to_string()),
|
||||
added_removed_id: None,
|
||||
silent: true,
|
||||
extra_msgs: vec![],
|
||||
});
|
||||
apply_chat_name_and_avatar_changes(
|
||||
context,
|
||||
mime_parser,
|
||||
from_id,
|
||||
chat,
|
||||
&mut send_event_chat_modified,
|
||||
&mut better_msg,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(removed_fpr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemovedFpr) {
|
||||
send_event_chat_modified = true;
|
||||
let removed_id = lookup_key_contact_by_fingerprint(context, removed_fpr).await?;
|
||||
if removed_id == Some(from_id) {
|
||||
// The sender of the message left the broadcast channel
|
||||
// Silently remove them without notifying the user
|
||||
chat::remove_from_chat_contacts_table_without_trace(context, chat.id, from_id).await?;
|
||||
info!(context, "Broadcast leave message (TRASH)");
|
||||
better_msg = Some("".to_string());
|
||||
} else if from_id == ContactId::SELF {
|
||||
if let Some(removed_id) = removed_id {
|
||||
chat::remove_from_chat_contacts_table_without_trace(context, chat.id, removed_id)
|
||||
.await?;
|
||||
|
||||
better_msg.get_or_insert(
|
||||
stock_str::msg_del_member_local(context, removed_id, ContactId::SELF).await,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No need to check for ChatGroupMemberAdded:
|
||||
// The only way to add a member is by having them scan a QR code.
|
||||
// All devices will receive Bob's vb-request-with-auth message and add him to the channel.
|
||||
|
||||
Ok(GroupChangesInfo::default())
|
||||
if send_event_chat_modified {
|
||||
context.emit_event(EventType::ChatModified(chat.id));
|
||||
chatlist_events::emit_chatlist_item_changed(context, chat.id);
|
||||
}
|
||||
Ok(GroupChangesInfo {
|
||||
better_msg,
|
||||
added_removed_id: None,
|
||||
silent: false,
|
||||
extra_msgs: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_in_broadcast_changes(
|
||||
@@ -3493,6 +3551,16 @@ async fn apply_in_broadcast_changes(
|
||||
) -> Result<GroupChangesInfo> {
|
||||
ensure!(chat.typ == Chattype::InBroadcast);
|
||||
|
||||
if let Some(part) = mime_parser.parts.first() {
|
||||
if let Some(error) = &part.error {
|
||||
warn!(
|
||||
context,
|
||||
"Not applying broadcast changes from message with error: {error}"
|
||||
);
|
||||
return Ok(GroupChangesInfo::default());
|
||||
}
|
||||
}
|
||||
|
||||
let mut send_event_chat_modified = false;
|
||||
let mut better_msg = None;
|
||||
|
||||
@@ -3506,12 +3574,61 @@ async fn apply_in_broadcast_changes(
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(_removed_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemoved) {
|
||||
// The only member added/removed message that is ever sent is "I left.",
|
||||
// so, this is the only case we need to handle here
|
||||
if let Some(added_addr) = mime_parser.get_header(HeaderDef::ChatGroupMemberAdded) {
|
||||
if context.is_self_addr(added_addr).await? {
|
||||
let msg;
|
||||
|
||||
if chat.is_self_in_chat(context).await? {
|
||||
// Self is already in the chat.
|
||||
// Probably Alice has two devices and her second device added us again;
|
||||
// just hide the message.
|
||||
info!(context, "No-op broadcast 'Member added' message (TRASH)");
|
||||
msg = "".to_string();
|
||||
} else {
|
||||
chat.id
|
||||
.add_encrypted_msg(context, mime_parser.timestamp_sent)
|
||||
.await?;
|
||||
msg = stock_str::msg_add_member_local(context, ContactId::SELF, from_id).await;
|
||||
}
|
||||
|
||||
better_msg.get_or_insert(msg);
|
||||
send_event_chat_modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(removed_fpr) = mime_parser.get_header(HeaderDef::ChatGroupMemberRemovedFpr) {
|
||||
// We are not supposed to receive a notification when someone else than self is removed:
|
||||
ensure!(removed_fpr == self_fingerprint(context).await?);
|
||||
|
||||
if from_id == ContactId::SELF {
|
||||
better_msg
|
||||
.get_or_insert(stock_str::msg_group_left_local(context, ContactId::SELF).await);
|
||||
} else {
|
||||
better_msg.get_or_insert(
|
||||
stock_str::msg_del_member_local(context, ContactId::SELF, from_id).await,
|
||||
);
|
||||
}
|
||||
|
||||
chat::remove_from_chat_contacts_table_without_trace(context, chat.id, ContactId::SELF)
|
||||
.await?;
|
||||
send_event_chat_modified = true;
|
||||
} else if !chat.is_self_in_chat(context).await? {
|
||||
// Apparently, self is in the chat now, because we're receiving messages
|
||||
chat::add_to_chat_contacts_table(
|
||||
context,
|
||||
mime_parser.timestamp_sent,
|
||||
chat.id,
|
||||
&[ContactId::SELF],
|
||||
)
|
||||
.await?;
|
||||
send_event_chat_modified = true;
|
||||
}
|
||||
|
||||
if let Some(secret) = mime_parser.get_header(HeaderDef::ChatBroadcastSecret) {
|
||||
if validate_broadcast_shared_secret(secret) {
|
||||
save_broadcast_shared_secret(context, chat.id, secret).await?;
|
||||
} else {
|
||||
warn!(context, "Not saving invalid broadcast secret");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3650,6 +3767,25 @@ async fn mark_recipients_as_verified(
|
||||
to_ids: &[Option<ContactId>],
|
||||
mimeparser: &MimeMessage,
|
||||
) -> Result<()> {
|
||||
let verifier_id = Some(from_id).filter(|&id| id != ContactId::SELF);
|
||||
for gossiped_key in mimeparser
|
||||
.gossiped_keys
|
||||
.values()
|
||||
.filter(|gossiped_key| gossiped_key.verified)
|
||||
{
|
||||
let fingerprint = gossiped_key.public_key.dc_fingerprint().hex();
|
||||
let Some(to_id) = lookup_key_contact_by_fingerprint(context, &fingerprint).await? else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if to_id == ContactId::SELF || to_id == from_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
mark_contact_id_as_verified(context, to_id, verifier_id).await?;
|
||||
ChatId::set_protection_for_contact(context, to_id, mimeparser.timestamp_sent).await?;
|
||||
}
|
||||
|
||||
if mimeparser.get_header(HeaderDef::ChatVerified).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -3658,7 +3794,7 @@ async fn mark_recipients_as_verified(
|
||||
continue;
|
||||
}
|
||||
|
||||
mark_contact_id_as_verified(context, to_id, from_id).await?;
|
||||
mark_contact_id_as_verified(context, to_id, verifier_id).await?;
|
||||
ChatId::set_protection_for_contact(context, to_id, mimeparser.timestamp_sent).await?;
|
||||
}
|
||||
|
||||
@@ -3745,7 +3881,7 @@ async fn add_or_lookup_contacts_by_address_list(
|
||||
async fn add_or_lookup_key_contacts(
|
||||
context: &Context,
|
||||
address_list: &[SingleInfo],
|
||||
gossiped_keys: &HashMap<String, SignedPublicKey>,
|
||||
gossiped_keys: &BTreeMap<String, GossipedKey>,
|
||||
fingerprints: &[Fingerprint],
|
||||
origin: Origin,
|
||||
) -> Result<Vec<Option<ContactId>>> {
|
||||
@@ -3761,7 +3897,7 @@ async fn add_or_lookup_key_contacts(
|
||||
// Iterator has not ran out of fingerprints yet.
|
||||
fp.hex()
|
||||
} else if let Some(key) = gossiped_keys.get(addr) {
|
||||
key.dc_fingerprint().hex()
|
||||
key.public_key.dc_fingerprint().hex()
|
||||
} else if context.is_self_addr(addr).await? {
|
||||
contact_ids.push(Some(ContactId::SELF));
|
||||
continue;
|
||||
@@ -3793,13 +3929,16 @@ async fn add_or_lookup_key_contacts(
|
||||
/// Looks up a key-contact by email address.
|
||||
///
|
||||
/// If provided, `chat_id` must be an encrypted chat ID that has key-contacts inside.
|
||||
/// Otherwise the function searches in all contacts, returning the recently seen one.
|
||||
/// Otherwise the function searches in all contacts, preferring accepted and most recently seen ones.
|
||||
async fn lookup_key_contact_by_address(
|
||||
context: &Context,
|
||||
addr: &str,
|
||||
chat_id: Option<ChatId>,
|
||||
) -> Result<Option<ContactId>> {
|
||||
if context.is_self_addr(addr).await? {
|
||||
if chat_id.is_none() {
|
||||
return Ok(Some(ContactId::SELF));
|
||||
}
|
||||
let is_self_in_chat = context
|
||||
.sql
|
||||
.exists(
|
||||
@@ -3836,11 +3975,26 @@ async fn lookup_key_contact_by_address(
|
||||
.sql
|
||||
.query_row_optional(
|
||||
"SELECT id FROM contacts
|
||||
WHERE contacts.addr=?1
|
||||
WHERE addr=?
|
||||
AND fingerprint<>''
|
||||
ORDER BY last_seen DESC, id DESC
|
||||
ORDER BY
|
||||
(
|
||||
SELECT COUNT(*) FROM chats c
|
||||
INNER JOIN chats_contacts cc
|
||||
ON c.id=cc.chat_id
|
||||
WHERE c.type=?
|
||||
AND c.id>?
|
||||
AND c.blocked=?
|
||||
AND cc.contact_id=contacts.id
|
||||
) DESC,
|
||||
last_seen DESC, id DESC
|
||||
",
|
||||
(addr,),
|
||||
(
|
||||
addr,
|
||||
Chattype::Single,
|
||||
constants::DC_CHAT_ID_LAST_SPECIAL,
|
||||
Blocked::Not,
|
||||
),
|
||||
|row| {
|
||||
let contact_id: ContactId = row.get(0)?;
|
||||
Ok(contact_id)
|
||||
@@ -3948,5 +4102,12 @@ async fn lookup_key_contacts_by_address_list(
|
||||
Ok(contact_ids)
|
||||
}
|
||||
|
||||
/// Returns true if the message should not result in renaming
|
||||
/// of the sender contact.
|
||||
fn should_prevent_rename(mime_parser: &MimeMessage) -> bool {
|
||||
(mime_parser.is_mailinglist_message() && !mime_parser.was_encrypted())
|
||||
|| mime_parser.get_header(HeaderDef::Sender).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod receive_imf_tests;
|
||||
|
||||
@@ -881,7 +881,7 @@ async fn test_github_mailing_list() -> Result<()> {
|
||||
Some("reply+elernshsetushoyseshetihseusaferuhsedtisneu@reply.github.com")
|
||||
);
|
||||
assert_eq!(chat.name, "deltachat/deltachat-core-rust");
|
||||
assert_eq!(chat::get_chat_contacts(&t.ctx, chat_id).await?.len(), 1);
|
||||
assert_eq!(chat::get_chat_contacts(&t.ctx, chat_id).await?.len(), 0);
|
||||
|
||||
receive_imf(&t.ctx, GH_MAILINGLIST2.as_bytes(), false).await?;
|
||||
|
||||
@@ -3316,6 +3316,31 @@ async fn test_thunderbird_autocrypt() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that a message without an Autocrypt header is assigned to the key-contact
|
||||
/// by using the signature Issuer Fingerprint.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_issuer_fingerprint() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_contact_id = bob.add_or_lookup_contact_id(alice).await;
|
||||
|
||||
let raw = include_bytes!("../../test-data/message/encrypted-signed.eml");
|
||||
let received_msg = receive_imf(bob, raw, false).await?.unwrap();
|
||||
|
||||
assert_eq!(received_msg.msg_ids.len(), 1);
|
||||
let msg_id = received_msg.msg_ids[0];
|
||||
|
||||
let message = Message::load_from_db(bob, msg_id).await?;
|
||||
assert!(message.get_showpadlock());
|
||||
|
||||
let from_id = message.from_id;
|
||||
assert_eq!(from_id, alice_contact_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests reception of a message from Thunderbird with attached key.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_prefer_encrypt_mutual_if_encrypted() -> Result<()> {
|
||||
@@ -5091,6 +5116,58 @@ async fn test_two_group_securejoins() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_unverified_member_msg() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let fiona = &tcm.fiona().await;
|
||||
|
||||
let alice_chat_id =
|
||||
chat::create_group_chat(alice, ProtectionStatus::Protected, "Group").await?;
|
||||
let qr = get_securejoin_qr(alice, Some(alice_chat_id)).await?;
|
||||
|
||||
tcm.exec_securejoin_qr(bob, alice, &qr).await;
|
||||
tcm.exec_securejoin_qr(fiona, alice, &qr).await;
|
||||
|
||||
let fiona_chat_id = fiona.get_last_msg().await.chat_id;
|
||||
let fiona_sent_msg = fiona.send_text(fiona_chat_id, "Hi").await;
|
||||
|
||||
// The message is by non-verified member,
|
||||
// but the checks have been removed
|
||||
// and the message should be downloaded as usual.
|
||||
let bob_msg = bob.recv_msg(&fiona_sent_msg).await;
|
||||
assert_eq!(bob_msg.download_state, DownloadState::Done);
|
||||
assert_eq!(bob_msg.text, "Hi");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_dont_reverify_by_self_on_outgoing_msg() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let a0 = &tcm.alice().await;
|
||||
let a1 = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
let fiona = &tcm.fiona().await;
|
||||
|
||||
let bob_chat_id = chat::create_group_chat(bob, ProtectionStatus::Protected, "Group").await?;
|
||||
let qr = get_securejoin_qr(bob, Some(bob_chat_id)).await?;
|
||||
tcm.exec_securejoin_qr(fiona, bob, &qr).await;
|
||||
tcm.exec_securejoin_qr(a0, bob, &qr).await;
|
||||
tcm.exec_securejoin_qr(a1, bob, &qr).await;
|
||||
|
||||
let a0_chat_id = a0.get_last_msg().await.chat_id;
|
||||
let a0_sent_msg = a0.send_text(a0_chat_id, "Hi").await;
|
||||
a1.recv_msg(&a0_sent_msg).await;
|
||||
let a1_bob_id = a1.add_or_lookup_contact_id(bob).await;
|
||||
let a1_fiona = a1.add_or_lookup_contact(fiona).await;
|
||||
assert_eq!(
|
||||
a1_fiona.get_verifier_id(a1).await?.unwrap().unwrap(),
|
||||
a1_bob_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_sanitize_filename_in_received() -> Result<()> {
|
||||
let alice = &TestContext::new_alice().await;
|
||||
@@ -5352,3 +5429,131 @@ async fn test_group_introduction_no_gossip() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests reception of an encrypted group message
|
||||
/// without Chat-Group-ID.
|
||||
///
|
||||
/// The message should be displayed as
|
||||
/// encrypted and have key-contact `from_id`,
|
||||
/// but all group members should be address-contacts.
|
||||
///
|
||||
/// Due to a bug in v2.10.0 this resulted
|
||||
/// in creation of an ad hoc group with a key-contact.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_encrypted_adhoc_group_message() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
// Bob receives encrypted message from Alice
|
||||
// sent to multiple recipients,
|
||||
// but without a group ID.
|
||||
let received = receive_imf(
|
||||
bob,
|
||||
include_bytes!("../../test-data/message/encrypted-group-without-id.eml"),
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.unwrap();
|
||||
let msg = Message::load_from_db(bob, *received.msg_ids.last().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let chat = Chat::load_from_db(bob, msg.chat_id).await.unwrap();
|
||||
assert_eq!(chat.typ, Chattype::Group);
|
||||
assert_eq!(chat.is_encrypted(bob).await?, false);
|
||||
|
||||
let contact_ids = get_chat_contacts(bob, chat.id).await?;
|
||||
assert_eq!(contact_ids.len(), 3);
|
||||
assert!(chat.is_self_in_chat(bob).await?);
|
||||
|
||||
// Since the group is unencrypted, all contacts have
|
||||
// to be address-contacts.
|
||||
for contact_id in contact_ids {
|
||||
let contact = Contact::get_by_id(bob, contact_id).await?;
|
||||
if contact_id != ContactId::SELF {
|
||||
assert_eq!(contact.is_key_contact(), false);
|
||||
}
|
||||
}
|
||||
|
||||
// `from_id` of the message corresponds to key-contact of Alice
|
||||
// even though the message is assigned to unencrypted chat.
|
||||
let alice_contact_id = bob.add_or_lookup_contact_id(alice).await;
|
||||
assert_eq!(msg.from_id, alice_contact_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that messages sent to unencrypted group
|
||||
/// with only two members arrive in a group
|
||||
/// and not in 1:1 chat.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_small_unencrypted_group() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let alice_chat_id = chat::create_group_ex(alice, None, "Unencrypted group").await?;
|
||||
let alice_bob_id = alice.add_or_lookup_address_contact_id(bob).await;
|
||||
add_contact_to_chat(alice, alice_chat_id, alice_bob_id).await?;
|
||||
send_text_msg(alice, alice_chat_id, "Hello!".to_string()).await?;
|
||||
|
||||
let sent_msg = alice.pop_sent_msg().await;
|
||||
let bob_chat_id = bob.recv_msg(&sent_msg).await.chat_id;
|
||||
let bob_chat = Chat::load_from_db(bob, bob_chat_id).await?;
|
||||
|
||||
assert_eq!(bob_chat.typ, Chattype::Group);
|
||||
assert_eq!(bob_chat.is_encrypted(bob).await?, false);
|
||||
|
||||
bob_chat_id.accept(bob).await?;
|
||||
send_text_msg(bob, bob_chat_id, "Hello back!".to_string()).await?;
|
||||
let sent_msg = bob.pop_sent_msg().await;
|
||||
let alice_rcvd_msg = alice.recv_msg(&sent_msg).await;
|
||||
assert_eq!(alice_rcvd_msg.chat_id, alice_chat_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that if the sender includes self
|
||||
/// in the `To` field, we do not count
|
||||
/// it as a third recipient in addition to ourselves
|
||||
/// and the sender and do not create a group chat.
|
||||
///
|
||||
/// This is a regression test.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_bcc_not_a_group() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
|
||||
let received = receive_imf(
|
||||
alice,
|
||||
b"From: \"\"<foobar@example.org>\n\
|
||||
To: <foobar@example.org>\n\
|
||||
Subject: Hello, this is not a group\n\
|
||||
Message-ID: <abcdef@example.org>\n\
|
||||
Chat-Version: 1.0\n\
|
||||
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
|
||||
\n\
|
||||
hello\n",
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.unwrap();
|
||||
|
||||
let received_chat = Chat::load_from_db(alice, received.chat_id).await?;
|
||||
assert_eq!(received_chat.typ, Chattype::Single);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_lookup_key_contact_by_address_self() -> Result<()> {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let t = &tcm.alice().await;
|
||||
let addr = &t.get_config(Config::Addr).await?.unwrap();
|
||||
assert_eq!(
|
||||
lookup_key_contact_by_address(t, addr, None).await?,
|
||||
Some(ContactId::SELF)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ use async_channel::{self as channel, Receiver, Sender};
|
||||
use futures::future::try_join_all;
|
||||
use futures_lite::FutureExt;
|
||||
use rand::Rng;
|
||||
use tokio::sync::{RwLock, RwLockWriteGuard, oneshot};
|
||||
use tokio::sync::{RwLock, oneshot};
|
||||
use tokio::task;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio_util::task::TaskTracker;
|
||||
|
||||
use self::connectivity::ConnectivityStore;
|
||||
pub(crate) use self::connectivity::ConnectivityStore;
|
||||
use crate::config::{self, Config};
|
||||
use crate::constants;
|
||||
use crate::contact::{ContactId, RecentlySeenLoop};
|
||||
@@ -53,32 +53,32 @@ impl SchedulerState {
|
||||
}
|
||||
|
||||
/// Starts the scheduler if it is not yet started.
|
||||
pub(crate) async fn start(&self, context: Context) {
|
||||
pub(crate) async fn start(&self, context: &Context) {
|
||||
let mut inner = self.inner.write().await;
|
||||
match *inner {
|
||||
InnerSchedulerState::Started(_) => (),
|
||||
InnerSchedulerState::Stopped => Self::do_start(inner, context).await,
|
||||
InnerSchedulerState::Stopped => Self::do_start(&mut inner, context).await,
|
||||
InnerSchedulerState::Paused {
|
||||
ref mut started, ..
|
||||
} => *started = true,
|
||||
}
|
||||
context.update_connectivities(&inner);
|
||||
}
|
||||
|
||||
/// Starts the scheduler if it is not yet started.
|
||||
async fn do_start(mut inner: RwLockWriteGuard<'_, InnerSchedulerState>, context: Context) {
|
||||
async fn do_start(inner: &mut InnerSchedulerState, context: &Context) {
|
||||
info!(context, "starting IO");
|
||||
|
||||
// Notify message processing loop
|
||||
// to allow processing old messages after restart.
|
||||
context.new_msgs_notify.notify_one();
|
||||
|
||||
let ctx = context.clone();
|
||||
match Scheduler::start(&context).await {
|
||||
match Scheduler::start(context).await {
|
||||
Ok(scheduler) => {
|
||||
*inner = InnerSchedulerState::Started(scheduler);
|
||||
context.emit_event(EventType::ConnectivityChanged);
|
||||
}
|
||||
Err(err) => error!(&ctx, "Failed to start IO: {:#}", err),
|
||||
Err(err) => error!(context, "Failed to start IO: {:#}", err),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,18 +87,19 @@ impl SchedulerState {
|
||||
let mut inner = self.inner.write().await;
|
||||
match *inner {
|
||||
InnerSchedulerState::Started(_) => {
|
||||
Self::do_stop(inner, context, InnerSchedulerState::Stopped).await
|
||||
Self::do_stop(&mut inner, context, InnerSchedulerState::Stopped).await
|
||||
}
|
||||
InnerSchedulerState::Stopped => (),
|
||||
InnerSchedulerState::Paused {
|
||||
ref mut started, ..
|
||||
} => *started = false,
|
||||
}
|
||||
context.update_connectivities(&inner);
|
||||
}
|
||||
|
||||
/// Stops the scheduler if it is currently running.
|
||||
async fn do_stop(
|
||||
mut inner: RwLockWriteGuard<'_, InnerSchedulerState>,
|
||||
inner: &mut InnerSchedulerState,
|
||||
context: &Context,
|
||||
new_state: InnerSchedulerState,
|
||||
) {
|
||||
@@ -122,7 +123,7 @@ impl SchedulerState {
|
||||
debug_logging.loop_handle.abort();
|
||||
debug_logging.loop_handle.await.ok();
|
||||
}
|
||||
let prev_state = std::mem::replace(&mut *inner, new_state);
|
||||
let prev_state = std::mem::replace(inner, new_state);
|
||||
context.emit_event(EventType::ConnectivityChanged);
|
||||
match prev_state {
|
||||
InnerSchedulerState::Started(scheduler) => scheduler.stop(context).await,
|
||||
@@ -138,7 +139,7 @@ impl SchedulerState {
|
||||
/// If in the meantime [`SchedulerState::start`] or [`SchedulerState::stop`] is called
|
||||
/// resume will do the right thing and restore the scheduler to the state requested by
|
||||
/// the last call.
|
||||
pub(crate) async fn pause(&'_ self, context: Context) -> Result<IoPausedGuard> {
|
||||
pub(crate) async fn pause(&'_ self, context: &Context) -> Result<IoPausedGuard> {
|
||||
{
|
||||
let mut inner = self.inner.write().await;
|
||||
match *inner {
|
||||
@@ -147,7 +148,7 @@ impl SchedulerState {
|
||||
started: true,
|
||||
pause_guards_count: NonZeroUsize::new(1).unwrap(),
|
||||
};
|
||||
Self::do_stop(inner, &context, new_state).await;
|
||||
Self::do_stop(&mut inner, context, new_state).await;
|
||||
}
|
||||
InnerSchedulerState::Stopped => {
|
||||
*inner = InnerSchedulerState::Paused {
|
||||
@@ -164,9 +165,11 @@ impl SchedulerState {
|
||||
.ok_or_else(|| Error::msg("Too many pause guards active"))?
|
||||
}
|
||||
}
|
||||
context.update_connectivities(&inner);
|
||||
}
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let context = context.clone();
|
||||
tokio::spawn(async move {
|
||||
rx.await.ok();
|
||||
let mut inner = context.scheduler.inner.write().await;
|
||||
@@ -183,7 +186,7 @@ impl SchedulerState {
|
||||
} => {
|
||||
if *pause_guards_count == NonZeroUsize::new(1).unwrap() {
|
||||
match *started {
|
||||
true => SchedulerState::do_start(inner, context.clone()).await,
|
||||
true => SchedulerState::do_start(&mut inner, &context).await,
|
||||
false => *inner = InnerSchedulerState::Stopped,
|
||||
}
|
||||
} else {
|
||||
@@ -193,6 +196,7 @@ impl SchedulerState {
|
||||
}
|
||||
}
|
||||
}
|
||||
context.update_connectivities(&inner);
|
||||
});
|
||||
Ok(IoPausedGuard { sender: Some(tx) })
|
||||
}
|
||||
@@ -202,7 +206,7 @@ impl SchedulerState {
|
||||
info!(context, "restarting IO");
|
||||
if self.is_running().await {
|
||||
self.stop(context).await;
|
||||
self.start(context.clone()).await;
|
||||
self.start(context).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +227,7 @@ impl SchedulerState {
|
||||
_ => return,
|
||||
};
|
||||
drop(inner);
|
||||
connectivity::idle_interrupted(inbox, oboxes).await;
|
||||
connectivity::idle_interrupted(inbox, oboxes);
|
||||
}
|
||||
|
||||
/// Indicate that the network likely is lost.
|
||||
@@ -240,7 +244,7 @@ impl SchedulerState {
|
||||
_ => return,
|
||||
};
|
||||
drop(inner);
|
||||
connectivity::maybe_network_lost(context, stores).await;
|
||||
connectivity::maybe_network_lost(context, stores);
|
||||
}
|
||||
|
||||
pub(crate) async fn interrupt_inbox(&self) {
|
||||
@@ -288,7 +292,7 @@ impl SchedulerState {
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
enum InnerSchedulerState {
|
||||
pub(crate) enum InnerSchedulerState {
|
||||
Started(Scheduler),
|
||||
#[default]
|
||||
Stopped,
|
||||
@@ -565,7 +569,7 @@ async fn fetch_idle(
|
||||
// The folder is not configured.
|
||||
// For example, this happens if the server does not have Sent folder
|
||||
// but watching Sent folder is enabled.
|
||||
connection.connectivity.set_not_configured(ctx).await;
|
||||
connection.connectivity.set_not_configured(ctx);
|
||||
connection.idle_interrupt_receiver.recv().await.ok();
|
||||
bail!("Cannot fetch folder {folder_meaning} because it is not configured");
|
||||
};
|
||||
@@ -655,7 +659,7 @@ async fn fetch_idle(
|
||||
.log_err(ctx)
|
||||
.ok();
|
||||
|
||||
connection.connectivity.set_idle(ctx).await;
|
||||
connection.connectivity.set_idle(ctx);
|
||||
|
||||
ctx.emit_event(EventType::ImapInboxIdle);
|
||||
|
||||
@@ -806,8 +810,8 @@ async fn smtp_loop(
|
||||
// Fake Idle
|
||||
info!(ctx, "SMTP fake idle started.");
|
||||
match &connection.last_send_error {
|
||||
None => connection.connectivity.set_idle(&ctx).await,
|
||||
Some(err) => connection.connectivity.set_err(&ctx, err).await,
|
||||
None => connection.connectivity.set_idle(&ctx),
|
||||
Some(err) => connection.connectivity.set_err(&ctx, err),
|
||||
}
|
||||
|
||||
// If send_smtp_messages() failed, we set a timeout for the fake-idle so that
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::{iter::once, ops::Deref, sync::Arc};
|
||||
|
||||
use anyhow::Result;
|
||||
use humansize::{BINARY, format_size};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::events::EventType;
|
||||
use crate::imap::{FolderMeaning, scan_folders::get_watched_folder_configs};
|
||||
@@ -160,52 +159,51 @@ impl DetailedConnectivity {
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct ConnectivityStore(Arc<Mutex<DetailedConnectivity>>);
|
||||
pub(crate) struct ConnectivityStore(Arc<parking_lot::Mutex<DetailedConnectivity>>);
|
||||
|
||||
impl ConnectivityStore {
|
||||
async fn set(&self, context: &Context, v: DetailedConnectivity) {
|
||||
fn set(&self, context: &Context, v: DetailedConnectivity) {
|
||||
{
|
||||
*self.0.lock().await = v;
|
||||
*self.0.lock() = v;
|
||||
}
|
||||
context.emit_event(EventType::ConnectivityChanged);
|
||||
}
|
||||
|
||||
pub(crate) async fn set_err(&self, context: &Context, e: impl ToString) {
|
||||
self.set(context, DetailedConnectivity::Error(e.to_string()))
|
||||
.await;
|
||||
pub(crate) fn set_err(&self, context: &Context, e: impl ToString) {
|
||||
self.set(context, DetailedConnectivity::Error(e.to_string()));
|
||||
}
|
||||
pub(crate) async fn set_connecting(&self, context: &Context) {
|
||||
self.set(context, DetailedConnectivity::Connecting).await;
|
||||
pub(crate) fn set_connecting(&self, context: &Context) {
|
||||
self.set(context, DetailedConnectivity::Connecting);
|
||||
}
|
||||
pub(crate) async fn set_working(&self, context: &Context) {
|
||||
self.set(context, DetailedConnectivity::Working).await;
|
||||
pub(crate) fn set_working(&self, context: &Context) {
|
||||
self.set(context, DetailedConnectivity::Working);
|
||||
}
|
||||
pub(crate) async fn set_preparing(&self, context: &Context) {
|
||||
self.set(context, DetailedConnectivity::Preparing).await;
|
||||
pub(crate) fn set_preparing(&self, context: &Context) {
|
||||
self.set(context, DetailedConnectivity::Preparing);
|
||||
}
|
||||
pub(crate) async fn set_not_configured(&self, context: &Context) {
|
||||
self.set(context, DetailedConnectivity::NotConfigured).await;
|
||||
pub(crate) fn set_not_configured(&self, context: &Context) {
|
||||
self.set(context, DetailedConnectivity::NotConfigured);
|
||||
}
|
||||
pub(crate) async fn set_idle(&self, context: &Context) {
|
||||
self.set(context, DetailedConnectivity::Idle).await;
|
||||
pub(crate) fn set_idle(&self, context: &Context) {
|
||||
self.set(context, DetailedConnectivity::Idle);
|
||||
}
|
||||
|
||||
async fn get_detailed(&self) -> DetailedConnectivity {
|
||||
self.0.lock().await.deref().clone()
|
||||
fn get_detailed(&self) -> DetailedConnectivity {
|
||||
self.0.lock().deref().clone()
|
||||
}
|
||||
async fn get_basic(&self) -> Option<Connectivity> {
|
||||
self.0.lock().await.to_basic()
|
||||
fn get_basic(&self) -> Option<Connectivity> {
|
||||
self.0.lock().to_basic()
|
||||
}
|
||||
async fn get_all_work_done(&self) -> bool {
|
||||
self.0.lock().await.all_work_done()
|
||||
fn get_all_work_done(&self) -> bool {
|
||||
self.0.lock().all_work_done()
|
||||
}
|
||||
}
|
||||
|
||||
/// Set all folder states to InterruptingIdle in case they were `Idle` before.
|
||||
/// Called during `dc_maybe_network()` to make sure that `all_work_done()`
|
||||
/// returns false immediately after `dc_maybe_network()`.
|
||||
pub(crate) async fn idle_interrupted(inbox: ConnectivityStore, oboxes: Vec<ConnectivityStore>) {
|
||||
let mut connectivity_lock = inbox.0.lock().await;
|
||||
pub(crate) fn idle_interrupted(inbox: ConnectivityStore, oboxes: Vec<ConnectivityStore>) {
|
||||
let mut connectivity_lock = inbox.0.lock();
|
||||
// For the inbox, we also have to set the connectivity to InterruptingIdle if it was
|
||||
// NotConfigured before: If all folders are NotConfigured, dc_get_connectivity()
|
||||
// returns Connected. But after dc_maybe_network(), dc_get_connectivity() must not
|
||||
@@ -219,7 +217,7 @@ pub(crate) async fn idle_interrupted(inbox: ConnectivityStore, oboxes: Vec<Conne
|
||||
drop(connectivity_lock);
|
||||
|
||||
for state in oboxes {
|
||||
let mut connectivity_lock = state.0.lock().await;
|
||||
let mut connectivity_lock = state.0.lock();
|
||||
if *connectivity_lock == DetailedConnectivity::Idle {
|
||||
*connectivity_lock = DetailedConnectivity::InterruptingIdle;
|
||||
}
|
||||
@@ -231,9 +229,9 @@ pub(crate) async fn idle_interrupted(inbox: ConnectivityStore, oboxes: Vec<Conne
|
||||
/// Set the connectivity to "Not connected" after a call to dc_maybe_network_lost().
|
||||
/// If we did not do this, the connectivity would stay "Connected" for quite a long time
|
||||
/// after `maybe_network_lost()` was called.
|
||||
pub(crate) async fn maybe_network_lost(context: &Context, stores: Vec<ConnectivityStore>) {
|
||||
pub(crate) fn maybe_network_lost(context: &Context, stores: Vec<ConnectivityStore>) {
|
||||
for store in &stores {
|
||||
let mut connectivity_lock = store.0.lock().await;
|
||||
let mut connectivity_lock = store.0.lock();
|
||||
if !matches!(
|
||||
*connectivity_lock,
|
||||
DetailedConnectivity::Uninitialized
|
||||
@@ -248,7 +246,7 @@ pub(crate) async fn maybe_network_lost(context: &Context, stores: Vec<Connectivi
|
||||
|
||||
impl fmt::Debug for ConnectivityStore {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if let Ok(guard) = self.0.try_lock() {
|
||||
if let Some(guard) = self.0.try_lock() {
|
||||
write!(f, "ConnectivityStore {:?}", &*guard)
|
||||
} else {
|
||||
write!(f, "ConnectivityStore [LOCKED]")
|
||||
@@ -271,27 +269,29 @@ impl Context {
|
||||
/// e.g. in the title of the main screen.
|
||||
///
|
||||
/// If the connectivity changes, a DC_EVENT_CONNECTIVITY_CHANGED will be emitted.
|
||||
pub async fn get_connectivity(&self) -> Connectivity {
|
||||
let lock = self.scheduler.inner.read().await;
|
||||
let stores: Vec<_> = match *lock {
|
||||
InnerSchedulerState::Started(ref sched) => sched
|
||||
.boxes()
|
||||
.map(|b| b.conn_state.state.connectivity.clone())
|
||||
.collect(),
|
||||
_ => return Connectivity::NotConnected,
|
||||
};
|
||||
drop(lock);
|
||||
|
||||
pub fn get_connectivity(&self) -> Connectivity {
|
||||
let stores = self.connectivities.lock().clone();
|
||||
let mut connectivities = Vec::new();
|
||||
for s in stores {
|
||||
if let Some(connectivity) = s.get_basic().await {
|
||||
if let Some(connectivity) = s.get_basic() {
|
||||
connectivities.push(connectivity);
|
||||
}
|
||||
}
|
||||
connectivities
|
||||
.into_iter()
|
||||
.min()
|
||||
.unwrap_or(Connectivity::Connected)
|
||||
.unwrap_or(Connectivity::NotConnected)
|
||||
}
|
||||
|
||||
pub(crate) fn update_connectivities(&self, sched: &InnerSchedulerState) {
|
||||
let stores: Vec<_> = match sched {
|
||||
InnerSchedulerState::Started(sched) => sched
|
||||
.boxes()
|
||||
.map(|b| b.conn_state.state.connectivity.clone())
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
*self.connectivities.lock() = stores;
|
||||
}
|
||||
|
||||
/// Get an overview of the current connectivity, and possibly more statistics.
|
||||
@@ -391,7 +391,7 @@ impl Context {
|
||||
let f = self.get_config(config).await.log_err(self).ok().flatten();
|
||||
|
||||
if let Some(foldername) = f {
|
||||
let detailed = &state.get_detailed().await;
|
||||
let detailed = &state.get_detailed();
|
||||
ret += "<li>";
|
||||
ret += &*detailed.to_icon();
|
||||
ret += " <b>";
|
||||
@@ -405,7 +405,7 @@ impl Context {
|
||||
}
|
||||
|
||||
if !folder_added && folder == &FolderMeaning::Inbox {
|
||||
let detailed = &state.get_detailed().await;
|
||||
let detailed = &state.get_detailed();
|
||||
if let DetailedConnectivity::Error(_) = detailed {
|
||||
// On the inbox thread, we also do some other things like scan_folders and run jobs
|
||||
// so, maybe, the inbox is not watched, but something else went wrong
|
||||
@@ -427,7 +427,7 @@ impl Context {
|
||||
|
||||
let outgoing_messages = stock_str::outgoing_messages(self).await;
|
||||
ret += &format!("<h3>{outgoing_messages}</h3><ul><li>");
|
||||
let detailed = smtp.get_detailed().await;
|
||||
let detailed = smtp.get_detailed();
|
||||
ret += &*detailed.to_icon();
|
||||
ret += " ";
|
||||
ret += &*escaper::encode_minimal(&detailed.to_string_smtp(self).await);
|
||||
@@ -551,7 +551,7 @@ impl Context {
|
||||
drop(lock);
|
||||
|
||||
for s in &stores {
|
||||
if !s.get_all_work_done().await {
|
||||
if !s.get_all_work_done() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Implementation of [SecureJoin protocols](https://securejoin.delta.chat/).
|
||||
|
||||
use anyhow::{Context as _, Error, Result, ensure};
|
||||
use anyhow::{Context as _, Error, Result, bail, ensure};
|
||||
use deltachat_contact_tools::ContactAddress;
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
|
||||
@@ -32,23 +32,37 @@ use qrinvite::QrInvite;
|
||||
|
||||
use crate::token::Namespace;
|
||||
|
||||
fn inviter_progress(context: &Context, contact_id: ContactId, progress: usize) {
|
||||
fn inviter_progress(
|
||||
context: &Context,
|
||||
contact_id: ContactId,
|
||||
step: &str,
|
||||
progress: usize,
|
||||
) -> Result<()> {
|
||||
logged_debug_assert!(
|
||||
context,
|
||||
progress <= 1000,
|
||||
"inviter_progress: contact {contact_id}, progress={progress}, but value in range 0..1000 expected with: 0=error, 1..999=progress, 1000=success."
|
||||
);
|
||||
let chat_type = match step.get(..3) {
|
||||
Some("vc-") => Chattype::Single,
|
||||
Some("vg-") => Chattype::Group,
|
||||
Some("vb-") => Chattype::OutBroadcast,
|
||||
_ => bail!("Unknown securejoin step {step}"),
|
||||
};
|
||||
context.emit_event(EventType::SecurejoinInviterProgress {
|
||||
contact_id,
|
||||
chat_type,
|
||||
progress,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generates a Secure Join QR code.
|
||||
///
|
||||
/// With `group` set to `None` this generates a setup-contact QR code, with `group` set to a
|
||||
/// [`ChatId`] generates a join-group QR code for the given chat.
|
||||
pub async fn get_securejoin_qr(context: &Context, group: Option<ChatId>) -> Result<String> {
|
||||
/// With `chat` set to `None` this generates a setup-contact QR code, with `chat` set to a
|
||||
/// [`ChatId`] generates a join-group/join-broadcast-channel QR code for the given chat.
|
||||
pub async fn get_securejoin_qr(context: &Context, chat: Option<ChatId>) -> Result<String> {
|
||||
/*=======================================================
|
||||
==== Alice - the inviter side ====
|
||||
==== Step 1 in "Setup verified contact" protocol ====
|
||||
@@ -56,17 +70,19 @@ pub async fn get_securejoin_qr(context: &Context, group: Option<ChatId>) -> Resu
|
||||
|
||||
ensure_secret_key_exists(context).await.ok();
|
||||
|
||||
let chat = match group {
|
||||
let chat = match chat {
|
||||
Some(id) => {
|
||||
let chat = Chat::load_from_db(context, id).await?;
|
||||
ensure!(
|
||||
chat.typ == Chattype::Group,
|
||||
"Can't generate SecureJoin QR code for 1:1 chat {id}"
|
||||
);
|
||||
ensure!(
|
||||
!chat.grpid.is_empty(),
|
||||
"Can't generate SecureJoin QR code for ad-hoc group {id}"
|
||||
chat.typ == Chattype::Group || chat.typ == Chattype::OutBroadcast,
|
||||
"Can't generate SecureJoin QR code for chat {id} of type {}",
|
||||
chat.typ
|
||||
);
|
||||
if chat.grpid.is_empty() {
|
||||
let err = format!("Can't generate QR code, chat {id} is a email thread");
|
||||
error!(context, "get_securejoin_qr: {}.", err);
|
||||
bail!(err);
|
||||
}
|
||||
Some(chat)
|
||||
}
|
||||
None => None,
|
||||
@@ -93,24 +109,40 @@ pub async fn get_securejoin_qr(context: &Context, group: Option<ChatId>) -> Resu
|
||||
utf8_percent_encode(&self_name, NON_ALPHANUMERIC_WITHOUT_DOT).to_string();
|
||||
|
||||
let qr = if let Some(chat) = chat {
|
||||
// parameters used: a=g=x=i=s=
|
||||
let group_name = chat.get_name();
|
||||
let group_name_urlencoded = utf8_percent_encode(group_name, NON_ALPHANUMERIC).to_string();
|
||||
if sync_token {
|
||||
context
|
||||
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
|
||||
.await?;
|
||||
context.scheduler.interrupt_inbox().await;
|
||||
}
|
||||
format!(
|
||||
"https://i.delta.chat/#{}&a={}&g={}&x={}&i={}&s={}",
|
||||
fingerprint.hex(),
|
||||
self_addr_urlencoded,
|
||||
&group_name_urlencoded,
|
||||
&chat.grpid,
|
||||
&invitenumber,
|
||||
&auth,
|
||||
)
|
||||
|
||||
if chat.typ == Chattype::OutBroadcast {
|
||||
let broadcast_name = chat.get_name();
|
||||
let broadcast_name_urlencoded =
|
||||
utf8_percent_encode(broadcast_name, NON_ALPHANUMERIC).to_string();
|
||||
format!(
|
||||
"https://i.delta.chat/#{}&a={}&b={}&x={}&s={}",
|
||||
fingerprint.hex(),
|
||||
self_addr_urlencoded,
|
||||
&broadcast_name_urlencoded,
|
||||
&chat.grpid,
|
||||
&auth,
|
||||
)
|
||||
} else {
|
||||
// parameters used: a=g=x=i=s=
|
||||
let group_name = chat.get_name();
|
||||
let group_name_urlencoded =
|
||||
utf8_percent_encode(group_name, NON_ALPHANUMERIC).to_string();
|
||||
format!(
|
||||
"https://i.delta.chat/#{}&a={}&g={}&x={}&i={}&s={}",
|
||||
fingerprint.hex(),
|
||||
self_addr_urlencoded,
|
||||
&group_name_urlencoded,
|
||||
&chat.grpid,
|
||||
&invitenumber,
|
||||
&auth,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// parameters used: a=n=i=s=
|
||||
if sync_token {
|
||||
@@ -209,7 +241,7 @@ async fn verify_sender_by_fingerprint(
|
||||
let contact = Contact::get_by_id(context, contact_id).await?;
|
||||
let is_verified = contact.fingerprint().is_some_and(|fp| &fp == fingerprint);
|
||||
if is_verified {
|
||||
mark_contact_id_as_verified(context, contact_id, ContactId::SELF).await?;
|
||||
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
|
||||
}
|
||||
Ok(is_verified)
|
||||
}
|
||||
@@ -265,13 +297,34 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
|
||||
info!(context, "Received secure-join message {step:?}.");
|
||||
|
||||
let join_vg = step.starts_with("vg-");
|
||||
|
||||
if !matches!(step, "vg-request" | "vc-request") {
|
||||
// Opportunistically protect against a theoretical 'surreptitious forwarding' attack:
|
||||
// If Eve obtains a QR code from Alice and starts a securejoin with her,
|
||||
// and also lets Bob scan a manipulated QR code,
|
||||
// she could reencrypt the v*-request-with-auth message to Bob while maintaining the signature,
|
||||
// and Bob would regard the message as valid.
|
||||
//
|
||||
// This attack is not actually relevant in any threat model,
|
||||
// because if Eve can see Alice's QR code and have Bob scan a manipulated QR code,
|
||||
// she can just do a classical MitM attack.
|
||||
//
|
||||
// Protecting all messages sent by Delta Chat against 'surreptitious forwarding'
|
||||
// by checking the 'intended recipient fingerprint'
|
||||
// will improve security (completely unrelated to the securejoin protocol)
|
||||
// and is something we want to do in the future:
|
||||
// https://www.rfc-editor.org/rfc/rfc9580.html#name-surreptitious-forwarding
|
||||
if !matches!(step, "vg-request" | "vc-request" | "vb-request-with-auth") {
|
||||
// We don't perform this check for `vb-request-with-auth`:
|
||||
// Since the message is encrypted symmetrically,
|
||||
// there are no gossip headers,
|
||||
// so we can't easily do the same check as for asymmetrically encrypted secure-join messages.
|
||||
// Because this check doesn't add protection in any threat model,
|
||||
// we just skip it for vb-request-with-auth.
|
||||
let mut self_found = false;
|
||||
let self_fingerprint = load_self_public_key(context).await?.dc_fingerprint();
|
||||
for (addr, key) in &mime_message.gossiped_keys {
|
||||
if key.dc_fingerprint() == self_fingerprint && context.is_self_addr(addr).await? {
|
||||
if key.public_key.dc_fingerprint() == self_fingerprint
|
||||
&& context.is_self_addr(addr).await?
|
||||
{
|
||||
self_found = true;
|
||||
break;
|
||||
}
|
||||
@@ -307,7 +360,7 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
}
|
||||
|
||||
inviter_progress(context, contact_id, 300);
|
||||
inviter_progress(context, contact_id, step, 300)?;
|
||||
|
||||
let from_addr = ContactAddress::new(&mime_message.from.addr)?;
|
||||
let autocrypt_fingerprint = mime_message.autocrypt_fingerprint.as_deref().unwrap_or("");
|
||||
@@ -337,7 +390,7 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
========================================================*/
|
||||
bob::handle_auth_required(context, mime_message).await
|
||||
}
|
||||
"vg-request-with-auth" | "vc-request-with-auth" => {
|
||||
"vg-request-with-auth" | "vc-request-with-auth" | "vb-request-with-auth" => {
|
||||
/*==========================================================
|
||||
==== Alice - the inviter side ====
|
||||
==== Steps 5+6 in "Setup verified contact" protocol ====
|
||||
@@ -360,7 +413,8 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
);
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
}
|
||||
// verify that the `Secure-Join-Auth:`-header matches the secret written to the QR code
|
||||
// verify that the `Secure-Join-Auth:`-header matches the secret written to the QR code,
|
||||
// or that the message was encrypted with the secret written to the QR code.
|
||||
let Some(auth) = mime_message.get_header(HeaderDef::SecureJoinAuth) else {
|
||||
warn!(
|
||||
context,
|
||||
@@ -398,11 +452,11 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
ContactId::scaleup_origin(context, &[contact_id], Origin::SecurejoinInvited).await?;
|
||||
// for setup-contact, make Alice's one-to-one chat with Bob visible
|
||||
// (secure-join-information are shown in the group chat)
|
||||
if !join_vg {
|
||||
if step.starts_with("vc-") {
|
||||
ChatId::create_for_contact(context, contact_id).await?;
|
||||
}
|
||||
context.emit_event(EventType::ContactsChanged(Some(contact_id)));
|
||||
inviter_progress(context, contact_id, 600);
|
||||
inviter_progress(context, contact_id, step, 600)?;
|
||||
if let Some(group_chat_id) = group_chat_id {
|
||||
// Join group.
|
||||
secure_connection_established(
|
||||
@@ -412,13 +466,21 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
mime_message.timestamp_sent,
|
||||
)
|
||||
.await?;
|
||||
|
||||
chat::add_contact_to_chat_ex(context, Nosync, group_chat_id, contact_id, true)
|
||||
.await?;
|
||||
inviter_progress(context, contact_id, 800);
|
||||
inviter_progress(context, contact_id, 1000);
|
||||
// IMAP-delete the message to avoid handling it by another device and adding the
|
||||
// member twice. Another device will know the member's key from Autocrypt-Gossip.
|
||||
Ok(HandshakeMessage::Done)
|
||||
inviter_progress(context, contact_id, step, 800)?;
|
||||
inviter_progress(context, contact_id, step, 1000)?;
|
||||
if step == "vb-request-with-auth" {
|
||||
// For broadcasts, we don't want to delete the message,
|
||||
// because the other device should also internally add the member
|
||||
// and see the key (because it won't see the member via autocrypt-gossip).
|
||||
Ok(HandshakeMessage::Ignore)
|
||||
} else {
|
||||
// IMAP-delete the message to avoid handling it by another device and adding the
|
||||
// member twice. Another device will know the member's key from Autocrypt-Gossip.
|
||||
Ok(HandshakeMessage::Done)
|
||||
}
|
||||
} else {
|
||||
// Setup verified contact.
|
||||
secure_connection_established(
|
||||
@@ -432,7 +494,7 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
.await
|
||||
.context("failed sending vc-contact-confirm message")?;
|
||||
|
||||
inviter_progress(context, contact_id, 1000);
|
||||
inviter_progress(context, contact_id, step, 1000)?;
|
||||
Ok(HandshakeMessage::Ignore) // "Done" would delete the message and break multi-device (the key from Autocrypt-header is needed)
|
||||
}
|
||||
}
|
||||
@@ -447,7 +509,7 @@ pub(crate) async fn handle_securejoin_handshake(
|
||||
});
|
||||
Ok(HandshakeMessage::Ignore)
|
||||
}
|
||||
"vg-member-added" => {
|
||||
"vg-member-added" | "vb-member-added" => {
|
||||
let Some(member_added) = mime_message.get_header(HeaderDef::ChatGroupMemberAdded)
|
||||
else {
|
||||
warn!(
|
||||
@@ -516,6 +578,13 @@ pub(crate) async fn observe_securejoin_on_other_device(
|
||||
step,
|
||||
"vg-request-with-auth" | "vc-request-with-auth" | "vg-member-added" | "vc-contact-confirm"
|
||||
) {
|
||||
// `vb-request-with-auth` can be ignored
|
||||
// because we wouldn't be able to decrypt the message
|
||||
// (it's symmetrically encrypted with the AUTH token, which only the scanning device knows);
|
||||
// instead, the verification is transferred via a `MarkVerified` sync message.
|
||||
// `vb-member-added` can be ignored
|
||||
// because all devices receive the `vb-request-with-auth` message
|
||||
// and mark Bob as verified because of this.
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
};
|
||||
|
||||
@@ -541,21 +610,21 @@ pub(crate) async fn observe_securejoin_on_other_device(
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
};
|
||||
|
||||
if key.dc_fingerprint() != contact_fingerprint {
|
||||
if key.public_key.dc_fingerprint() != contact_fingerprint {
|
||||
// Fingerprint does not match, ignore.
|
||||
warn!(context, "Fingerprint does not match.");
|
||||
return Ok(HandshakeMessage::Ignore);
|
||||
}
|
||||
|
||||
mark_contact_id_as_verified(context, contact_id, ContactId::SELF).await?;
|
||||
mark_contact_id_as_verified(context, contact_id, Some(ContactId::SELF)).await?;
|
||||
|
||||
ChatId::set_protection_for_contact(context, contact_id, mime_message.timestamp_sent).await?;
|
||||
|
||||
if step == "vg-member-added" {
|
||||
inviter_progress(context, contact_id, 800);
|
||||
inviter_progress(context, contact_id, step, 800)?;
|
||||
}
|
||||
if step == "vg-member-added" || step == "vc-contact-confirm" {
|
||||
inviter_progress(context, contact_id, 1000);
|
||||
inviter_progress(context, contact_id, step, 1000)?;
|
||||
}
|
||||
|
||||
if step == "vg-request-with-auth" || step == "vc-request-with-auth" {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Bob's side of SecureJoin handling, the joiner-side.
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
|
||||
use super::HandshakeMessage;
|
||||
use super::qrinvite::QrInvite;
|
||||
@@ -10,14 +10,14 @@ use crate::contact::Origin;
|
||||
use crate::context::Context;
|
||||
use crate::events::EventType;
|
||||
use crate::key::self_fingerprint;
|
||||
use crate::log::info;
|
||||
use crate::log::{LogExt as _, info};
|
||||
use crate::message::{Message, Viewtype};
|
||||
use crate::mimeparser::{MimeMessage, SystemMessage};
|
||||
use crate::param::Param;
|
||||
use crate::securejoin::{ContactId, encrypted_and_signed, verify_sender_by_fingerprint};
|
||||
use crate::stock_str;
|
||||
use crate::sync::Sync::*;
|
||||
use crate::tools::{create_smeared_timestamp, time};
|
||||
use crate::tools::{smeared_time, time};
|
||||
|
||||
/// Starts the securejoin protocol with the QR `invite`.
|
||||
///
|
||||
@@ -47,16 +47,49 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
let hidden = match invite {
|
||||
QrInvite::Contact { .. } => Blocked::Not,
|
||||
QrInvite::Group { .. } => Blocked::Yes,
|
||||
QrInvite::Broadcast { .. } => Blocked::Yes,
|
||||
};
|
||||
let chat_id = ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden)
|
||||
.await
|
||||
.with_context(|| format!("can't create chat for contact {}", invite.contact_id()))?;
|
||||
|
||||
// The 1:1 chat with the inviter
|
||||
let private_chat_id =
|
||||
ChatId::create_for_contact_with_blocked(context, invite.contact_id(), hidden)
|
||||
.await
|
||||
.with_context(|| format!("can't create chat for contact {}", invite.contact_id()))?;
|
||||
|
||||
// The chat id of the 1:1 chat, group or broadcast that is being joined
|
||||
let joining_chat_id = joining_chat_id(context, &invite, private_chat_id).await?;
|
||||
|
||||
ContactId::scaleup_origin(context, &[invite.contact_id()], Origin::SecurejoinJoined).await?;
|
||||
context.emit_event(EventType::ContactsChanged(None));
|
||||
|
||||
// Now start the protocol and initialise the state.
|
||||
{
|
||||
if invite.is_v2() {
|
||||
if !verify_sender_by_fingerprint(context, invite.fingerprint(), invite.contact_id()).await?
|
||||
{
|
||||
bail!("V2 protocol failed because of fingerprint mismatch");
|
||||
}
|
||||
info!(context, "Using fast securejoin with symmetric encryption");
|
||||
|
||||
send_handshake_message(
|
||||
context,
|
||||
&invite,
|
||||
private_chat_id,
|
||||
BobHandshakeMsg::RequestWithAuth,
|
||||
)
|
||||
.await?;
|
||||
|
||||
context.emit_event(EventType::SecurejoinJoinerProgress {
|
||||
contact_id: invite.contact_id(),
|
||||
progress: JoinerProgress::RequestWithAuthSent.to_usize(),
|
||||
});
|
||||
|
||||
// Our second device won't be able to decrypt the outgoing message
|
||||
// because it will be symmetrically encrypted with the AUTH token.
|
||||
// So, we need to send a sync message:
|
||||
let id = chat::SyncId::ContactFingerprint(invite.fingerprint().hex());
|
||||
let action = chat::SyncAction::MarkVerified;
|
||||
chat::sync(context, id, action).await.log_err(context).ok();
|
||||
} else {
|
||||
// Start the version 1 protocol and initialise the state.
|
||||
let has_key = context
|
||||
.sql
|
||||
.exists(
|
||||
@@ -71,11 +104,16 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
{
|
||||
// The scanned fingerprint matches Alice's key, we can proceed to step 4b.
|
||||
info!(context, "Taking securejoin protocol shortcut");
|
||||
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::RequestWithAuth)
|
||||
.await?;
|
||||
send_handshake_message(
|
||||
context,
|
||||
&invite,
|
||||
private_chat_id,
|
||||
BobHandshakeMsg::RequestWithAuth,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Mark 1:1 chat as verified already.
|
||||
chat_id
|
||||
private_chat_id
|
||||
.set_protection(
|
||||
context,
|
||||
ProtectionStatus::Protected,
|
||||
@@ -89,9 +127,10 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
progress: JoinerProgress::RequestWithAuthSent.to_usize(),
|
||||
});
|
||||
} else {
|
||||
send_handshake_message(context, &invite, chat_id, BobHandshakeMsg::Request).await?;
|
||||
send_handshake_message(context, &invite, private_chat_id, BobHandshakeMsg::Request)
|
||||
.await?;
|
||||
|
||||
insert_new_db_entry(context, invite.clone(), chat_id).await?;
|
||||
insert_new_db_entry(context, invite.clone(), private_chat_id).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,19 +138,35 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
QrInvite::Group { .. } => {
|
||||
// For a secure-join we need to create the group and add the contact. The group will
|
||||
// only become usable once the protocol is finished.
|
||||
let group_chat_id = joining_chat_id(context, &invite, chat_id).await?;
|
||||
if !is_contact_in_chat(context, group_chat_id, invite.contact_id()).await? {
|
||||
if !is_contact_in_chat(context, joining_chat_id, invite.contact_id()).await? {
|
||||
chat::add_to_chat_contacts_table(
|
||||
context,
|
||||
time(),
|
||||
group_chat_id,
|
||||
joining_chat_id,
|
||||
&[invite.contact_id()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let msg = stock_str::secure_join_started(context, invite.contact_id()).await;
|
||||
chat::add_info_msg(context, group_chat_id, &msg, time()).await?;
|
||||
Ok(group_chat_id)
|
||||
chat::add_info_msg(context, joining_chat_id, &msg, time()).await?;
|
||||
Ok(joining_chat_id)
|
||||
}
|
||||
QrInvite::Broadcast { .. } => {
|
||||
if !is_contact_in_chat(context, joining_chat_id, invite.contact_id()).await? {
|
||||
chat::add_to_chat_contacts_table(
|
||||
context,
|
||||
time(),
|
||||
joining_chat_id,
|
||||
&[invite.contact_id()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !is_contact_in_chat(context, joining_chat_id, ContactId::SELF).await? {
|
||||
let msg = stock_str::securejoin_wait(context).await;
|
||||
chat::add_info_msg(context, joining_chat_id, &msg, time()).await?;
|
||||
}
|
||||
Ok(joining_chat_id)
|
||||
}
|
||||
QrInvite::Contact { .. } => {
|
||||
// For setup-contact the BobState already ensured the 1:1 chat exists because it
|
||||
@@ -120,14 +175,14 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
// race with its change, we don't add our message below the protection message.
|
||||
let sort_to_bottom = true;
|
||||
let (received, incoming) = (false, false);
|
||||
let ts_sort = chat_id
|
||||
let ts_sort = private_chat_id
|
||||
.calc_sort_timestamp(context, 0, sort_to_bottom, received, incoming)
|
||||
.await?;
|
||||
if chat_id.is_protected(context).await? == ProtectionStatus::Unprotected {
|
||||
if private_chat_id.is_protected(context).await? == ProtectionStatus::Unprotected {
|
||||
let ts_start = time();
|
||||
chat::add_info_msg_with_cmd(
|
||||
context,
|
||||
chat_id,
|
||||
private_chat_id,
|
||||
&stock_str::securejoin_wait(context).await,
|
||||
SystemMessage::SecurejoinWait,
|
||||
ts_sort,
|
||||
@@ -138,7 +193,7 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(chat_id)
|
||||
Ok(private_chat_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,7 +259,7 @@ pub(super) async fn handle_auth_required(
|
||||
.await?;
|
||||
|
||||
match invite {
|
||||
QrInvite::Contact { .. } => {}
|
||||
QrInvite::Contact { .. } | QrInvite::Broadcast { .. } => {}
|
||||
QrInvite::Group { .. } => {
|
||||
// The message reads "Alice replied, waiting to be added to the group…",
|
||||
// so only show it on secure-join and not on setup-contact.
|
||||
@@ -253,19 +308,19 @@ pub(crate) async fn send_handshake_message(
|
||||
) -> Result<()> {
|
||||
let mut msg = Message {
|
||||
viewtype: Viewtype::Text,
|
||||
text: step.body_text(invite),
|
||||
text: step.body_text(invite)?,
|
||||
hidden: true,
|
||||
..Default::default()
|
||||
};
|
||||
msg.param.set_cmd(SystemMessage::SecurejoinMessage);
|
||||
|
||||
// Sends the step in Secure-Join header.
|
||||
msg.param.set(Param::Arg, step.securejoin_header(invite));
|
||||
msg.param.set(Param::Arg, step.securejoin_header(invite)?);
|
||||
|
||||
match step {
|
||||
BobHandshakeMsg::Request => {
|
||||
// Sends the Secure-Join-Invitenumber header in mimefactory.rs.
|
||||
msg.param.set(Param::Arg2, invite.invitenumber());
|
||||
msg.param.set_optional(Param::Arg2, invite.invitenumber());
|
||||
msg.force_plaintext();
|
||||
}
|
||||
BobHandshakeMsg::RequestWithAuth => {
|
||||
@@ -299,7 +354,7 @@ pub(crate) async fn send_handshake_message(
|
||||
pub(crate) enum BobHandshakeMsg {
|
||||
/// vc-request or vg-request
|
||||
Request,
|
||||
/// vc-request-with-auth or vg-request-with-auth
|
||||
/// vc-request-with-auth, vg-request-with-auth, or vb-request-with-auth
|
||||
RequestWithAuth,
|
||||
}
|
||||
|
||||
@@ -309,8 +364,8 @@ impl BobHandshakeMsg {
|
||||
/// This text has no significance to the protocol, but would be visible if users see
|
||||
/// this email message directly, e.g. when accessing their email without using
|
||||
/// DeltaChat.
|
||||
fn body_text(&self, invite: &QrInvite) -> String {
|
||||
format!("Secure-Join: {}", self.securejoin_header(invite))
|
||||
fn body_text(&self, invite: &QrInvite) -> Result<String> {
|
||||
Ok(format!("Secure-Join: {}", self.securejoin_header(invite)?))
|
||||
}
|
||||
|
||||
/// Returns the `Secure-Join` header value.
|
||||
@@ -318,17 +373,22 @@ impl BobHandshakeMsg {
|
||||
/// This identifies the step this message is sending information about. Most protocol
|
||||
/// steps include additional information into other headers, see
|
||||
/// [`send_handshake_message`] for these.
|
||||
fn securejoin_header(&self, invite: &QrInvite) -> &'static str {
|
||||
match self {
|
||||
fn securejoin_header(&self, invite: &QrInvite) -> Result<&'static str> {
|
||||
let res = match self {
|
||||
Self::Request => match invite {
|
||||
QrInvite::Contact { .. } => "vc-request",
|
||||
QrInvite::Group { .. } => "vg-request",
|
||||
QrInvite::Broadcast { .. } => {
|
||||
bail!("There is no request-with-auth for broadcasts")
|
||||
}
|
||||
},
|
||||
Self::RequestWithAuth => match invite {
|
||||
QrInvite::Contact { .. } => "vc-request-with-auth",
|
||||
QrInvite::Group { .. } => "vg-request-with-auth",
|
||||
QrInvite::Broadcast { .. } => "vb-request-with-auth",
|
||||
},
|
||||
}
|
||||
};
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,8 +406,19 @@ async fn joining_chat_id(
|
||||
) -> Result<ChatId> {
|
||||
match invite {
|
||||
QrInvite::Contact { .. } => Ok(alice_chat_id),
|
||||
QrInvite::Group { grpid, name, .. } => {
|
||||
let group_chat_id = match chat::get_chat_id_by_grpid(context, grpid).await? {
|
||||
QrInvite::Group { grpid, name, .. }
|
||||
| QrInvite::Broadcast {
|
||||
broadcast_name: name,
|
||||
grpid,
|
||||
..
|
||||
} => {
|
||||
let chattype = if matches!(invite, QrInvite::Group { .. }) {
|
||||
Chattype::Group
|
||||
} else {
|
||||
Chattype::InBroadcast
|
||||
};
|
||||
|
||||
let chat_id = match chat::get_chat_id_by_grpid(context, grpid).await? {
|
||||
Some((chat_id, _protected, _blocked)) => {
|
||||
chat_id.unblock_ex(context, Nosync).await?;
|
||||
chat_id
|
||||
@@ -355,18 +426,18 @@ async fn joining_chat_id(
|
||||
None => {
|
||||
ChatId::create_multiuser_record(
|
||||
context,
|
||||
Chattype::Group,
|
||||
chattype,
|
||||
grpid,
|
||||
name,
|
||||
Blocked::Not,
|
||||
ProtectionStatus::Unprotected, // protection is added later as needed
|
||||
None,
|
||||
create_smeared_timestamp(context),
|
||||
smeared_time(context),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(group_chat_id)
|
||||
Ok(chat_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub enum QrInvite {
|
||||
Contact {
|
||||
contact_id: ContactId,
|
||||
fingerprint: Fingerprint,
|
||||
invitenumber: String,
|
||||
invitenumber: Option<String>,
|
||||
authcode: String,
|
||||
},
|
||||
Group {
|
||||
@@ -26,7 +26,14 @@ pub enum QrInvite {
|
||||
fingerprint: Fingerprint,
|
||||
name: String,
|
||||
grpid: String,
|
||||
invitenumber: String,
|
||||
invitenumber: Option<String>,
|
||||
authcode: String,
|
||||
},
|
||||
Broadcast {
|
||||
contact_id: ContactId,
|
||||
fingerprint: Fingerprint,
|
||||
broadcast_name: String,
|
||||
grpid: String,
|
||||
authcode: String,
|
||||
},
|
||||
}
|
||||
@@ -38,30 +45,50 @@ impl QrInvite {
|
||||
/// translated to a contact ID.
|
||||
pub fn contact_id(&self) -> ContactId {
|
||||
match self {
|
||||
Self::Contact { contact_id, .. } | Self::Group { contact_id, .. } => *contact_id,
|
||||
Self::Contact { contact_id, .. }
|
||||
| Self::Group { contact_id, .. }
|
||||
| Self::Broadcast { contact_id, .. } => *contact_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// The fingerprint of the inviter.
|
||||
pub fn fingerprint(&self) -> &Fingerprint {
|
||||
match self {
|
||||
Self::Contact { fingerprint, .. } | Self::Group { fingerprint, .. } => fingerprint,
|
||||
Self::Contact { fingerprint, .. }
|
||||
| Self::Group { fingerprint, .. }
|
||||
| Self::Broadcast { fingerprint, .. } => fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `INVITENUMBER` of the setup-contact/secure-join protocol.
|
||||
pub fn invitenumber(&self) -> &str {
|
||||
pub fn invitenumber(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => invitenumber,
|
||||
Self::Contact { invitenumber, .. } | Self::Group { invitenumber, .. } => {
|
||||
invitenumber.as_deref()
|
||||
}
|
||||
Self::Broadcast { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `AUTH` code of the setup-contact/secure-join protocol.
|
||||
pub fn authcode(&self) -> &str {
|
||||
match self {
|
||||
Self::Contact { authcode, .. } | Self::Group { authcode, .. } => authcode,
|
||||
Self::Contact { authcode, .. }
|
||||
| Self::Group { authcode, .. }
|
||||
| Self::Broadcast { authcode, .. } => authcode,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this QR code uses the faster "version 2" protocol,
|
||||
/// where the first message from Bob to Alice is symmetrically encrypted
|
||||
/// with the AUTH code.
|
||||
/// We may decide in the future to backwards-compatibly mark QR codes as V2,
|
||||
/// but for now, everything without an invite number
|
||||
/// is definitely V2,
|
||||
/// because the invite number is needed for V1.
|
||||
pub(crate) fn is_v2(&self) -> bool {
|
||||
self.invitenumber().is_none()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Qr> for QrInvite {
|
||||
@@ -77,7 +104,7 @@ impl TryFrom<Qr> for QrInvite {
|
||||
} => Ok(QrInvite::Contact {
|
||||
contact_id,
|
||||
fingerprint,
|
||||
invitenumber,
|
||||
invitenumber: Some(invitenumber),
|
||||
authcode,
|
||||
}),
|
||||
Qr::AskVerifyGroup {
|
||||
@@ -92,10 +119,23 @@ impl TryFrom<Qr> for QrInvite {
|
||||
fingerprint,
|
||||
name: grpname,
|
||||
grpid,
|
||||
invitenumber,
|
||||
invitenumber: Some(invitenumber),
|
||||
authcode,
|
||||
}),
|
||||
_ => bail!("Unsupported QR type"),
|
||||
Qr::AskJoinBroadcast {
|
||||
broadcast_name,
|
||||
grpid,
|
||||
contact_id,
|
||||
fingerprint,
|
||||
authcode,
|
||||
} => Ok(QrInvite::Broadcast {
|
||||
broadcast_name,
|
||||
grpid,
|
||||
contact_id,
|
||||
fingerprint,
|
||||
authcode,
|
||||
}),
|
||||
_ => bail!("Unsupported QR type: {qr:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ use crate::chat::{CantSendReason, remove_contact_from_chat};
|
||||
use crate::chatlist::Chatlist;
|
||||
use crate::constants::Chattype;
|
||||
use crate::key::self_fingerprint;
|
||||
use crate::mimeparser::GossipedKey;
|
||||
use crate::receive_imf::receive_imf;
|
||||
use crate::stock_str::{self, messages_e2e_encrypted};
|
||||
use crate::test_utils::{
|
||||
TestContext, TestContextManager, TimeShiftFalsePositiveNote, get_chat_msg,
|
||||
AVATAR_64x64_BYTES, AVATAR_64x64_DEDUPLICATED, TestContext, TestContextManager,
|
||||
TimeShiftFalsePositiveNote, get_chat_msg,
|
||||
};
|
||||
use crate::tools::SystemTime;
|
||||
use std::time::Duration;
|
||||
@@ -185,7 +187,10 @@ async fn test_setup_contact_ex(case: SetupContactCase) {
|
||||
);
|
||||
|
||||
if case == SetupContactCase::WrongAliceGossip {
|
||||
let wrong_pubkey = load_self_public_key(&bob).await.unwrap();
|
||||
let wrong_pubkey = GossipedKey {
|
||||
public_key: load_self_public_key(&bob).await.unwrap(),
|
||||
verified: false,
|
||||
};
|
||||
let alice_pubkey = msg
|
||||
.gossiped_keys
|
||||
.insert(alice_addr.to_string(), wrong_pubkey)
|
||||
@@ -361,6 +366,29 @@ async fn test_setup_contact_bob_knows_alice() -> Result<()> {
|
||||
alice.recv_msg_trash(&sent).await;
|
||||
assert_eq!(contact_bob.is_verified(alice).await?, true);
|
||||
|
||||
// Check Alice signalled success via the SecurejoinInviterProgress event.
|
||||
let event = alice
|
||||
.evtracker
|
||||
.get_matching(|evt| {
|
||||
matches!(
|
||||
evt,
|
||||
EventType::SecurejoinInviterProgress { progress: 1000, .. }
|
||||
)
|
||||
})
|
||||
.await;
|
||||
match event {
|
||||
EventType::SecurejoinInviterProgress {
|
||||
contact_id,
|
||||
chat_type,
|
||||
progress,
|
||||
} => {
|
||||
assert_eq!(contact_id, contact_bob.id);
|
||||
assert_eq!(chat_type, Chattype::Single);
|
||||
assert_eq!(progress, 1000);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
let msg = bob.parse_msg(&sent).await;
|
||||
assert!(msg.was_encrypted());
|
||||
@@ -511,6 +539,29 @@ async fn test_secure_join() -> Result<()> {
|
||||
alice.recv_msg_trash(&sent).await;
|
||||
assert_eq!(contact_bob.is_verified(&alice).await?, true);
|
||||
|
||||
// Check Alice signalled success via the SecurejoinInviterProgress event.
|
||||
let event = alice
|
||||
.evtracker
|
||||
.get_matching(|evt| {
|
||||
matches!(
|
||||
evt,
|
||||
EventType::SecurejoinInviterProgress { progress: 1000, .. }
|
||||
)
|
||||
})
|
||||
.await;
|
||||
match event {
|
||||
EventType::SecurejoinInviterProgress {
|
||||
contact_id,
|
||||
chat_type,
|
||||
progress,
|
||||
} => {
|
||||
assert_eq!(contact_id, contact_bob.id);
|
||||
assert_eq!(chat_type, Chattype::Group);
|
||||
assert_eq!(progress, 1000);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
let sent = alice.pop_sent_msg().await;
|
||||
let msg = bob.parse_msg(&sent).await;
|
||||
assert!(msg.was_encrypted());
|
||||
@@ -634,7 +685,7 @@ async fn test_unknown_sender() -> Result<()> {
|
||||
// The message from Bob is delivered late, Bob is already removed.
|
||||
let msg = alice.recv_msg(&sent).await;
|
||||
assert_eq!(msg.text, "Hi hi!");
|
||||
assert_eq!(msg.error.unwrap(), "Unknown sender for this chat.");
|
||||
assert_eq!(msg.get_override_sender_name().unwrap(), "bob@example.net");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -819,3 +870,72 @@ async fn test_wrong_auth_token() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_send_avatar_in_securejoin() -> Result<()> {
|
||||
async fn exec_securejoin_group(
|
||||
tcm: &TestContextManager,
|
||||
scanner: &TestContext,
|
||||
scanned: &TestContext,
|
||||
) {
|
||||
let chat_id = chat::create_group_chat(scanned, ProtectionStatus::Protected, "group")
|
||||
.await
|
||||
.unwrap();
|
||||
let qr = get_securejoin_qr(scanned, Some(chat_id)).await.unwrap();
|
||||
tcm.exec_securejoin_qr(scanner, scanned, &qr).await;
|
||||
}
|
||||
async fn exec_securejoin_broadcast(
|
||||
tcm: &TestContextManager,
|
||||
scanner: &TestContext,
|
||||
scanned: &TestContext,
|
||||
) {
|
||||
let chat_id = chat::create_broadcast(scanned, "group".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let qr = get_securejoin_qr(scanned, Some(chat_id)).await.unwrap();
|
||||
tcm.exec_securejoin_qr(scanner, scanned, &qr).await;
|
||||
}
|
||||
|
||||
for round in 0..6 {
|
||||
let mut tcm = TestContextManager::new();
|
||||
let alice = &tcm.alice().await;
|
||||
let bob = &tcm.bob().await;
|
||||
|
||||
let file = alice.dir.path().join("avatar.png");
|
||||
tokio::fs::write(&file, AVATAR_64x64_BYTES).await?;
|
||||
alice
|
||||
.set_config(Config::Selfavatar, Some(file.to_str().unwrap()))
|
||||
.await?;
|
||||
|
||||
match round {
|
||||
0 => {
|
||||
tcm.execute_securejoin(alice, bob).await;
|
||||
}
|
||||
1 => {
|
||||
tcm.execute_securejoin(bob, alice).await;
|
||||
}
|
||||
2 => {
|
||||
exec_securejoin_group(&tcm, alice, bob).await;
|
||||
}
|
||||
3 => {
|
||||
exec_securejoin_group(&tcm, bob, alice).await;
|
||||
}
|
||||
4 => {
|
||||
exec_securejoin_broadcast(&tcm, alice, bob).await;
|
||||
}
|
||||
5 => {
|
||||
exec_securejoin_broadcast(&tcm, bob, alice).await;
|
||||
}
|
||||
_ => panic!(),
|
||||
}
|
||||
|
||||
let alice_on_bob = bob.add_or_lookup_contact_no_key(alice).await;
|
||||
let avatar = alice_on_bob.get_profile_image(bob).await?.unwrap();
|
||||
assert_eq!(
|
||||
avatar.file_name().unwrap().to_str().unwrap(),
|
||||
AVATAR_64x64_DEDUPLICATED
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
10
src/smtp.rs
10
src/smtp.rs
@@ -87,7 +87,7 @@ impl Smtp {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.connectivity.set_connecting(context).await;
|
||||
self.connectivity.set_connecting(context);
|
||||
let lp = ConfiguredLoginParam::load(context)
|
||||
.await?
|
||||
.context("Not configured")?;
|
||||
@@ -187,7 +187,7 @@ pub(crate) async fn smtp_send(
|
||||
info!(context, "SMTP-sending out mime message:\n{message}");
|
||||
}
|
||||
|
||||
smtp.connectivity.set_working(context).await;
|
||||
smtp.connectivity.set_working(context);
|
||||
|
||||
if let Err(err) = smtp
|
||||
.connect_configured(context)
|
||||
@@ -414,7 +414,11 @@ pub(crate) async fn send_msg_to_smtp(
|
||||
.await?;
|
||||
}
|
||||
SendResult::Failure(ref err) => {
|
||||
if err.to_string().contains("Invalid unencrypted mail") {
|
||||
if err
|
||||
.to_string()
|
||||
.to_lowercase()
|
||||
.contains("invalid unencrypted mail")
|
||||
{
|
||||
let res = context
|
||||
.sql
|
||||
.query_row_optional(
|
||||
|
||||
89
src/sql.rs
89
src/sql.rs
@@ -3,7 +3,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use anyhow::{Context as _, Result, bail, ensure};
|
||||
use rusqlite::{Connection, OpenFlags, Row, config::DbConfig, types::ValueRef};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::net::http::http_cache_cleanup;
|
||||
use crate::net::prune_connection_history;
|
||||
use crate::param::{Param, Params};
|
||||
use crate::stock_str;
|
||||
use crate::tools::{SystemTime, delete_file, time};
|
||||
use crate::tools::{SystemTime, Time, delete_file, time, time_elapsed};
|
||||
|
||||
/// Extension to [`rusqlite::ToSql`] trait
|
||||
/// which also includes [`Send`] and [`Sync`].
|
||||
@@ -175,10 +175,12 @@ impl Sql {
|
||||
.await
|
||||
}
|
||||
|
||||
const N_DB_CONNECTIONS: usize = 3;
|
||||
|
||||
/// Creates a new connection pool.
|
||||
fn new_pool(dbfile: &Path, passphrase: String) -> Result<Pool> {
|
||||
let mut connections = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let mut connections = Vec::with_capacity(Self::N_DB_CONNECTIONS);
|
||||
for _ in 0..Self::N_DB_CONNECTIONS {
|
||||
let connection = new_connection(dbfile, &passphrase)?;
|
||||
connections.push(connection);
|
||||
}
|
||||
@@ -637,6 +639,77 @@ impl Sql {
|
||||
pub fn config_cache(&self) -> &RwLock<HashMap<String, Option<String>>> {
|
||||
&self.config_cache
|
||||
}
|
||||
|
||||
/// Runs a checkpoint operation in TRUNCATE mode, so the WAL file is truncated to 0 bytes.
|
||||
pub(crate) async fn wal_checkpoint(context: &Context) -> Result<()> {
|
||||
let t_start = Time::now();
|
||||
let lock = context.sql.pool.read().await;
|
||||
let Some(pool) = lock.as_ref() else {
|
||||
// No db connections, nothing to checkpoint.
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Do as much work as possible without blocking anybody.
|
||||
let query_only = true;
|
||||
let conn = pool.get(query_only).await?;
|
||||
tokio::task::block_in_place(|| {
|
||||
// Execute some transaction causing the WAL file to be opened so that the
|
||||
// `wal_checkpoint()` can proceed, otherwise it fails when called the first time,
|
||||
// see https://sqlite.org/forum/forumpost/7512d76a05268fc8.
|
||||
conn.query_row("PRAGMA table_list", [], |_| Ok(()))?;
|
||||
conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |_| Ok(()))
|
||||
})?;
|
||||
|
||||
// Kick out writers.
|
||||
const _: () = assert!(Sql::N_DB_CONNECTIONS > 1, "Deadlock possible");
|
||||
let _write_lock = pool.write_lock().await;
|
||||
let t_writers_blocked = Time::now();
|
||||
// Ensure that all readers use the most recent database snapshot (are at the end of WAL) so
|
||||
// that `wal_checkpoint(FULL)` isn't blocked. We could use `PASSIVE` as well, but it's
|
||||
// documented poorly, https://www.sqlite.org/pragma.html#pragma_wal_checkpoint and
|
||||
// https://www.sqlite.org/c3ref/wal_checkpoint_v2.html don't tell how it interacts with new
|
||||
// readers.
|
||||
let mut read_conns = Vec::with_capacity(Self::N_DB_CONNECTIONS - 1);
|
||||
for _ in 0..(Self::N_DB_CONNECTIONS - 1) {
|
||||
read_conns.push(pool.get(query_only).await?);
|
||||
}
|
||||
read_conns.clear();
|
||||
// Checkpoint the remaining WAL pages without blocking readers.
|
||||
let (pages_total, pages_checkpointed) = tokio::task::block_in_place(|| {
|
||||
conn.query_row("PRAGMA wal_checkpoint(FULL)", [], |row| {
|
||||
let pages_total: i64 = row.get(1)?;
|
||||
let pages_checkpointed: i64 = row.get(2)?;
|
||||
Ok((pages_total, pages_checkpointed))
|
||||
})
|
||||
})?;
|
||||
if pages_checkpointed < pages_total {
|
||||
warn!(
|
||||
context,
|
||||
"Cannot checkpoint whole WAL. Pages total: {pages_total}, checkpointed: {pages_checkpointed}. Make sure there are no external connections running transactions.",
|
||||
);
|
||||
}
|
||||
// Kick out readers to avoid blocking/SQLITE_BUSY.
|
||||
for _ in 0..(Self::N_DB_CONNECTIONS - 1) {
|
||||
read_conns.push(pool.get(query_only).await?);
|
||||
}
|
||||
let t_readers_blocked = Time::now();
|
||||
tokio::task::block_in_place(|| {
|
||||
let blocked = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
|
||||
let blocked: i64 = row.get(0)?;
|
||||
Ok(blocked)
|
||||
})?;
|
||||
ensure!(blocked == 0);
|
||||
Ok(())
|
||||
})?;
|
||||
info!(
|
||||
context,
|
||||
"wal_checkpoint: Total time: {:?}. Writers blocked for: {:?}. Readers blocked for: {:?}.",
|
||||
time_elapsed(&t_start),
|
||||
time_elapsed(&t_writers_blocked),
|
||||
time_elapsed(&t_readers_blocked),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new SQLite connection.
|
||||
@@ -760,6 +833,14 @@ pub async fn housekeeping(context: &Context) -> Result<()> {
|
||||
if let Err(err) = incremental_vacuum(context).await {
|
||||
warn!(context, "Failed to run incremental vacuum: {err:#}.");
|
||||
}
|
||||
// Work around possible checkpoint starvations (there were cases reported when a WAL file is
|
||||
// bigger than 200M) and also make sure we truncate the WAL periodically. Auto-checkponting does
|
||||
// not normally truncate the WAL (unless the `journal_size_limit` pragma is set), see
|
||||
// https://www.sqlite.org/wal.html.
|
||||
if let Err(err) = Sql::wal_checkpoint(context).await {
|
||||
warn!(context, "wal_checkpoint() failed: {err:#}.");
|
||||
debug_assert!(false);
|
||||
}
|
||||
|
||||
context
|
||||
.sql
|
||||
|
||||
@@ -1261,6 +1261,18 @@ CREATE INDEX gossip_timestamp_index ON gossip_timestamp (chat_id, fingerprint);
|
||||
.await?;
|
||||
}
|
||||
|
||||
inc_and_check(&mut migration_version, 134)?;
|
||||
if dbversion < migration_version {
|
||||
sql.execute_migration(
|
||||
"CREATE TABLE broadcasts_shared_secrets(
|
||||
chat_id INTEGER PRIMARY KEY NOT NULL,
|
||||
secret TEXT NOT NULL
|
||||
) STRICT",
|
||||
migration_version,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let new_version = sql
|
||||
.get_raw_config_int(VERSION_CFG)
|
||||
.await?
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
use crate::chat;
|
||||
use crate::chat::ChatId;
|
||||
use crate::config::Config;
|
||||
use crate::constants;
|
||||
use crate::contact::Contact;
|
||||
use crate::contact::ContactId;
|
||||
use crate::contact::Origin;
|
||||
@@ -43,19 +44,11 @@ async fn test_key_contacts_migration_autocrypt() -> Result<()> {
|
||||
t.sql.run_migrations(&t).await?;
|
||||
|
||||
//std::thread::sleep(std::time::Duration::from_secs(1000));
|
||||
let email_bob_id = Contact::lookup_id_by_addr(&t, "bob@example.net", Origin::Hidden)
|
||||
let pgp_bob_id = Contact::lookup_id_by_addr(&t, "bob@example.net", Origin::Hidden)
|
||||
.await?
|
||||
.unwrap();
|
||||
let email_bob = Contact::get_by_id(&t, email_bob_id).await?;
|
||||
assert_eq!(email_bob.is_key_contact(), false);
|
||||
assert_eq!(email_bob.origin, Origin::Hidden); // Email bob is in no chats, so, contact is hidden
|
||||
assert_eq!(email_bob.e2ee_avail(&t).await?, false);
|
||||
assert_eq!(email_bob.fingerprint(), None);
|
||||
assert_eq!(email_bob.get_verifier_id(&t).await?, None);
|
||||
|
||||
let bob_chat_contacts = chat::get_chat_contacts(&t, ChatId::new(10)).await?;
|
||||
let pgp_bob_id = tools::single_value(bob_chat_contacts).unwrap();
|
||||
let pgp_bob = Contact::get_by_id(&t, pgp_bob_id).await?;
|
||||
assert_eq!(pgp_bob.is_key_contact(), true);
|
||||
assert_eq!(pgp_bob.origin, Origin::OutgoingTo);
|
||||
assert_eq!(pgp_bob.e2ee_avail(&t).await?, true);
|
||||
assert_eq!(
|
||||
@@ -64,6 +57,16 @@ async fn test_key_contacts_migration_autocrypt() -> Result<()> {
|
||||
);
|
||||
assert_eq!(pgp_bob.get_verifier_id(&t).await?, None);
|
||||
|
||||
// Hidden address-contact can't be looked up.
|
||||
assert!(
|
||||
Contact::get_all(&t, constants::DC_GCL_ADDRESS, Some("bob@example.net"))
|
||||
.await?
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let bob_chat_contacts = chat::get_chat_contacts(&t, ChatId::new(10)).await?;
|
||||
assert_eq!(tools::single_value(bob_chat_contacts).unwrap(), pgp_bob_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -82,8 +85,9 @@ async fn test_key_contacts_migration_email1() -> Result<()> {
|
||||
)?)).await?;
|
||||
t.sql.run_migrations(&t).await?;
|
||||
|
||||
let email_bob_id = Contact::lookup_id_by_addr(&t, "bob@example.net", Origin::Hidden)
|
||||
let email_bob_id = *Contact::get_all(&t, constants::DC_GCL_ADDRESS, Some("bob@example.net"))
|
||||
.await?
|
||||
.first()
|
||||
.unwrap();
|
||||
let email_bob = Contact::get_by_id(&t, email_bob_id).await?;
|
||||
assert_eq!(email_bob.is_key_contact(), false);
|
||||
@@ -112,12 +116,23 @@ async fn test_key_contacts_migration_email2() -> Result<()> {
|
||||
)?)).await?;
|
||||
t.sql.run_migrations(&t).await?;
|
||||
|
||||
let email_bob_id = Contact::lookup_id_by_addr(&t, "bob@example.net", Origin::Hidden)
|
||||
// Hidden key-contact can't be looked up.
|
||||
assert!(
|
||||
Contact::get_all(&t, 0, Some("bob@example.net"))
|
||||
.await?
|
||||
.is_empty()
|
||||
);
|
||||
let pgp_bob = Contact::get_by_id(&t, ContactId::new(11)).await?;
|
||||
assert_eq!(pgp_bob.is_key_contact(), true);
|
||||
assert_eq!(pgp_bob.origin, Origin::Hidden);
|
||||
|
||||
let email_bob_id = *Contact::get_all(&t, constants::DC_GCL_ADDRESS, Some("bob@example.net"))
|
||||
.await?
|
||||
.first()
|
||||
.unwrap();
|
||||
let email_bob = Contact::get_by_id(&t, email_bob_id).await?;
|
||||
assert_eq!(email_bob.is_key_contact(), false);
|
||||
assert_eq!(email_bob.origin, Origin::OutgoingTo); // Email bob is in no chats, so, contact is hidden
|
||||
assert_eq!(email_bob.origin, Origin::OutgoingTo);
|
||||
assert_eq!(email_bob.e2ee_avail(&t).await?, false);
|
||||
assert_eq!(email_bob.fingerprint(), None);
|
||||
assert_eq!(email_bob.get_verifier_id(&t).await?, None);
|
||||
@@ -146,16 +161,12 @@ async fn test_key_contacts_migration_verified() -> Result<()> {
|
||||
)?)).await?;
|
||||
t.sql.run_migrations(&t).await?;
|
||||
|
||||
let email_bob_id = Contact::lookup_id_by_addr(&t, "bob@example.net", Origin::Hidden)
|
||||
.await?
|
||||
.unwrap();
|
||||
let email_bob = Contact::get_by_id(&t, email_bob_id).await?;
|
||||
dbg!(&email_bob);
|
||||
assert_eq!(email_bob.is_key_contact(), false);
|
||||
assert_eq!(email_bob.origin, Origin::Hidden); // Email bob is in no chats, so, contact is hidden
|
||||
assert_eq!(email_bob.e2ee_avail(&t).await?, false);
|
||||
assert_eq!(email_bob.fingerprint(), None);
|
||||
assert_eq!(email_bob.get_verifier_id(&t).await?, None);
|
||||
// Hidden address-contact can't be looked up.
|
||||
assert!(
|
||||
Contact::get_all(&t, constants::DC_GCL_ADDRESS, Some("bob@example.net"))
|
||||
.await?
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let mut bob_chat_contacts = chat::get_chat_contacts(&t, ChatId::new(10)).await?;
|
||||
assert_eq!(bob_chat_contacts.len(), 2);
|
||||
|
||||
@@ -67,7 +67,7 @@ struct InnerPool {
|
||||
///
|
||||
/// This mutex is locked when write connection
|
||||
/// is outside the pool.
|
||||
write_mutex: Arc<Mutex<()>>,
|
||||
pub(crate) write_mutex: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl InnerPool {
|
||||
@@ -96,13 +96,13 @@ impl InnerPool {
|
||||
.pop()
|
||||
.context("Got a permit when there are no connections in the pool")?
|
||||
};
|
||||
conn.pragma_update(None, "query_only", "1")?;
|
||||
let conn = PooledConnection {
|
||||
pool: Arc::downgrade(&self),
|
||||
conn: Some(conn),
|
||||
_permit: permit,
|
||||
_write_mutex_guard: None,
|
||||
};
|
||||
conn.pragma_update(None, "query_only", "1")?;
|
||||
Ok(conn)
|
||||
} else {
|
||||
// We get write guard first to avoid taking a permit
|
||||
@@ -119,13 +119,13 @@ impl InnerPool {
|
||||
"Got a permit and write lock when there are no connections in the pool",
|
||||
)?
|
||||
};
|
||||
conn.pragma_update(None, "query_only", "0")?;
|
||||
let conn = PooledConnection {
|
||||
pool: Arc::downgrade(&self),
|
||||
conn: Some(conn),
|
||||
_permit: permit,
|
||||
_write_mutex_guard: Some(write_mutex_guard),
|
||||
};
|
||||
conn.pragma_update(None, "query_only", "0")?;
|
||||
Ok(conn)
|
||||
}
|
||||
}
|
||||
@@ -195,4 +195,12 @@ impl Pool {
|
||||
pub async fn get(&self, query_only: bool) -> Result<PooledConnection> {
|
||||
Arc::clone(&self.inner).get(query_only).await
|
||||
}
|
||||
|
||||
/// Returns a mutex guard guaranteeing that there are no concurrent write connections.
|
||||
///
|
||||
/// NB: Make sure you're not holding all connections when calling this, otherwise it deadlocks
|
||||
/// if there is a concurrent writer waiting for available connection.
|
||||
pub(crate) async fn write_lock(&self) -> OwnedMutexGuard<()> {
|
||||
Arc::clone(&self.inner.write_mutex).lock_owned().await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,9 +123,6 @@ pub enum StockMessage {
|
||||
however, of course, if they like, you may point them to 👉 https://get.delta.chat"))]
|
||||
WelcomeMessage = 71,
|
||||
|
||||
#[strum(props(fallback = "Unknown sender for this chat."))]
|
||||
UnknownSenderForChat = 72,
|
||||
|
||||
#[strum(props(fallback = "Message from %1$s"))]
|
||||
SubjectForNewContact = 73,
|
||||
|
||||
@@ -362,6 +359,12 @@ pub enum StockMessage {
|
||||
#[strum(props(fallback = "Message deletion timer is set to %1$s weeks by %2$s."))]
|
||||
MsgEphemeralTimerWeeksBy = 157,
|
||||
|
||||
#[strum(props(fallback = "You set message deletion timer to 1 year."))]
|
||||
MsgYouEphemeralTimerYear = 158,
|
||||
|
||||
#[strum(props(fallback = "Message deletion timer is set to 1 year by %1$s."))]
|
||||
MsgEphemeralTimerYearBy = 159,
|
||||
|
||||
#[strum(props(fallback = "Scan to set up second device for %1$s"))]
|
||||
BackupTransferQr = 162,
|
||||
|
||||
@@ -662,9 +665,9 @@ pub(crate) async fn msg_del_member_remote(context: &Context, removed_member_addr
|
||||
.replace1(whom)
|
||||
}
|
||||
|
||||
/// Stock string: `I added member %1$s.` or `Member %1$s removed by %2$s.`.
|
||||
/// Stock string: `Member %1$s removed.`, `You removed member %1$s.` or `Member %1$s removed by %2$s.`
|
||||
///
|
||||
/// The `removed_member_addr` parameter should be an email address and is looked up in
|
||||
/// The `removed_member` and `by_contact` parameter is looked up in
|
||||
/// the contacts to combine with the display name.
|
||||
pub(crate) async fn msg_del_member_local(
|
||||
context: &Context,
|
||||
@@ -903,11 +906,6 @@ pub(crate) async fn welcome_message(context: &Context) -> String {
|
||||
translated(context, StockMessage::WelcomeMessage).await
|
||||
}
|
||||
|
||||
/// Stock string: `Unknown sender for this chat.`.
|
||||
pub(crate) async fn unknown_sender_for_chat(context: &Context) -> String {
|
||||
translated(context, StockMessage::UnknownSenderForChat).await
|
||||
}
|
||||
|
||||
/// Stock string: `Message from %1$s`.
|
||||
// TODO: This can compute `self_name` itself instead of asking the caller to do this.
|
||||
pub(crate) async fn subject_for_new_contact(context: &Context, self_name: &str) -> String {
|
||||
@@ -992,6 +990,17 @@ pub(crate) async fn msg_ephemeral_timer_week(context: &Context, by_contact: Cont
|
||||
}
|
||||
}
|
||||
|
||||
/// Stock string: `Message deletion timer is set to 1 year.`.
|
||||
pub(crate) async fn msg_ephemeral_timer_year(context: &Context, by_contact: ContactId) -> String {
|
||||
if by_contact == ContactId::SELF {
|
||||
translated(context, StockMessage::MsgYouEphemeralTimerYear).await
|
||||
} else {
|
||||
translated(context, StockMessage::MsgEphemeralTimerYearBy)
|
||||
.await
|
||||
.replace1(&by_contact.get_stock_name(context).await)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stock string: `Video chat invitation`.
|
||||
pub(crate) async fn videochat_invitation(context: &Context) -> String {
|
||||
translated(context, StockMessage::VideochatInvitation).await
|
||||
|
||||
@@ -97,7 +97,7 @@ impl Summary {
|
||||
let prefix = if msg.state == MessageState::OutDraft {
|
||||
Some(SummaryPrefix::Draft(stock_str::draft(context).await))
|
||||
} else if msg.from_id == ContactId::SELF {
|
||||
if msg.is_info() {
|
||||
if msg.is_info() || msg.viewtype == Viewtype::Call {
|
||||
None
|
||||
} else {
|
||||
Some(SummaryPrefix::Me(stock_str::self_msg(context).await))
|
||||
@@ -233,6 +233,16 @@ impl Message {
|
||||
type_file = self.param.get(Param::Summary1).map(|s| s.to_string());
|
||||
append_text = true;
|
||||
}
|
||||
Viewtype::Call => {
|
||||
emoji = Some("📞");
|
||||
type_name = Some(if self.from_id == ContactId::SELF {
|
||||
"Outgoing call".to_string()
|
||||
} else {
|
||||
"Incoming call".to_string()
|
||||
});
|
||||
type_file = None;
|
||||
append_text = false
|
||||
}
|
||||
Viewtype::Text | Viewtype::Unknown => {
|
||||
emoji = None;
|
||||
if self.param.get_cmd() == SystemMessage::LocationOnly {
|
||||
|
||||
18
src/sync.rs
18
src/sync.rs
@@ -1,6 +1,6 @@
|
||||
//! # Synchronize items between devices.
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context as _, Result};
|
||||
use mail_builder::mime::MimePart;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -270,6 +270,7 @@ impl Context {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
.with_context(|| format!("Sync data {:?}", item.data))
|
||||
.log_err(self)
|
||||
.ok();
|
||||
}
|
||||
@@ -292,8 +293,15 @@ impl Context {
|
||||
}
|
||||
|
||||
async fn delete_qr_token(&self, token: &QrTokenData) -> Result<()> {
|
||||
token::delete(self, Namespace::InviteNumber, &token.invitenumber).await?;
|
||||
token::delete(self, Namespace::Auth, &token.auth).await?;
|
||||
self.sql
|
||||
.execute(
|
||||
"DELETE FROM tokens
|
||||
WHERE foreign_key IN
|
||||
(SELECT foreign_key FROM tokens
|
||||
WHERE token=? OR token=?)",
|
||||
(&token.invitenumber, &token.auth),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -564,8 +572,8 @@ mod tests {
|
||||
.await?
|
||||
.is_none()
|
||||
);
|
||||
assert!(token::exists(&t, Namespace::InviteNumber, "yip-in").await?);
|
||||
assert!(token::exists(&t, Namespace::Auth, "yip-auth").await?);
|
||||
assert!(!token::exists(&t, Namespace::InviteNumber, "yip-in").await?);
|
||||
assert!(!token::exists(&t, Namespace::Auth, "yip-auth").await?);
|
||||
assert!(!token::exists(&t, Namespace::Auth, "non-existent").await?);
|
||||
assert!(!token::exists(&t, Namespace::Auth, "directly deleted").await?);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user